Skip to content

feat: add <cli> help [subcmd] [-R] + Typer PEP 695 alias support#104

Merged
chrisguillory merged 5 commits into
mainfrom
feat/cli-help-and-pep695
May 5, 2026
Merged

feat: add <cli> help [subcmd] [-R] + Typer PEP 695 alias support#104
chrisguillory merged 5 commits into
mainfrom
feat/cli-help-and-pep695

Conversation

@chrisguillory
Copy link
Copy Markdown
Owner

@chrisguillory chrisguillory commented May 5, 2026

Claude Claude Code Plan

Summary

Three related improvements to cc_lib.cli plus full PEP 695 alias adoption across selenium-browser's MCP↔CLI surface, fixing 5 silent-failure bugs.

1. add_help_command(app) — recursive --help for one-shot CLI discovery

A new <cli> help subcommand with three modes:

  • <cli> help — top-level help (byte-equivalent to --help)
  • <cli> help <subcmd> — subcommand help; on unknown subcommand uses click.UsageError (exit 2) plus difflib.get_close_matches for typo suggestions
  • <cli> help --recursive / <cli> help -R — top-level + every non-hidden subcommand's --help in one shot, the AI-consumable artifact

Tab completion wired via autocompletion= callback so <cli> help <TAB> lists subcommands.

2. Pep695AliasPatcher — Typer support for type X = Literal[...]

Vanilla Typer 0.23.x raises RuntimeError: Type not yet supported when registering CLI commands annotated with PEP 695 type aliases. This patcher teaches Typer's is_literal_type and literal_values helpers to unwrap aliases via __value__. Tracked upstream as fastapi/typer#970.

Properties:

  • Lazy installation from create_app() so non-CLI consumers don't mutate Typer's global state
  • Idempotent via sentinel marker — multiple create_app() calls in one process are safe
  • Self-deleting when upstream lands the fix: end-to-end registration smoke test detects PR #970-style fixes and raises with a "remove this code" message

3. selenium-browser PEP 695 alias propagation + bug fixes

Adopts PEP 695 aliases at every site in the selenium-browser MCP↔CLI surface. Fixes 5 silent-failure modes where the CLI accepted bogus input that downstream code rejected (exactly the pattern --browser had before this PR).

New aliases in selenium_browser/models.py:

  • ScrollDirection = Literal['up', 'down', 'left', 'right']
  • ScrollPosition = Literal['top', 'bottom', 'left', 'right']
  • ScrollBehavior = Literal['instant', 'smooth']
  • WaitForSelectorState = Literal['visible', 'hidden', 'attached', 'detached']
  • ConsoleLogLevel = Literal['SEVERE', 'WARNING', 'INFO'] (data)
  • ConsoleLogLevelFilter = Literal['ALL', 'SEVERE', 'WARNING', 'INFO'] (filter)

New cross-CLI alias in cc-lib/cc_lib/types.py:

  • OutputFormat = Literal['text', 'json'] — replaces 31 inline duplicates in selenium-browser CLI

Bugs fixed:

Bug Before After
scroll --direction xyz bare str → KeyError leak through bridge [up|down|left|right] parse-time reject, exit 2
scroll --position xyz bare str → KeyError leak [top|bottom|left|right] parse-time reject
wait-for-selector --state VISIBLE bare str → silently times out 30s (every branch falls through) [visible|hidden|attached|detached] parse-time reject
get-console-logs --level error bare str → silently no-filtering (.get(default=0)) [ALL|SEVERE|WARNING|INFO] parse-time reject
pipeline --on-error garbage bare str → Pydantic catches but help shows TEXT [stop|continue] shown in help

Plus --browser (already wired): [chrome\|chromium] rendering, parse-time validation.

Test plan

Empirical tests run end-to-end against the installed CLI:

  • diff <(selenium-browser --help) <(selenium-browser help) exits 0 (byte-equivalent)
  • diff <(selenium-browser navigate --help) <(selenium-browser help navigate) exits 0
  • selenium-browser help -R produces 34 dividers matching 34 non-hidden subcommands
  • selenium-browser help <TAB> autocompletes to subcommand list
  • All 6 new selenium-browser literal-set parameters render correctly in --help (e.g., [up\|down\|left\|right])
  • All 6 reject bogus values at parse time (exit 2)
  • selenium-browser help nvigateDid you mean 'navigate'? exit 2
  • Patch idempotency: 3× create_app() calls produce same patched function object id
  • All pre-commit hooks pass workspace-wide (ruff, mypy strict, strict_typing_linter, exception_safety_linter, reexport_linter, suppression_rationale_linter, uv_script_linter)

Out of scope (deferred to follow-up PRs)

  • Wiring add_help_command(app) into the other 10 workspace Typer CLIs (claude-session, document-search, claude-remote-bash, imessage-kit, claude-login, scripts/); one-line change per CLI
  • OutputFormat propagation to document-search (×12 callsites) and claude-session (×2)
  • imessage-kit AttachmentMode / SendService propagation (drops 2 # type: ignore[arg-type]; fixes --mode VIEW silent miscall)
  • document-search StopAfterStage / FileType / SearchType propagation (drops 1 cast())
  • claude-session ArchiveFormat (drops _validate_archive_format callback) + GistVisibility cleanup
  • A signature-drift detector decorator (@matches_reference); deferred per validator finding
Reference Claude AI-native CLI help: empirical comparison — design rationale weighing recursive `--help` text vs `Click.Context.to_info_dict()` JSON vs `fastmcp list --json` for AI consumers

Two related improvements to cc_lib.cli for workspace CLI quality:

1. `add_help_command(app)` — registers `<cli> help` with three modes:
   bare (= --help), subcommand (= <subcmd> --help with difflib typo
   suggestion + click.UsageError exit 2), and --recursive/-R (top-level
   help + every non-hidden subcommand's --help in one shot, the
   AI-consumable artifact this work was prompted by).

2. `Pep695AliasPatcher` — class wrapping a Typer monkey-patch so PEP 695
   aliases (`type Browser = Literal[...]` in models.py) are recognized
   in CLI signatures. Installed lazily from create_app() so non-CLI
   consumers of cc_lib (hooks, tests, MCP runtime) don't mutate Typer's
   global state. Idempotent via sentinel marker, fails loudly when
   upstream PR #970 lands via end-to-end registration smoke test.

Wires both into selenium-browser: `add_help_command(app)` + changes
`browser: str | None` to `browser: Browser | None` at the two
`navigate*` sites. `selenium-browser navigate --help` now shows
`[chrome|chromium]` instead of `TEXT`; `selenium-browser help -R`
produces the recursive dump (~67KB) for AI consumers.

Design rationale + alternatives weighed in `docs/ai-cli-help-empirical.md`.
The empirical comparison of AI-native CLI manifest surfaces is now linked
from the PR description as a secret gist rather than landing in-repo.
Two refinements to the cli.py changes from the prior commit:

1. add_help_command(): wire autocompletion= on the subcommand argument so
   `<cli> help <TAB>` lists subcommands and `<cli> help nav<TAB>` filters
   to navigate / navigate-with-profile-state. Previously fell back to
   _files completion.

2. Pep695AliasPatcher: convert classmethods to instance methods. Now used
   as Pep695AliasPatcher().install() to match CompletionInstaller's shape.
   No functional change; keeps door open for adding instance state later
   without touching every method signature.
… bugs

Propagates the Pep695AliasPatcher across selenium-browser's MCP↔CLI surface,
adopting PEP 695 aliases at every site. Fixes silent-failure modes where
the CLI accepted bogus input that downstream code rejected — exactly the
pattern --browser had before this PR.

New aliases in selenium_browser/models.py:
  ScrollDirection         = Literal['up', 'down', 'left', 'right']
  ScrollPosition          = Literal['top', 'bottom', 'left', 'right']
  ScrollBehavior          = Literal['instant', 'smooth']
  WaitForSelectorState    = Literal['visible', 'hidden', 'attached', 'detached']
  ConsoleLogLevel         = Literal['SEVERE', 'WARNING', 'INFO']           (data)
  ConsoleLogLevelFilter   = Literal['ALL', 'SEVERE', 'WARNING', 'INFO']    (filter)

New cross-CLI alias in cc-lib/cc_lib/types.py:
  OutputFormat            = Literal['text', 'json']

Bugs fixed (silent-failure modes verified empirically by ecosystem audit):
  • scroll --direction xyz       → was bare str, fell through to KeyError leak
  • scroll --position xyz        → same shape
  • wait-for-selector --state X  → bare str, all branches fall through, 30s timeout
  • get-console-logs --level X   → bare str, .get(default=0) silently no-filtering
  • pipeline --on-error X        → bare str, Pydantic catches but help text wrong

Sites changed:
  • cli/main.py: 31 inline Literal['text','json'] → OutputFormat
                 6 type-degradation params replaced with new aliases
  • models.py: ConsoleLogEntry.level, WaitForSelectorResult.state → aliases
  • service.py, tools/{interaction,waiting,performance}.py: inline Literals → aliases
  • scroll.py: 3 typed params + 4 bare-str helpers → aliases (full propagation)

Out of scope (deferred to follow-up):
  • OutputFormat propagation to document-search (×12) and claude-session (×2)
  • imessage-kit AttachmentMode/SendService propagation (drops 2 type-ignores)
  • document-search StopAfterStage/FileType/SearchType propagation
  • claude-session ArchiveFormat/GistVisibility cleanup
Four medium-priority fixes from PR #104 review:

1. Pep695AliasPatcher._resolve(): replace unbounded `while` with
   `for _ in range(32)` plus explicit TypeError. Guards against
   mutually-recursive aliases (`type A = B; type B = A`) that would
   otherwise hang at module-load time inside create_app().

2. Extract HTTP wire-format types to selenium_browser/wire.py.
   Previously the CLI imported OnErrorPolicy from bridge.py, which
   transitively pulled FastAPI + Selenium + fastmcp through
   `from .service import BrowserService`. CLI cold-start drops from
   3516ms → 156ms (22.5x), modules loaded 1656 → 479. bridge.py and
   cli/main.py both import from wire.py now.

3. Re-export add_help_command from cc_lib package __init__.py to
   match the other four CLI factory functions. Was exposed in
   cc_lib.cli.__all__ but missing at package level.

4. Pep695AliasPatcher docstring: document the coverage boundary —
   `type X = Literal[...] | None` (with `| None` inside the alias)
   fails the same way as no-patch state. Workaround: write
   `type X = Literal[...]` and use `X | None` at the call site.

Test coverage: empirical CLI tests in PR description cover the
happy paths and rejection paths end-to-end. Pytest tests deferred
as scope creep — most candidate behaviors are already empirically
verified (tab completion, --help rendering, parse-time rejection,
typo suggestions, hidden-command exclusion).
@chrisguillory chrisguillory merged commit 9e4b661 into main May 5, 2026
@chrisguillory chrisguillory deleted the feat/cli-help-and-pep695 branch May 5, 2026 19:04
chrisguillory added a commit that referenced this pull request May 5, 2026
PR #104 shipped add_help_command() in cc_lib.cli and wired it only into
selenium-browser as the pilot. This propagates it to every other Typer
CLI in the workspace so each one supports `<cli> help`, `<cli> help <subcmd>`,
and `<cli> help -R` (recursive).

CLIs wired:
  - claude-remote-bash
  - claude-session
  - document-search
  - imessage-kit
  - claude-binary-patcher (script)
  - claude-exec (script — wires to ext_app)
  - claude-login (script)
  - claude-session-patcher (script)
  - claude-version-manager (script)
  - toggle-ask-before-auto-approval (script)

python-interpreter is argparse-based and falls outside this helper's scope.

Each change is two lines: import add_help_command, call add_help_command(app)
after create_app(). No behavior change beyond adding the new subcommand.

Empirical verification:
  imessage-kit help                  → top-level help (was: "No such command 'help'.")
  claude-session help -R | grep -c '^━━━ '  → 10 (one per subcommand)
  document-search help info          → subcommand help (byte-equivalent to `info --help`)
chrisguillory added a commit that referenced this pull request May 6, 2026
…and across workspace CLIs (#105)

* fix(cli): tighten Typer Literal annotations across imessage-kit, document-search, claude-session

Closes the audit follow-up from PR #104 (which shipped PEP 695 alias support
in cc_lib.cli). Replaces `str` annotations + `# type: ignore[arg-type]` +
`cast()` with the existing Literal aliases at CLI/MCP boundaries so Typer
enforces validation at parse time and `--help` renders valid choices.

Three severity tiers in one PR (mechanical, ~50 LOC):

P0 silent bugs (production foot-guns now rejected at parse time):

- imessage-kit get-attachment --mode: `mode: str` accepted any value;
  `--mode VIEW` (caps) silently called save() instead of view() because
  service.py:248 falls through to save() for any value != 'view'. The
  comment claiming "service validates mode against AttachmentMode Literal"
  was false. Now: `mode: AttachmentMode` rejects bad values at parse time.
- document-search index --stop-after: `cast(StopAfterStage, ...)` papered
  over invalid stage strings, silently running the full pipeline when the
  user expected early exit. Now: `stop_after: StopAfterStage | None`,
  Typer rejects bad values at parse time, cast removed.

P1 help-text drift (visible, runtime-validated but `--help` showed `TEXT`):

- imessage-kit send --service: `service_type: str` → `SendService`
  (CLI + MCP). Help now shows `[auto|iMessage|SMS]`.
- document-search list --type: `file_type: str | None` → `FileType | None`
  (CLI + MCP). Help now shows the full enum (markdown, text, pdf, image,
  json, email, csv, jsonl).

P2 DRY cleanups (no behavior change):

- document-search: 12 inline `Literal['text', 'json']` sites collapsed to
  `OutputFormat` import from cc_lib.types.
- claude-session: `_validate_archive_format` callback + `_is_archive_format`
  TypeGuard helper dropped; Typer enforces the Literal natively (verified:
  `--format zip` rejected the same way it was before).
- claude-session: extracted `type GistVisibility = Literal['public', 'secret']`
  into new `claude_session/types.py` module, replacing 3 inline duplicates
  across CLI + MCP.
- claude-session: `Literal['text', 'json']` (×2) → `OutputFormat`.

Empirical verification (all confirmed):

  imessage-kit get-attachment 1 --mode VIEW
    → "Invalid value for '--mode': 'VIEW' is not one of 'view', 'save'."

  document-search index --stop-after garbage
    → "Invalid value for '--stop-after': 'garbage' is not one of 'scan',
       'chunk', 'embed'."

  document-search list --type bogus
    → rejected with full enum

  claude-session archive --gist-visibility maybe
    → "Invalid value for '--gist-visibility': 'maybe' is not one of
       'public', 'secret'."

  claude-session archive --format zip
    → still rejected after callback drop (Typer-native validation)

Out of scope (separate follow-ups):
- add_help_command() propagation to the other 10 workspace Typer CLIs
- Lineage `Literal['text', 'tree', 'json']` extraction (different format set)
- Storage-layer `Literal['public', 'secret']` propagation (5 more sites)

* feat(cli): propagate add_help_command() to all 10 workspace Typer CLIs

PR #104 shipped add_help_command() in cc_lib.cli and wired it only into
selenium-browser as the pilot. This propagates it to every other Typer
CLI in the workspace so each one supports `<cli> help`, `<cli> help <subcmd>`,
and `<cli> help -R` (recursive).

CLIs wired:
  - claude-remote-bash
  - claude-session
  - document-search
  - imessage-kit
  - claude-binary-patcher (script)
  - claude-exec (script — wires to ext_app)
  - claude-login (script)
  - claude-session-patcher (script)
  - claude-version-manager (script)
  - toggle-ask-before-auto-approval (script)

python-interpreter is argparse-based and falls outside this helper's scope.

Each change is two lines: import add_help_command, call add_help_command(app)
after create_app(). No behavior change beyond adding the new subcommand.

Empirical verification:
  imessage-kit help                  → top-level help (was: "No such command 'help'.")
  claude-session help -R | grep -c '^━━━ '  → 10 (one per subcommand)
  document-search help info          → subcommand help (byte-equivalent to `info --help`)

* refactor: propagate ArchiveFormat, GistVisibility, SearchType beyond CLI/MCP boundaries

Closes the audit's deeper type-fidelity items (originally marked out of
scope for #105's first cut): centralize the three Literal aliases that
appeared inline in services, storage, and Pydantic schema layers.

Changes:

- Move `ArchiveFormat = Literal['json', 'zst']` from
  `claude_session/cli/main.py:80` to `claude_session/types.py` (now a
  PEP 695 `type` alias for consistency with `GistVisibility`). Pydantic
  2.12 supports PEP 695 aliases natively. Propagated to:
    - `cli/main.py` (helper signature)
    - `mcp/main.py` (save_current_session signature)
    - `schemas/operations/archive.py` (ArchiveMetadata.format)
    - `services/archive.py` (FormatDetector.EXTENSION_MAP, detect_format
      arg + return, _detect_from_extension return, create_archive arg)

- Propagate `GistVisibility` from `claude_session/types.py` to its
  remaining inline duplicate at `storage/gist.py:39`. Both CLI/MCP and
  the underlying storage layer now reference the same alias.

- Propagate `SearchType` (already exists in
  `document_search/schemas/vectors.py:54`) into
  `document_search/cli/main.py` (×2 sites: `search --type` Annotated
  parameter at line 314, internal `_search_async` helper at line 615).
  Help text now shows `[hybrid|lexical|embedding]` instead of `TEXT`.

Empirical verification:
  document-search search --help               → shows [hybrid|lexical|embedding]
  document-search search hi --type bogus      → rejected at parse time
  claude-session archive ... --format zst     → archive completes successfully
  claude-session archive ... --format zip     → rejected at parse time

No behavior change for happy paths; just centralizes the source of truth
for these aliases so future evolution touches one location instead of
5-8 duplicated sites.

* fix: address two LOW findings from code-review-validated on PR #105

Both findings were consistency/maintainability gaps where the propagation
sweep in earlier commits missed a sibling site:

1. claude-remote-bash-daemon (mcp/claude-remote-bash/.../daemon.py) is a
   Typer app with 6 subcommands and was missed in commit 4f96ffd despite
   matching every criterion (uses cc_lib.cli, Typer-based, user-facing CLI).
   Add `add_help_command(app)` for parity. The commit message claim "all 10
   workspace Typer CLIs" is now accurate at 11 (10 from 4f96ffd + this one).

2. document-search MCP search_documents tool still had
   `file_types: Sequence[str] | None` plus a `cast(FileType, ft)` at the
   call site — exact same pattern commit 5441eda removed for the singular
   `file_type` on `list_documents`. Tighten to `Sequence[FileType] | None`
   and drop the cast (Pydantic was already validating downstream, just one
   frame deeper than the boundary).

Empirical verification:
  claude-remote-bash-daemon help              → top-level help (was: error)
  claude-remote-bash-daemon help -R           → 9 subcommand sections
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant