Skip to content

ADR 003: Spec-First CLI and Skills

Author: Joe McGinley Status: Draft Created: 2026-04-25


Problem

The homelab CLI (tools/cli/) is the human and Claude-Code interface to the private API surface (https://private.jomcgi.dev). Today it is hand-written: each backend route gets a corresponding typer subcommand in a per-domain file (knowledge_cmd.py, tasks_cmd.py, …), with its own copy of the auth + retry boilerplate, its own request/response parsing, and its own bespoke output formatter.

This produces three concrete frictions:

1. The CLI is a perpetual lagging indicator of the backend. Adding a route to FastAPI requires a separate ~30–60-LOC PR against tools/cli/ to make it reachable from a workstation. The two PRs can drift indefinitely; nothing fails if the CLI is missing a command for an endpoint that already ships in production.

2. Auth, retry, and CF-Access re-auth logic are duplicated. Every command file copies _client() and _request() (see knowledge_cmd.py:20-46 vs. tasks_cmd.py:20-46). Bug fixes to the re-auth flow have to be applied N times, and they have already drifted in small ways (compact_line formatter import in one file, not the other).

3. There are no Claude Code skills for using the CLI. Skills ship as markdown files at .claude/skills/<name>/SKILL.md and let Claude discover when and how to invoke a CLI command from natural language. We have skills for knowledge (debug-knowledge-ingest, knowledge) but they are written by hand and reference only a small subset of available endpoints. Most of the private API surface is invisible to Claude unless someone hand-writes a skill for it.

The forcing function for this ADR is the addition of scheduler API endpoints (see docs/plans/2026-04-25-scheduler-api-design.md). That work is shipping in the existing hand-written style to avoid blocking on this larger refactor, but it is the third domain in a row that has paid the duplication tax. The next domain should not.


Proposal

Treat the FastAPI OpenAPI schema as the single source of truth for the CLI surface and the per-endpoint Claude skills. Both artifacts become derived from openapi.json, regenerated whenever the contract changes.

AspectTodayProposed
Source of truthHand-written CLI command + hand-written skill, both copied from a siblingFastAPI route definition (Pydantic response_model, summary, description, tags)
Adding a new endpointThree PRs in sequence (backend → CLI → skill) and humans diff them by eyeOne PR: backend + regen artifact; CLI and skills land in the same commit via codegen
Auth + retryRe-implemented per command fileA single shared tools/cli/_client.py injected into every generated command
Output formattingBespoke per command (task_line, search_line, compact_line) — terse, token-efficientGeneric JSON/table renderer by default; opt-in registry of named formatters keyed by operationId for the routes where the bespoke shape matters
Skill coverageHand-written, sparse, driftsPer-tag SKILL.md generated from tags/summary/description with example invocations baked in
CI gateNone — drift is invisibleCodegen runs in CI; a stale generated artifact fails the build

The "auto-inherit" property the user wants — a new endpoint automatically becomes a CLI command and a Claude skill — is delivered by generating from the OpenAPI spec at build time, with the spec itself derived from the live FastAPI app and committed as a build input.


Architecture

mermaid
graph LR
    A[FastAPI route<br/>+ Pydantic response_model<br/>+ tags / summary / description] --> B[/openapi.json<br/>committed artifact/]
    B --> C[Bazel codegen rule<br/>openapi_to_cli]
    B --> D[Bazel codegen rule<br/>openapi_to_skill]
    C --> E[Generated typer module<br/>tools/cli/generated/*.py]
    D --> F[Generated skills<br/>.claude/skills/private-api/*]
    G[tools/cli/_client.py<br/>auth + retry] --> E
    H[tools/cli/formatters/<br/>operationId registry] --> E
    E --> I[homelab CLI binary]
    F --> J[Claude Code skill discovery]

Key components

1. The committed openapi.json artifact. A Bazel target (//projects/monolith:openapi_json) imports the FastAPI app, calls app.openapi(), and writes the result to a stable JSON file at projects/monolith/openapi.json. CI runs this in a check mode that fails if the committed file is out of date — same pattern as gazelle BUILD files today.

2. openapi_to_cli codegen. A Python script (probably under bazel/tools/openapi/) walks the spec and emits one typer file per OpenAPI tag, plus an __init__.py that wires them into the root app. Conventions:

  • Tag → subcommand group (scheduler, knowledge, tasks, …)
  • operationId → command name (kebab-cased)
  • Path parameters → typer arguments (positional)
  • Query / body parameters → typer options (named flags)
  • summary → typer help text
  • response_model → output schema; default formatter pretty-prints JSON, registry overrides take a parsed Pydantic instance and return a string
  • x-cli-formatter: <name> extension on the route → look up named formatter from tools/cli/formatters/ (allows hand-tuned output for specific routes without leaving the codegen path)

3. openapi_to_skill codegen. Walks tags and emits one skill per tag at .claude/skills/private-api-<tag>/SKILL.md. Each skill has:

  • Frontmatter name: private-api-<tag> and description: <tag-level-doc>
  • A "When to use" section enumerating the operations under the tag
  • Per-operation example invocations (homelab <tag> <command> --flag value), expected output shape, and the natural-language triggers that should fire it
  • A pointer to the generated CLI module so future contributors know not to edit the skill directly

4. Shared tools/cli/_client.py. Owns CF-Access auth, redirect-driven re-auth, JSON decoding, and HTTP error formatting. Generated commands import this and never construct httpx clients themselves. Replaces the duplicated _client()/_request() pairs in current per-domain files.

5. Build vs. runtime tradeoff. Codegen runs at build time, not runtime. The CLI image still contains plain typer code with no openapi dependency at runtime — important for the homelab_cli_tar image footprint. The cost is that an API change requires the monolith to be rebuilt + the openapi.json regenerated + the CLI rebuilt; the auto-inherit is "next build" not "next request." This is acceptable for our cadence.

Edge cases that need explicit handling

  • Streaming / SSE responses (e.g., chat) — codegen emits a stub that defers to a hand-written formatter; we keep an explicit allowlist of routes that opt out of generic codegen.
  • File upload (multipart) and download — same pattern: generator detects multipart/form-data request bodies or non-JSON response content types and emits a stub.
  • Routes with redirect chains (CF-Access 302s) — handled in the shared _client.py, not per-route.
  • Routes intentionally omitted from the CLI (internal health checks, Discord webhooks) — x-cli-skip: true extension.

Implementation

The ADR captures the full canonical task list. Work is deferred until at least one more domain is added in the existing style (the scheduler PR is the immediate trigger; if the next domain after that is also painful, that's the signal to start Phase 1).

Phase 1: Spec-as-source

  • [ ] Add Bazel target //projects/monolith:openapi_json that imports the FastAPI app and writes projects/monolith/openapi.json. Wire into format so format regenerates it.
  • [ ] Add a CI check that fails if openapi.json is stale (same pattern as gazelle drift). Document the format command in docs/contributing.md.
  • [ ] Backfill summary, description, tags, and response_model on every existing FastAPI route. Audit and fix routes that are missing these fields. The codegen rules in Phases 2–3 depend on this metadata being present and correct.

Phase 2: CLI codegen pilot

  • [ ] Implement bazel/tools/openapi/cli_gen.py that emits typer modules from openapi.json. Output goes to tools/cli/generated/.
  • [ ] Implement tools/cli/_client.py (shared auth + retry + redirect re-auth).
  • [ ] Implement tools/cli/formatters/ registry with operationId lookup.
  • [ ] Pilot the codegen on the scheduler domain (smallest surface, two operations) without removing the hand-written domains. Both styles coexist — if the generated commands work in practice, proceed; if not, the ADR moves to Deprecated and we keep the hand-written style.
  • [ ] Generate a single pilot skill .claude/skills/private-api-scheduler/ using the same openapi_to_skill rule (Phase 3 generalizes it).
  • [ ] Decide on convention for the x-cli-skip, x-cli-formatter, and x-cli-positional extensions. Document in docs/contributing.md.

Phase 3: Migrate existing domains

  • [ ] Migrate knowledge (largest hand-written domain — biggest payoff). Preserve the existing terse output via the formatter registry, keyed by the operationIds that need bespoke shaping (search, notes, dead_letter).
  • [ ] Migrate tasks (smaller, similar shape).
  • [ ] Migrate home/schedule (read-only, trivial).
  • [ ] Migrate chat (deferred — streaming responses need the stub path).
  • [ ] Delete the hand-written *_cmd.py files and their per-file auth duplication. Keep the test files and migrate them to test the generated modules.

Phase 4: Skill generalization

  • [ ] Generalize openapi_to_skill to emit one skill per tag, covering every non-skipped operation.
  • [ ] Add a skill discovery doc at .claude/skills/private-api/README.md that points to the generated skills and explains they are derived, not hand-edited.
  • [ ] Add a CI check that fails if a route lacks summary or description so generated skills are never empty.

Security

The CLI continues to authenticate via Cloudflare Access (CF_Authorization cookie), inheriting the existing trust boundary. No security posture changes:

  • All generated commands hit the same private.jomcgi.dev host that hand-written commands do today; the HTTPRoute and CF-Access policy is the authoritative gate.
  • The codegen pipeline does not embed secrets in generated artifacts. The openapi.json exposes route shapes but no auth material — equivalent to what FastAPI's /openapi.json already serves to authorized clients.
  • Generated skills carry no credentials; they describe how to invoke the CLI, which then performs auth.

See docs/security.md for baseline. No deviations.


Risks

RiskLikelihoodImpactMitigation
Bespoke output formatters degrade under codegen and the CLI feels worse to useMediumMediumFormatter registry with x-cli-formatter extension keeps hand-tuned output for the routes that matter (knowledge search, tasks list); pilot on scheduler first to validate the seam before migrating any domain that has bespoke output today
openapi.json drift gets ignored if the CI check is too noisyLowMediumMake the regeneration a format-time pass (same UX as gazelle), so the inner loop fixes drift automatically before push
Codegen tool itself becomes a maintenance burden larger than the duplication it removesMediumHighKeep the generator small (~300 LOC target). If the generator grows past 600 LOC or sprouts plugins, that's the signal that hand-writing was actually fine — revisit the ADR
New CLI contributors confused by "where do I edit?" — generated vs. shared client vs. formatterMediumLowTop-of-file generator banners (# Generated from openapi.json — do not edit); contributing doc explains the three places code lives (route, formatter, client)
Streaming / multipart routes never get cleanly generated and the "escape hatch" path becomes the common caseLowMediumTrack the count of x-cli-skip routes; if it grows beyond ~20% of the surface, the codegen abstraction is leaky and we should reconsider
Generated skills are noisy and pollute Claude's skill discoveryMediumLowOne skill per tag, not per operation; tag-level skills enumerate operations in the body. Audit during Phase 4 — if Claude consistently picks the wrong skill, restructure or revert

Open Questions

  1. Where does openapi.json live in the tree? Options: projects/monolith/openapi.json (colocated with the API it describes — simplest) vs. bazel/contracts/monolith.openapi.json (centralized contracts directory — better if other services later need the same treatment). Default to colocated for now; revisit if a second service exposes a private API.
  2. Should the codegen tool be in this repo or vendored from upstream? datamodel-code-generator and openapi-python-client exist but have opinionated output that doesn't match our typer + bespoke-formatter shape. A small in-repo generator is probably cleaner. Leave the build/buy decision to Phase 2 implementation.
  3. Do we want a homelab shell completion pipeline? Generated commands can also feed typer-completion to ship zsh/bash/fish completions in the CLI image. Out of scope for the ADR but a natural follow-on.
  4. Skills granularity. Per-tag is the proposed default, but for some tags (e.g., knowledge with ~10 operations) per-operation skills might be more discoverable for Claude. Decide during Phase 4 with empirical data.

References

ResourceRelevance
tools/cli/Current hand-written CLI; the duplication this ADR removes
tools/cli/knowledge_cmd.pyReference for current command shape (auth, retry, output formatting)
projects/monolith/app/main.pyFastAPI app wiring; openapi.json source
docs/plans/2026-04-25-scheduler-api-design.mdThe forcing function — third domain paying duplication tax
FastAPI OpenAPI customizationMechanism for x-cli-* extensions on routes
Claude Code skills formatFrontmatter + body structure for .claude/skills/<name>/SKILL.md
ADR 002: Service Deployment ToolingSister ADR — same "scaffolding from a single source" philosophy applied to service creation