# cli-core-yo — Implementation Plan

> Derived from `SPEC.md` (normative). This plan covers phases, module order, dependencies, testing, risk, and validation checkpoints.

---

## 1. Implementation Phases

### Phase 0 — Project Scaffolding

| Item | Detail |
|------|--------|
| **Goal** | Buildable, installable Python package skeleton |
| **Outputs** | `pyproject.toml`, `cli_core_yo/__init__.py`, `.gitignore`, empty test scaffold |
| **Risk** | Low |

**Tasks:**

- `pyproject.toml` with `setuptools_scm` (per global rule `06_github.md`), pinned deps:
  - `typer>=0.21.0,<0.22.0`
  - `rich>=14.0.0,<15.0.0`
  - `click>=8.3.0,<9.0.0`
- `cli_core_yo/__init__.py` with version import (from `_version.py` generated by `setuptools_scm`)
- `.gitignore` (egg-info, `_version.py`, `__pycache__`, `dist/`, `build/`, `.eggs/`)
- `tests/` directory with `conftest.py`
- Validate: `pip install -e .` succeeds, `python -c "import cli_core_yo"` succeeds

**Checkpoint:** Package installs in editable mode. `import cli_core_yo` works.

---

### Phase 1 — Foundation Modules (No Cross-Dependencies)

| Item | Detail |
|------|--------|
| **Goal** | Pure dataclass and exception definitions |
| **Modules** | `errors.py`, `spec.py` |
| **Risk** | Low — pure data, no I/O |

#### 1a. `cli_core_yo/errors.py`

- Define base `CliCoreYoError(Exception)`
- `ContextNotInitializedError` — raised when `get_context()` called before init
- `RegistryFrozenError` — raised on post-freeze registration
- `RegistryConflictError` — raised on name collision
- `PluginLoadError` — raised on import/registration failure
- Map each to exit codes per §2.6

#### 1b. `cli_core_yo/spec.py`

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 | None`
- `EnvSpec`: `active_env_var: str`, `project_root_env_var: str`, `activate_script_name: str`, `deactivate_script_name: str`
- `PluginSpec`: `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

---

### Phase 2 — Core Infrastructure

| Item | Detail |
|------|--------|
| **Goal** | XDG resolution, runtime context, output primitives |
| **Modules** | `xdg.py`, `runtime.py`, `output.py` |
| **Risk** | Medium — platform-specific XDG logic, Rich rendering |

#### 2a. `cli_core_yo/xdg.py`

- `resolve_paths(xdg_spec: XdgSpec) -> XdgPaths` returning config/data/state/cache `Path` objects
- 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

#### 2b. `cli_core_yo/runtime.py`

- `RuntimeContext` dataclass: `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 set
- `get_context() -> RuntimeContext` — raises `ContextNotInitializedError` if not set
- `_reset()` — test-only reset

**Tests:**
- Init once, get succeeds
- Get before init raises
- Double init raises
- `_reset()` allows re-init

#### 2c. `cli_core_yo/output.py`

- `Console` wrapper: uses `Rich.Console` with `stderr=False`, respects `NO_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_mode` and suppress if True
- Color mapping per §6.1

**Tests:**
- Each primitive produces correct prefix/markup
- JSON mode suppresses human output
- `emit_json` produces valid, deterministic JSON
- `NO_COLOR` suppresses ANSI codes

**Checkpoint:** XDG paths resolve. Context initializes. Output primitives render correctly.

---

### Phase 3 — Command Registry

| Item | Detail |
|------|--------|
| **Goal** | Registration, validation, conflict detection, freeze semantics |
| **Module** | `registry.py` |
| **Risk** | Medium — ordering and conflict logic |

- `CommandRegistry` class
  - 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-app
  - `freeze()` — prevents further mutations
  - `apply(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

### Phase 4 — Plugin Loading

| 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` + `getattr` to 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 `PluginLoadError` with 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.

---

### Phase 5 — App Factory and Built-In Commands

| 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 |

#### 5a. `create_app(spec: CliSpec) -> typer.Typer`

Startup sequence (§3.5):
1. Validate `CliSpec` (non-empty required fields, valid regex for names)
2. Construct root Typer app: `name=spec.prog_name`, `help=spec.root_help`, `add_completion=True`, `no_args_is_help=True`, **no `-h` alias**
3. Initialize `RuntimeContext`
4. Register built-in commands: `version`, `info`
5. If `spec.config` not None → register `config` group (§4.6)
6. If `spec.env` not None → register `env` group (§4.7)
7. Load plugins via `plugins.load_plugins()`
8. Freeze registry
9. Apply registry to Typer app

#### 5b. `run(spec: CliSpec, argv: list[str] | None) -> int`

- 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

#### 5c. Built-in `version` command

- `<prog> version` prints `<app_display_name> <app_version>` (§2.5)
- Version resolved from `importlib.metadata.version(spec.dist_name)`
- Cyan styling in human mode
- Exit 0

#### 5d. Built-in `info` command

- 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_hooks` in order, appending rows
- Exit 0

#### 5e. Built-in `config` group (optional, §4.6)

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 |

#### 5f. Built-in `env` group (optional, §4.7)

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_app` returns a `typer.Typer` with correct settings
- `run` returns exit codes without calling `sys.exit()`
- `version` output format and exit code
- `info` output contains all required rows
- `info` hooks append extra rows
- `config path` prints path
- `config init` creates file, respects `--force`
- `config show` prints contents, errors on missing
- `config validate` runs validator
- `config edit` launches editor
- `config reset` creates backup, replaces
- `env status` reports active/inactive
- `env activate/deactivate/reset` print correct shell commands
- No-args → help → exit 0
- Unknown command → exit 2
- `--help` at 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.

---

### Phase 6 — Integration Tests and Behavioral Equivalence

| 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 `CliSpec` with known values
- Test matrix:
  - Root help output (§2.4)
  - Global flags: `--help`, `--install-completion`, `--show-completion`
  - No `-h` at 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_COLOR` suppresses ANSI
  - `CLI_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.

---

## 2. Module Dependency Graph

```
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.

---

## 3. Risk Assessment

| 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 |

---

## 4. Testing Strategy Summary

| 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`.

---

## 5. Migration Validation Checkpoints (§7)

These are verified in Phase 6 but documented here for traceability:

1. **`create_app` returns correct Typer app** — `add_completion=True`, `no_args_is_help=True`, rich help
2. **`version` output** — `<app_display_name> <version>` format, cyan in human mode
3. **`info` output** — all §6.3 base rows present
4. **Command ordering** — built-in first, then downstream in registration order
5. **JSON output** — `json.loads()` succeeds, sorted keys, indent 2
6. **Exit codes** — 0/1/2/130 match §2.6
7. **`-h` not reserved globally** — downstream can use `-h` for own options
8. **Plugin loading** — explicit before entry-point, deterministic order
9. **Registry freeze** — post-freeze registration raises error
10. **Conflict detection** — all §4.3 branches tested

---

## 6. Clarifying Questions / Ambiguities

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.

---

## 7. File Inventory (Post-Implementation)

```
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
```
