---
phase: 04-cli-surface-generation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/actions/definitions.py
  - src/ztlctl/actions/_register_core.py
  - src/ztlctl/commands/generator.py
  - tests/commands/test_generator.py
  - tests/actions/test_definitions.py
autonomous: true
requirements: [ACTN-04, ACTN-05]
must_haves:
  truths:
    - "ActionDefinition has a cli_name field for explicit CLI name override"
    - "Every ActionDefinition has a non-None cli_group assignment"
    - "update ActionDefinition is marked custom_presentation=True"
    - "generator.py can create Click commands from ActionDefinitions"
    - "_param_to_click maps all ActionParam varieties (argument, flag, choice, multiple, dict, int, float, str) to Click params"
    - "Generated callbacks call action.handler(app.vault, **kwargs) then app.emit(result)"
    - "cli_multiple params are normalized from tuple to list|None in callbacks"
  artifacts:
    - path: "src/ztlctl/actions/definitions.py"
      provides: "cli_name field on ActionDefinition"
      contains: "cli_name"
    - path: "src/ztlctl/actions/_register_core.py"
      provides: "All 59 ActionDefinitions with cli_group and cli_name set"
    - path: "src/ztlctl/commands/generator.py"
      provides: "CLI command factory and group registration"
      exports: ["generate_commands", "_make_command", "_param_to_click"]
    - path: "tests/commands/test_generator.py"
      provides: "Unit tests for generator"
  key_links:
    - from: "src/ztlctl/commands/generator.py"
      to: "src/ztlctl/actions/registry.py"
      via: "get_action_registry().list_actions(custom_presentation=False)"
      pattern: "list_actions.*custom_presentation.*False"
    - from: "src/ztlctl/commands/generator.py"
      to: "src/ztlctl/commands/_base.py"
      via: "ZtlCommand and ZtlGroup constructors"
      pattern: "ZtlCommand|ZtlGroup"
---

<objective>
Build the ActionDefinition CLI metadata and the CLI command generator module that creates Click commands from ActionDefinitions at runtime.

Purpose: This is the foundation layer for Phase 4 — the generator module mirrors mcp/generator.py but targets Click instead of FastMCP. All ActionDefinitions need correct cli_group and cli_name metadata before the generator can produce the right CLI structure.

Output: Updated ActionDefinition with cli_name field, all 59 registrations with correct CLI metadata, generator.py with full type mapping, unit tests proving generator correctness.
</objective>

<execution_context>
@/Users/shparki/.claude/get-shit-done/workflows/execute-plan.md
@/Users/shparki/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-cli-surface-generation/04-CONTEXT.md
@.planning/phases/04-cli-surface-generation/04-RESEARCH.md
@.planning/phases/03-mcp-surface-generation/03-01-SUMMARY.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From src/ztlctl/actions/definitions.py:
```python
@dataclass(frozen=True)
class ActionParam:
    name: str
    type: type
    required: bool = True
    default: Any = None
    description: str = ""
    choices: tuple[str, ...] | None = None
    cli_multiple: bool = False
    cli_is_argument: bool = False
    cli_flag: bool = False
    mcp_example: str = ""

@dataclass(frozen=True)
class ActionDefinition:
    name: str
    description: str
    category: str
    params: tuple[ActionParam, ...]
    handler: Callable[..., Any]
    side_effect: Literal["read", "write"]
    mcp_when_to_use: str = ""
    mcp_avoid_when: str = ""
    mcp_common_errors: tuple[str, ...] = ()
    cli_group: str | None = None
    cli_examples: str = ""
    cli_interactive_params: tuple[str, ...] = ()
    custom_presentation: bool = False
```

From src/ztlctl/actions/registry.py:
```python
class ActionRegistry:
    def register(self, action: ActionDefinition) -> None: ...
    def get(self, name: str) -> ActionDefinition: ...
    def list_actions(self, *, category=None, side_effect=None, custom_presentation=None) -> list[ActionDefinition]: ...

def get_action_registry() -> ActionRegistry: ...
```

From src/ztlctl/commands/_base.py:
```python
class ZtlCommand(click.Command):
    def __init__(self, *args, examples: str | None = None, **kwargs): ...

class ZtlGroup(click.Group):
    command_class = ZtlCommand
    def __init__(self, *args, examples: str | None = None, **kwargs): ...
```

From src/ztlctl/commands/_context.py:
```python
class AppContext:
    def __init__(self, settings: ZtlSettings) -> None: ...
    @property
    def vault(self) -> Vault: ...
    def emit(self, result: ServiceResult) -> None: ...
```

From src/ztlctl/mcp/generator.py (pattern to follow):
```python
def _make_tool_fn(action: ActionDefinition, vault: Any) -> Callable[..., dict[str, Any]]: ...
def generate_tools(server: Any, vault: Any) -> None: ...
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add cli_name field to ActionDefinition and update all registrations with CLI metadata</name>
  <files>
    src/ztlctl/actions/definitions.py
    src/ztlctl/actions/_register_core.py
    tests/actions/test_definitions.py
  </files>
  <read_first>
    src/ztlctl/actions/definitions.py
    src/ztlctl/actions/_register_core.py
    src/ztlctl/commands/__init__.py
    src/ztlctl/commands/query.py
    src/ztlctl/commands/agent.py
    src/ztlctl/commands/update.py
    src/ztlctl/commands/archive.py
    src/ztlctl/commands/extract.py
    src/ztlctl/commands/reweave.py
    src/ztlctl/commands/garden.py
    tests/actions/test_definitions.py
  </read_first>
  <behavior>
    - Test: ActionDefinition accepts cli_name field, defaults to None
    - Test: ActionDefinition with cli_name="list" stores correctly on frozen dataclass
    - Test: Existing ActionDefinitions without cli_name still work (backward compat)
  </behavior>
  <action>
1. **Add `cli_name` field to ActionDefinition** in `src/ztlctl/actions/definitions.py`:
   Add after `cli_interactive_params`:
   ```python
   cli_name: str | None = None
   ```
   Document: "Explicit CLI command name override. When None, the generator derives the name from `action.name` by stripping the `cli_group` prefix (if present) and replacing underscores with hyphens."

2. **Update `_register_core.py`** to add `cli_group` and `cli_name` to every ActionDefinition that currently lacks them. The complete mapping (current CLI structure as source of truth):

   **query category** — all need `cli_group="query"`:
   - `count_items`: cli_group="query", cli_name="count"
   - `search`: cli_group="query" (name already matches)
   - `get`: cli_group="query" (name already matches)
   - `list_items`: cli_group="query", cli_name="list"
   - `work_queue`: cli_group="query" (auto-derives "work-queue")
   - `list_tags`: cli_group="query", cli_name="tags"
   - `decision_support`: cli_group="query", cli_name="decision-support"
   - `topic_packet`: cli_group="query", cli_name="packet"
   - `draft_from_topic`: cli_group="query", cli_name="draft"
   - `vault_review`: cli_group="query", cli_name="review"

   **lifecycle category** — standalone top-level commands:
   - `update`: add `custom_presentation=True` (keeps hand-written update.py which decomposes changes dict into individual --title/--status/--tags/--topic/--body/--maturity flags)
   - `archive`: cli_group=None (stays top-level), cli_name="archive"
   - `supersede`: cli_group=None (stays top-level), cli_name="supersede"

   **reweave category** — standalone top-level commands (but research says to group):
   - `reweave`: cli_group="reweave", cli_name="run" (or keep top-level: cli_group=None)
   - Actually, current CLI has `reweave` as standalone. Keep: cli_group=None for reweave, prune, undo. They become top-level commands `reweave`, `prune`, `undo`.
   - Wait — current CLI only has `reweave` as standalone. `prune` and `undo` are likely subcommands. Let me check...
   Actually looking at _register_core.py, reweave/prune/undo all have NO cli_group. And the current commands/__init__.py registers `reweave` as standalone. But prune/undo are not registered at all in __init__.py — they're new actions added in Phase 2 that only have MCP tools.
   Set all three to: cli_group="reweave", with cli_name="run" for reweave, cli_name="prune" for prune, cli_name="undo" for undo.

   **session category** — cli_group="session" already set on most, but need nested structure:
   The current CLI has `agent` > `session` > {start, close, reopen, cost, log, status} and `agent` > {context, brief, regenerate}. The registry has these as cli_group="session".
   The generator will create a flat `session` group at top level with: start, close, reopen, cost, log, status, context, brief, extract-decision.
   - `log_entry`: already cli_group="session", add cli_name="log"
   - `extract_decision`: already cli_group="session", add cli_name="extract"
   All others in session already have cli_group="session" and names that work.

   **check category** — cli_group="check" already set. No changes needed.

   **ingest category** — cli_group="ingest" already set.
   - `list_providers`: cli_name="providers"
   - `ingest_text`: cli_name="text"
   - `ingest_file`: cli_name="file"
   - `ingest_url`: cli_name="url"

   **export category** — cli_group="export" already set.
   - `export_markdown`: cli_name="markdown"
   - `export_indexes`: cli_name="indexes"
   - `export_graph`: cli_name="graph" (conflicts with graph group — but this is UNDER export group, so "export graph" is fine)
   - `export_dashboard`: cli_name="dashboard"

   **vector category** — cli_group="vector" already set.
   - `reindex_all`: cli_name="reindex"

   **upgrade category** — need cli_group="upgrade":
   - `check_pending`: cli_group="upgrade", cli_name="check"
   - `apply`: cli_group="upgrade", cli_name="apply"
   - `stamp_current`: cli_group="upgrade", cli_name="stamp"

   **init category** (non-custom):
   - `regenerate_self`: cli_group="init", cli_name="regenerate"
   - `check_staleness`: cli_group="init", cli_name="staleness"

   **graph category** — cli_group="graph" already set.
   - `materialize_metrics`: cli_name="materialize"

3. **Update test file** `tests/actions/test_definitions.py` if it exists — add test for cli_name field. If it doesn't exist, add a small test to an appropriate test file.

4. Run `uv run pytest tests/actions/ -x -q` and `uv run mypy src/ztlctl/actions/` to verify.
  </action>
  <verify>
    <automated>uv run pytest tests/actions/ -x -q && uv run mypy src/ztlctl/actions/</automated>
  </verify>
  <acceptance_criteria>
    - grep -c "cli_name" src/ztlctl/actions/definitions.py returns at least 2 (field def + docstring)
    - grep -c "cli_group=" src/ztlctl/actions/_register_core.py returns at least 55 (every non-custom action has cli_group)
    - grep -c "cli_name=" src/ztlctl/actions/_register_core.py returns at least 20 (actions needing explicit name override)
    - grep "custom_presentation=True" src/ztlctl/actions/_register_core.py includes "update" action
    - uv run mypy src/ztlctl/actions/ exits 0
    - uv run pytest tests/actions/ -x -q exits 0
  </acceptance_criteria>
  <done>Every ActionDefinition has a non-None cli_group (except custom_presentation actions where it's optional), explicit cli_name where the default derivation would produce the wrong name, and the update action is marked custom_presentation=True.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Build CLI command generator module with unit tests</name>
  <files>
    src/ztlctl/commands/generator.py
    tests/commands/test_generator.py
  </files>
  <read_first>
    src/ztlctl/mcp/generator.py
    src/ztlctl/commands/_base.py
    src/ztlctl/commands/_context.py
    src/ztlctl/actions/definitions.py
    src/ztlctl/actions/registry.py
    tests/mcp/test_generator.py
  </read_first>
  <behavior>
    - Test: _param_to_click converts cli_is_argument=True ActionParam to click.Argument
    - Test: _param_to_click converts cli_flag=True ActionParam to click.Option with is_flag=True
    - Test: _param_to_click converts choices ActionParam to click.Option with click.Choice type
    - Test: _param_to_click converts cli_multiple=True ActionParam to click.Option with multiple=True
    - Test: _param_to_click converts type=int ActionParam to click.Option with click.INT type
    - Test: _param_to_click converts type=dict ActionParam to click.Option with click.STRING type (JSON input)
    - Test: _make_command produces a ZtlCommand with correct name (hyphenated, using cli_name if set)
    - Test: _make_command callback calls action.handler and emits result via app
    - Test: _make_command normalizes cli_multiple empty tuples to None
    - Test: _make_command parses JSON for dict-typed params
    - Test: generate_commands registers commands under correct groups
    - Test: generate_commands skips custom_presentation=True actions
    - Test: generate_commands creates ZtlGroup instances for each distinct cli_group
    - Test: _derive_cli_name strips group prefix and hyphenates (e.g., export_markdown with group=export -> markdown)
    - Test: _derive_cli_name uses cli_name override when present
    - Test: generated command has help text from action.description
  </behavior>
  <action>
Create `src/ztlctl/commands/generator.py` mirroring the structure of `src/ztlctl/mcp/generator.py`:

```python
"""CLI command generator -- ActionRegistry-driven Click command registration.

Replaces hand-written command files with auto-generated Click commands derived
from ActionDefinitions. ``generate_commands()`` iterates the registry and
builds a ZtlCommand for each non-custom_presentation action, registering it
under the appropriate ZtlGroup.

Mirrors the approach of ``mcp/generator.py`` for the MCP surface.
"""

from __future__ import annotations

import json
from typing import Any

import click

from ztlctl.actions.definitions import ActionDefinition, ActionParam
from ztlctl.commands._base import ZtlCommand, ZtlGroup


# ---------------------------------------------------------------------------
# CLI name derivation
# ---------------------------------------------------------------------------

def _derive_cli_name(action: ActionDefinition) -> str:
    """Derive the Click command name for an ActionDefinition.

    Priority:
    1. Explicit ``action.cli_name`` if set.
    2. Strip ``{cli_group}_`` prefix from action.name if cli_group is set and
       action.name starts with that prefix.
    3. Replace underscores with hyphens in action.name.
    """
    if action.cli_name is not None:
        return action.cli_name
    name = action.name
    if action.cli_group and name.startswith(f"{action.cli_group}_"):
        name = name[len(action.cli_group) + 1:]
    return name.replace("_", "-")


# ---------------------------------------------------------------------------
# ActionParam -> Click parameter mapping
# ---------------------------------------------------------------------------

def _param_to_click(p: ActionParam) -> click.Parameter:
    """Map one ActionParam to a Click Argument or Option."""
    if p.cli_is_argument:
        return click.Argument([p.name])

    option_name = f"--{p.name.replace('_', '-')}"

    if p.cli_flag:
        return click.Option(
            [option_name],
            is_flag=True,
            default=False,
            help=p.description,
        )

    if p.choices is not None:
        return click.Option(
            [option_name],
            type=click.Choice(list(p.choices)),
            default=p.default,
            required=p.required and p.default is None,
            help=p.description,
        )

    if p.cli_multiple:
        return click.Option(
            [option_name],
            multiple=True,
            default=(),
            help=p.description,
        )

    # dict type -> JSON string input
    if p.type is dict:
        return click.Option(
            [option_name],
            type=click.STRING,
            default=p.default,
            required=p.required and p.default is None,
            help=f"{p.description} [JSON]" if p.description else "[JSON]",
        )

    click_type = {int: click.INT, float: click.FLOAT, bool: click.BOOL}.get(
        p.type, click.STRING
    )
    return click.Option(
        [option_name],
        type=click_type,
        default=p.default,
        required=p.required and p.default is None,
        help=p.description,
    )


# ---------------------------------------------------------------------------
# Command factory
# ---------------------------------------------------------------------------

def _make_command(action: ActionDefinition) -> click.Command:
    """Build a ZtlCommand from an ActionDefinition.

    The callback:
    1. Normalizes cli_multiple params: empty tuple () -> None, non-empty -> list.
    2. Parses JSON for dict-typed params.
    3. Calls action.handler(app.vault, **kwargs).
    4. Emits result via app.emit().

    ``@click.pass_obj`` is applied to the callback BEFORE constructing the
    ZtlCommand so Click's context injection works correctly.
    """
    cli_name = _derive_cli_name(action)
    params = [_param_to_click(p) for p in action.params]

    # Identify params that need normalization
    multiple_params = {p.name for p in action.params if p.cli_multiple}
    dict_params = {p.name for p in action.params if p.type is dict}

    @click.pass_obj
    def callback(app: Any, **kwargs: Any) -> None:
        # Normalize cli_multiple: () -> None, ("a", "b") -> ["a", "b"]
        for pname in multiple_params:
            if pname in kwargs:
                val = kwargs[pname]
                kwargs[pname] = list(val) if val else None
        # Parse JSON for dict-typed params
        for pname in dict_params:
            if pname in kwargs and isinstance(kwargs[pname], str):
                kwargs[pname] = json.loads(kwargs[pname])

        result = action.handler(app.vault, **kwargs)
        app.emit(result)

    # Set __name__ for Click introspection
    callback.__name__ = cli_name.replace("-", "_")

    return ZtlCommand(
        name=cli_name,
        callback=callback,
        params=params,
        help=action.description,
        examples=action.cli_examples or None,
    )


# ---------------------------------------------------------------------------
# Group help text
# ---------------------------------------------------------------------------

_GROUP_HELP: dict[str, str] = {
    "query": "Search, list, and query vault content.",
    "graph": "Traverse and analyze the knowledge graph.",
    "session": "Manage sessions, context, and agent workflows.",
    "create": "Create notes, references, and tasks.",
    "check": "Check vault integrity and repair issues.",
    "ingest": "Ingest text and source material into the vault.",
    "export": "Export vault content and dashboards.",
    "vector": "Manage semantic search indexing.",
    "upgrade": "Run pending database migrations.",
    "reweave": "Densify the knowledge graph with links.",
    "init": "Initialize and manage vault configuration.",
}


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------

def generate_commands(cli: click.Group) -> None:
    """Register all non-custom_presentation actions as Click commands.

    Iterates ``get_action_registry().list_actions(custom_presentation=False)``,
    creates a ZtlCommand for each, and registers under the appropriate group
    (or directly on ``cli`` if ``cli_group`` is ``None``).

    Groups are created lazily and stored in a local dict to avoid duplicates.
    """
    import ztlctl.actions  # noqa: F401 — triggers _register_core_actions()
    from ztlctl.actions.registry import get_action_registry

    registry = get_action_registry()
    groups: dict[str, ZtlGroup] = {}

    for action in registry.list_actions(custom_presentation=False):
        cmd = _make_command(action)
        group_name = action.cli_group

        if group_name is None:
            cli.add_command(cmd)
        else:
            if group_name not in groups:
                groups[group_name] = ZtlGroup(
                    name=group_name,
                    help=_GROUP_HELP.get(group_name, ""),
                )
                cli.add_command(groups[group_name])
            groups[group_name].add_command(cmd)
```

Then create `tests/commands/test_generator.py` with unit tests:

- Test `_derive_cli_name`: explicit cli_name, group prefix stripping, plain hyphenation
- Test `_param_to_click`: argument, flag, choices, multiple, dict, int, float, str
- Test `_make_command`: produces ZtlCommand with correct name, help, params, callback
- Test callback normalization: cli_multiple empty tuple -> None, non-empty -> list
- Test callback JSON parsing for dict params
- Test `generate_commands`: creates groups, skips custom_presentation, correct structure

Use mock ActionDefinitions (not the real registry) for isolated unit tests. Use a simple mock handler: `lambda vault, **kw: ServiceResult(ok=True, op="test", data=kw)`.

For `generate_commands` test, use a bare `click.Group()` and verify command structure after generation.
  </action>
  <verify>
    <automated>uv run pytest tests/commands/test_generator.py -x -v && uv run mypy src/ztlctl/commands/generator.py</automated>
  </verify>
  <acceptance_criteria>
    - File src/ztlctl/commands/generator.py exists and contains "def generate_commands"
    - File src/ztlctl/commands/generator.py contains "def _make_command"
    - File src/ztlctl/commands/generator.py contains "def _param_to_click"
    - File src/ztlctl/commands/generator.py contains "def _derive_cli_name"
    - File tests/commands/test_generator.py exists with at least 10 test functions
    - grep "custom_presentation=False" src/ztlctl/commands/generator.py returns at least 1 match
    - grep "ZtlCommand" src/ztlctl/commands/generator.py returns at least 1 match
    - grep "ZtlGroup" src/ztlctl/commands/generator.py returns at least 2 matches
    - uv run pytest tests/commands/test_generator.py -x -v exits 0
    - uv run mypy src/ztlctl/commands/generator.py exits 0
  </acceptance_criteria>
  <done>generator.py creates Click commands from ActionDefinitions with correct type mapping, name derivation, callback normalization, and group registration. All unit tests pass.</done>
</task>

</tasks>

<verification>
After both tasks:
- `uv run pytest tests/actions/ tests/commands/test_generator.py -x -q` passes
- `uv run mypy src/ztlctl/actions/ src/ztlctl/commands/generator.py` passes
- `uv run ruff check src/ztlctl/actions/ src/ztlctl/commands/generator.py` passes
- All existing tests still pass: `uv run pytest -x -q`
</verification>

<success_criteria>
1. ActionDefinition has cli_name field; all 59 registrations have correct cli_group and cli_name metadata
2. generator.py exists and can create Click commands from ActionDefinitions
3. Unit tests prove all ActionParam type mappings and callback normalization patterns work correctly
4. No regressions in existing test suite
</success_criteria>

<output>
After completion, create `.planning/phases/04-cli-surface-generation/04-01-SUMMARY.md`
</output>
