Derived from
SPEC.md(normative). This plan covers phases, module order, dependencies, testing, risk, and validation checkpoints.
| Item | Detail |
|---|---|
| Goal | Buildable, installable Python package skeleton |
| Outputs | pyproject.toml, cli_core_yo/__init__.py, .gitignore, empty test scaffold |
| Risk | Low |
Tasks:
pyproject.tomlwithsetuptools_scm(per global rule06_github.md), pinned deps:typer>=0.21.0,<0.22.0rich>=14.0.0,<15.0.0click>=8.3.0,<9.0.0
cli_core_yo/__init__.pywith version import (from_version.pygenerated bysetuptools_scm).gitignore(egg-info,_version.py,__pycache__,dist/,build/,.eggs/)tests/directory withconftest.py- Validate:
pip install -e .succeeds,python -c "import cli_core_yo"succeeds
Checkpoint: Package installs in editable mode. import cli_core_yo works.
| Item | Detail |
|---|---|
| Goal | Pure dataclass and exception definitions |
| Modules | errors.py, spec.py |
| Risk | Low — pure data, no I/O |
- Define base
CliCoreYoError(Exception) ContextNotInitializedError— raised whenget_context()called before initRegistryFrozenError— raised on post-freeze registrationRegistryConflictError— raised on name collisionPluginLoadError— raised on import/registration failure- Map each to exit codes per §2.6
Immutable dataclasses (all frozen=True):
XdgSpec:app_dir_name: str,legacy_macos_config_dir: str | None,legacy_copy_files: list[str]ConfigSpec:primary_filename: str,template_bytes: bytes | None,template_resource: tuple[str, str] | None,validator: Callable | NoneEnvSpec:active_env_var: str,project_root_env_var: str,activate_script_name: str,deactivate_script_name: strPluginSpec:explicit: list[str],entry_points: list[str]CliSpec:prog_name: str,app_display_name: str,dist_name: str,root_help: str,xdg: XdgSpec,config: ConfigSpec | None,env: EnvSpec | None,plugins: PluginSpec,info_hooks: list[Callable]
Checkpoint: from cli_core_yo.spec import CliSpec works. Dataclasses freeze correctly.
Tests:
- Frozen dataclass immutability
- Required field enforcement
- Default values
| Item | Detail |
|---|---|
| Goal | XDG resolution, runtime context, output primitives |
| Modules | xdg.py, runtime.py, output.py |
| Risk | Medium — platform-specific XDG logic, Rich rendering |
resolve_paths(xdg_spec: XdgSpec) -> XdgPathsreturning config/data/state/cachePathobjects- Platform detection: macOS vs Linux for data/state/cache defaults (§6.4)
mkdir(parents=True, exist_ok=True)on resolution- Legacy macOS migration: copy listed files if legacy exists and XDG target does not
- Preserve file metadata on copy (
shutil.copy2)
Tests:
- Env var overrides (
XDG_CONFIG_HOME, etc.) - macOS defaults vs Linux defaults (mock
sys.platform) - Legacy migration: copy occurs, idempotent, metadata preserved
- Directory creation
RuntimeContextdataclass:spec: CliSpec,xdg_paths: XdgPaths,json_mode: bool,debug: bool- Module-level
_context: RuntimeContext | None = None initialize(spec, json_mode, debug) -> RuntimeContext— sets module-level, raises if already setget_context() -> RuntimeContext— raisesContextNotInitializedErrorif not set_reset()— test-only reset
Tests:
- Init once, get succeeds
- Get before init raises
- Double init raises
_reset()allows re-init
Consolewrapper: usesRich.Consolewithstderr=False, respectsNO_COLOR- Primitives (§6.2):
heading(title),success(msg),warning(msg),error(msg),action(msg),detail(msg),bullet(msg) emit_json(data: Any)—json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False)+\n, bypasses Rich- All primitives check
get_context().json_modeand suppress if True - Color mapping per §6.1
Tests:
- Each primitive produces correct prefix/markup
- JSON mode suppresses human output
emit_jsonproduces valid, deterministic JSONNO_COLORsuppresses ANSI codes
Checkpoint: XDG paths resolve. Context initializes. Output primitives render correctly.
| Item | Detail |
|---|---|
| Goal | Registration, validation, conflict detection, freeze semantics |
| Module | registry.py |
| Risk | Medium — ordering and conflict logic |
CommandRegistryclass- Internal storage: ordered dict of registered nodes
add_group(name, help, order)— validates name regex^[a-z][a-z0-9-]*$add_command(group_path, name, callable, help, order)— validates name, checks conflicts (§4.3)add_typer_app(group_path, typer_app, name, help, order)— registers a full Typer sub-appfreeze()— prevents further mutationsapply(app: typer.Typer)— materializes registrations onto the Typer app in deterministic order
- Conflict rules per §4.3:
- Group+Group: help must match or new help empty
| Item | Detail |
|---|---|
| Goal | Deterministic plugin discovery, import, invocation |
| Module | plugins.py |
| Risk | Medium — import machinery, entry points API |
load_plugins(spec: CliSpec, registry: CommandRegistry) -> None- Step 1: Load explicit plugins (
spec.plugins.explicit) —importlib.import_module+getattrto resolve dotted path to callable - Step 2: Load entry-point plugins (
spec.plugins.entry_points) —importlib.metadata.entry_points(group="cli_core_yo.plugins"), filter to names in spec list, load in spec list order - Each callable invoked as
callable(registry, spec) - On import failure or exception during registration: raise
PluginLoadErrorwith plugin name and original exception
Tests:
- Explicit plugin loads and registers commands
- Entry-point plugin loads (mock
importlib.metadata) - Explicit before entry-point ordering
- Import failure →
PluginLoadError - Exception during registration →
PluginLoadError - Each callable invoked exactly once
Checkpoint: Plugins load deterministically. Failures surface with clear messages.
| Item | Detail |
|---|---|
| Goal | Complete create_app / run API, built-in commands, optional groups |
| Module | app.py (plus command implementations) |
| Risk | High — integrates everything; Typer API surface |
Startup sequence (§3.5):
- Validate
CliSpec(non-empty required fields, valid regex for names) - Construct root Typer app:
name=spec.prog_name,help=spec.root_help,add_completion=True,no_args_is_help=True, no-halias - Initialize
RuntimeContext - Register built-in commands:
version,info - If
spec.confignot None → registerconfiggroup (§4.6) - If
spec.envnot None → registerenvgroup (§4.7) - Load plugins via
plugins.load_plugins() - Freeze registry
- Apply registry to Typer app
- Calls
create_app(spec), then invokes Typer - Catches exceptions → maps to exit codes (§2.6)
- MUST NOT call
sys.exit() - Debug mode (§6.6):
CLI_CORE_YO_DEBUG=1→ traceback to STDERR - SIGINT/KeyboardInterrupt → exit 130
<prog> versionprints<app_display_name> <app_version>(§2.5)- Version resolved from
importlib.metadata.version(spec.dist_name) - Cyan styling in human mode
- Exit 0
- Prints two-column table (§6.3): Version, Python, Config Dir, Data Dir, State Dir, Cache Dir, CLI Core
- CLI Core version from
importlib.metadata.version("cli-core-yo") - Invokes
spec.info_hooksin order, appending rows - Exit 0
Subcommands: path, init, show, validate, edit, reset
| Command | Key behavior |
|---|---|
config path |
Print resolved config file path, exit 0 |
config init |
Create file from template; --force to overwrite; exit 1 if exists without --force |
config show |
Print file contents; exit 1 if missing |
config validate |
Run ConfigSpec.validator; exit 0 pass / exit 1 fail |
config edit |
Open in editor (§6.5: VISUAL → EDITOR → vi); exit 1 if not interactive |
config reset |
Backup (timestamped), replace with template; --yes to skip confirmation |
Subcommands: status, activate, deactivate, reset
| Command | Key behavior |
|---|---|
env status |
Check EnvSpec.active_env_var; print active state + key paths; exit 0 |
env activate |
Print shell source command for activation script; exit 0 |
env deactivate |
Print shell source command for deactivation script; exit 0 |
env reset |
Print deactivate then activate sequence; exit 0 |
Project root detection: env var → upward pyproject.toml search from CWD.
Tests (Phase 5):
create_appreturns atyper.Typerwith correct settingsrunreturns exit codes without callingsys.exit()versionoutput format and exit codeinfooutput contains all required rowsinfohooks append extra rowsconfig pathprints pathconfig initcreates file, respects--forceconfig showprints contents, errors on missingconfig validateruns validatorconfig editlaunches editorconfig resetcreates backup, replacesenv statusreports active/inactiveenv activate/deactivate/resetprint correct shell commands- No-args → help → exit 0
- Unknown command → exit 2
--helpat every level → exit 0- SIGINT → exit 130
- Debug mode → traceback on STDERR
Checkpoint: Full CLI functional. All §2 contract behaviors pass. All §4.6/4.7 commands work.
| Item | Detail |
|---|---|
| Goal | End-to-end CLI tests via CliRunner, migration validation |
| Risk | Low — test-only phase |
- All tests use
typer.testing.CliRunner - Construct a test
CliSpecwith known values - Test matrix:
- Root help output (§2.4)
- Global flags:
--help,--install-completion,--show-completion - No
-hat root level - Command ordering in help matches registration order (§4.5)
- JSON output: valid, sorted keys, indent 2, UTF-8, trailing newline (§2.8)
- Exit codes: 0 (success), 1 (failure), 2 (usage error), 130 (interrupt)
NO_COLORsuppresses ANSICLI_CORE_YO_DEBUG=1→ traceback on STDERR
- Plugin integration test: register a mock plugin, verify commands appear
Checkpoint: All SPEC §2, §4, §6 behaviors verified by automated tests.
errors.py ←── (no deps)
spec.py ←── (no deps)
xdg.py ←── spec.py
runtime.py ←── spec.py, xdg.py
output.py ←── runtime.py
registry.py ←── errors.py, spec.py
plugins.py ←── registry.py, spec.py, errors.py
app.py ←── spec.py, runtime.py, xdg.py, output.py, registry.py, plugins.py, errors.py
Implementation order follows a topological sort of this graph.
| Module | Complexity | Risk | Mitigation |
|---|---|---|---|
spec.py |
Low | Low | Pure dataclasses, no logic |
errors.py |
Low | Low | Simple exception hierarchy |
xdg.py |
Medium | Medium | Platform-specific defaults; test with mocked sys.platform |
runtime.py |
Low | Low | Module-level singleton; straightforward |
output.py |
Medium | Medium | Rich Console integration; NO_COLOR and JSON mode interactions |
registry.py |
Medium | Medium | Conflict rules require careful testing of all §4.3 branches |
plugins.py |
Medium | Medium | Entry-point API version differences (importlib.metadata); mock in tests |
app.py |
High | High | Integrates all modules; Typer internals for -h suppression, completion, help rendering |
config group |
Medium | Medium | File I/O, editor launching, backup logic |
env group |
Low-Medium | Low | Mostly printing shell commands; project root detection needs upward search |
| Phase | Test Type | Tool | Coverage Target |
|---|---|---|---|
| 0 | Smoke | pip install -e . + import |
Package builds |
| 1 | Unit | pytest |
All dataclass fields, exception types |
| 2 | Unit | pytest |
XDG resolution, context lifecycle, output primitives |
| 3 | Unit | pytest |
All conflict branches, freeze, ordering |
| 4 | Unit | pytest (mocked imports) |
Plugin load/fail paths |
| 5 | Unit + Integration | pytest + CliRunner |
Every CLI command and subcommand, exit codes |
| 6 | Integration | pytest + CliRunner |
Full behavioral equivalence matrix |
All tests run with pytest -q and pytest --cov=cli_core_yo.
These are verified in Phase 6 but documented here for traceability:
create_appreturns correct Typer app —add_completion=True,no_args_is_help=True, rich helpversionoutput —<app_display_name> <version>format, cyan in human modeinfooutput — all §6.3 base rows present- Command ordering — built-in first, then downstream in registration order
- JSON output —
json.loads()succeeds, sorted keys, indent 2 - Exit codes — 0/1/2/130 match §2.6
-hnot reserved globally — downstream can use-hfor own options- Plugin loading — explicit before entry-point, deterministic order
- Registry freeze — post-freeze registration raises error
- Conflict detection — all §4.3 branches tested
After thorough review of SPEC.md §1–§9, the following items are noted:
| # | Item | Assessment |
|---|---|---|
| 1 | CliSpec.info_hooks signature — §6.3 says "each callable returns a list of (key, value) rows" but does not specify the callable's input signature. |
Assumption: Callable[[], list[tuple[str, str]]] (no arguments). This is the simplest contract and the spec doesn't mention any arguments being passed. Will implement this way unless directed otherwise. |
| 2 | ConfigSpec.template_resource — §4.6 says "template_bytes OR template_resource (exactly one is non-null)" but does not define template_resource format. |
Assumption: tuple[str, str] representing (package_name, resource_name) for importlib.resources. Will validate exactly one is non-null at CliSpec validation time. |
| 3 | config validate with null validator — §4.6 says "Runs validation using the downstream-provided validator hook." If ConfigSpec.validator is None, behavior is unspecified. |
Assumption: Print a success message ("No validator configured") and exit 0. |
| 4 | env activate / env deactivate shell command format — §4.7 says "Prints a shell command instructing the user to source the activation script" but does not specify the exact format. |
Assumption: Print source <path> where path is constructed from project root + script name. Compatible with bash and zsh per §2.9. |
| 5 | env status "key paths" content — §4.7 says "Prints active state and key paths (project root, python path, config dir)." "python path" is ambiguous (sys.executable? sys.path? PYTHONPATH?). |
Assumption: sys.executable (the Python interpreter path). This is the most useful single value and matches common CLI info patterns. |
| 6 | Help ordering enforcement via Typer — §4.5 requires registration-order listing. Typer sorts commands alphabetically by default. | Mitigation: Use rich_help_panel or explicit ordering via Typer's internal _commands dict manipulation, or set rich_markup_mode="rich" and manage panel order. Will investigate Typer internals during Phase 5 to find the cleanest approach. |
| 7 | run() return type — §3.3 says "Returns the integer exit code" and "MUST NOT call sys.exit() internally." Typer's app() calls sys.exit() by default. |
Mitigation: Use standalone_mode=False when invoking Typer, catch Click's SystemExit, and return the exit code. |
None of these are blocking. All have reasonable default interpretations consistent with the spec.
cli-core-yo/
├── pyproject.toml
├── .gitignore
├── LICENSE
├── README.md
├── SPEC.md
├── SPEC_IMPLEMENTATION_PLAN.md
├── cli_core_yo/
│ ├── __init__.py
│ ├── spec.py
│ ├── errors.py
│ ├── xdg.py
│ ├── runtime.py
│ ├── output.py
│ ├── registry.py
│ ├── plugins.py
│ └── app.py
└── tests/
├── conftest.py
├── test_spec.py
├── test_errors.py
├── test_xdg.py
├── test_runtime.py
├── test_output.py
├── test_registry.py
├── test_plugins.py
├── test_app.py
├── test_config_group.py
├── test_env_group.py
└── test_integration.py