← back to Cli Printing Press
fix(cli): generator template polish (WU-1, F1+F2+F3+F6) (#576)
542835d393bf3efdc5870ef9c00feedbe0f0edf6 · 2026-05-04 12:13:46 -0700 · Trevin Chow
* fix(cli): generator template polish — usage line, framework Examples, framework JSON envelopes, sync trailing line
Four template defects in internal/generator/templates/ recur on every printed
CLI; movie-goat reprint surfaced them via dogfood and patched them post-hoc.
This change applies the same fixes back into the templates so future printed
CLIs ship clean.
F1 — Usage-line double-print
command_promoted.go.tmpl, command_endpoint.go.tmpl: drop cmd.Root().Name()
from the format args so usage errors print the binary name once, not twice.
cmd.CommandPath() already begins with the root command's name.
F2 — Framework command Examples
doctor.go.tmpl, profile.go.tmpl (save/use/list/show/delete), feedback.go.tmpl:
add Example: blocks using the {{ .Name }}-pp-cli convention. Dogfood
help-check failed for these on every CLI.
F3 — Framework command JSON envelopes
auth.go.tmpl, auth_simple.go.tmpl, auth_client_credentials.go.tmpl: status,
logout, set-token (where applicable) emit JSON envelopes when --json
is set. auth status's not-authenticated path still returns authErr after
writing JSON so exit code stays consistent.
api_discovery.go.tmpl: data restructured to a typed []ifaceEntry consumed by
both human and JSON paths; emits {interfaces, note} or {interface, methods}.
import.go.tmpl: {succeeded, failed, skipped}.
profile.go.tmpl delete: {deleted: name}.
tail.go.tmpl no-arg path: {resources, note} (helper tailKnownResources()
sources from .Resources, independent of sync's defaultSyncResources).
which.go.tmpl: swap json.NewEncoder for printJSONFiltered; wrap as
{matches: [...]} envelope (the wrap routes through compactObjectFields'
blocklist instead of compactListFields' allowlist that would strip the
Entry/Score keys); empty-match returns {matches: []} exit 0; non-JSON
keeps the existing exit-2 on no-match human path.
command_promoted.go.tmpl no-query path: write {error, usage} envelope first,
then return usageErr so exit code stays 2 across modes.
F6 — Sync trailing human line
sync.go.tmpl, graphql_sync.go.tmpl: wrap the trailing "Sync complete:"
fmt.Fprintf(os.Stderr, ...) block in `if humanFriendly { ... }` so it's
suppressed under --json. The sync_summary JSON event already carries the
same data.
JSON envelope shapes are best-effort-stable contracts going forward (additive
changes only) — regression-guarded by U5's golden artifact byte-comparison.
Plan: docs/plans/2026-05-04-002-fix-generator-template-polish-plan.md
Closes mvanhorn/cli-printing-press#572 (parent retro mvanhorn/cli-printing-press#571 — Movie Goat)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(cli): add plan for generator template polish (WU-1)
Plan authored from retro #571 sub-issue #572. Walked through ce-doc-review
with 13 findings applied (3 P1 critical: sync gate variable correction, auth
template flavors expansion, golden harness pivot to byte-comparison). Plan
status: completed by PR #576.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): simplify generator templates per /simplify review
Four small cleanups applied after /simplify reviewer pass on the WU-1
template polish (PR #576):
api_discovery.go.tmpl
Drop the parallel `interfaces []string` slice; iterate `ifaces`
directly in the human-output path. The format string is built once
per print, not stored. Removes one drift point and avoids building
the human strings under --json.
Also trim the WHAT-narrating sentence in the helper comment, keeping
only the WHY about pre-formatting blocking the JSON path.
sync.go.tmpl, graphql_sync.go.tmpl
Flatten the trailing summary block from `if !humanFriendly { JSON } if
humanFriendly { if/else }` into `if humanFriendly { if/else } else {
JSON }`. Single conditional, mutually exclusive branches, eliminates
the future-edit footgun where both flags set would emit twice.
tail.go.tmpl
Drop the dead `if resources == nil { resources = []string{} }` guard.
`tailKnownResources()` returns a `[]string{...}` literal which is
always non-nil even when empty.
Golden fixtures regenerated; `scripts/golden.sh verify` passes 11/11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-05-04-002-fix-generator-template-polish-plan.mdM internal/generator/templates/api_discovery.go.tmplM internal/generator/templates/auth.go.tmplM internal/generator/templates/auth_client_credentials.go.tmplM internal/generator/templates/auth_simple.go.tmplM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/doctor.go.tmplM internal/generator/templates/feedback.go.tmplM internal/generator/templates/graphql_sync.go.tmplM internal/generator/templates/import.go.tmplM internal/generator/templates/profile.go.tmplM internal/generator/templates/sync.go.tmplM internal/generator/templates/tail.go.tmplM internal/generator/templates/which.go.tmplM testdata/golden/cases/generate-golden-api/artifacts.txtM testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.goA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/api_discovery.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.goA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/feedback.goA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/import.goA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/profile.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.goA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.goA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/which.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
Diff
commit 542835d393bf3efdc5870ef9c00feedbe0f0edf6
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon May 4 12:13:46 2026 -0700
fix(cli): generator template polish (WU-1, F1+F2+F3+F6) (#576)
* fix(cli): generator template polish — usage line, framework Examples, framework JSON envelopes, sync trailing line
Four template defects in internal/generator/templates/ recur on every printed
CLI; movie-goat reprint surfaced them via dogfood and patched them post-hoc.
This change applies the same fixes back into the templates so future printed
CLIs ship clean.
F1 — Usage-line double-print
command_promoted.go.tmpl, command_endpoint.go.tmpl: drop cmd.Root().Name()
from the format args so usage errors print the binary name once, not twice.
cmd.CommandPath() already begins with the root command's name.
F2 — Framework command Examples
doctor.go.tmpl, profile.go.tmpl (save/use/list/show/delete), feedback.go.tmpl:
add Example: blocks using the {{ .Name }}-pp-cli convention. Dogfood
help-check failed for these on every CLI.
F3 — Framework command JSON envelopes
auth.go.tmpl, auth_simple.go.tmpl, auth_client_credentials.go.tmpl: status,
logout, set-token (where applicable) emit JSON envelopes when --json
is set. auth status's not-authenticated path still returns authErr after
writing JSON so exit code stays consistent.
api_discovery.go.tmpl: data restructured to a typed []ifaceEntry consumed by
both human and JSON paths; emits {interfaces, note} or {interface, methods}.
import.go.tmpl: {succeeded, failed, skipped}.
profile.go.tmpl delete: {deleted: name}.
tail.go.tmpl no-arg path: {resources, note} (helper tailKnownResources()
sources from .Resources, independent of sync's defaultSyncResources).
which.go.tmpl: swap json.NewEncoder for printJSONFiltered; wrap as
{matches: [...]} envelope (the wrap routes through compactObjectFields'
blocklist instead of compactListFields' allowlist that would strip the
Entry/Score keys); empty-match returns {matches: []} exit 0; non-JSON
keeps the existing exit-2 on no-match human path.
command_promoted.go.tmpl no-query path: write {error, usage} envelope first,
then return usageErr so exit code stays 2 across modes.
F6 — Sync trailing human line
sync.go.tmpl, graphql_sync.go.tmpl: wrap the trailing "Sync complete:"
fmt.Fprintf(os.Stderr, ...) block in `if humanFriendly { ... }` so it's
suppressed under --json. The sync_summary JSON event already carries the
same data.
JSON envelope shapes are best-effort-stable contracts going forward (additive
changes only) — regression-guarded by U5's golden artifact byte-comparison.
Plan: docs/plans/2026-05-04-002-fix-generator-template-polish-plan.md
Closes mvanhorn/cli-printing-press#572 (parent retro mvanhorn/cli-printing-press#571 — Movie Goat)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(cli): add plan for generator template polish (WU-1)
Plan authored from retro #571 sub-issue #572. Walked through ce-doc-review
with 13 findings applied (3 P1 critical: sync gate variable correction, auth
template flavors expansion, golden harness pivot to byte-comparison). Plan
status: completed by PR #576.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): simplify generator templates per /simplify review
Four small cleanups applied after /simplify reviewer pass on the WU-1
template polish (PR #576):
api_discovery.go.tmpl
Drop the parallel `interfaces []string` slice; iterate `ifaces`
directly in the human-output path. The format string is built once
per print, not stored. Removes one drift point and avoids building
the human strings under --json.
Also trim the WHAT-narrating sentence in the helper comment, keeping
only the WHY about pre-formatting blocking the JSON path.
sync.go.tmpl, graphql_sync.go.tmpl
Flatten the trailing summary block from `if !humanFriendly { JSON } if
humanFriendly { if/else }` into `if humanFriendly { if/else } else {
JSON }`. Single conditional, mutually exclusive branches, eliminates
the future-edit footgun where both flags set would emit twice.
tail.go.tmpl
Drop the dead `if resources == nil { resources = []string{} }` guard.
`tailKnownResources()` returns a `[]string{...}` literal which is
always non-nil even when empty.
Golden fixtures regenerated; `scripts/golden.sh verify` passes 11/11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
...05-04-002-fix-generator-template-polish-plan.md | 349 ++++++
internal/generator/templates/api_discovery.go.tmpl | 46 +-
internal/generator/templates/auth.go.tmpl | 21 +-
.../templates/auth_client_credentials.go.tmpl | 50 +-
internal/generator/templates/auth_simple.go.tmpl | 50 +-
.../generator/templates/command_endpoint.go.tmpl | 2 +-
.../generator/templates/command_promoted.go.tmpl | 12 +-
internal/generator/templates/doctor.go.tmpl | 3 +
internal/generator/templates/feedback.go.tmpl | 3 +
internal/generator/templates/graphql_sync.go.tmpl | 17 +-
internal/generator/templates/import.go.tmpl | 8 +
internal/generator/templates/profile.go.tmpl | 22 +-
internal/generator/templates/sync.go.tmpl | 17 +-
internal/generator/templates/tail.go.tmpl | 33 +-
internal/generator/templates/which.go.tmpl | 22 +-
.../golden/cases/generate-golden-api/artifacts.txt | 6 +
.../printing-press-oauth2-cc/internal/cli/auth.go | 48 +-
.../internal/cli/api_discovery.go | 109 ++
.../printing-press-golden/internal/cli/auth.go | 48 +-
.../printing-press-golden/internal/cli/doctor.go | 3 +
.../printing-press-golden/internal/cli/feedback.go | 225 ++++
.../printing-press-golden/internal/cli/import.go | 109 ++
.../printing-press-golden/internal/cli/profile.go | 344 ++++++
.../internal/cli/projects_tasks_update-project.go | 2 +-
.../printing-press-golden/internal/cli/sync.go | 1114 ++++++++++++++++++++
.../printing-press-golden/internal/cli/which.go | 219 ++++
.../tier-routing-golden/internal/cli/doctor.go | 3 +
.../tier-routing-golden/internal/cli/sync.go | 17 +-
28 files changed, 2838 insertions(+), 64 deletions(-)
diff --git a/docs/plans/2026-05-04-002-fix-generator-template-polish-plan.md b/docs/plans/2026-05-04-002-fix-generator-template-polish-plan.md
new file mode 100644
index 00000000..213c5646
--- /dev/null
+++ b/docs/plans/2026-05-04-002-fix-generator-template-polish-plan.md
@@ -0,0 +1,349 @@
+---
+title: Generator template polish — Usage line, framework Examples, framework JSON envelopes, sync trailing line
+type: fix
+status: active
+date: 2026-05-04
+origin: https://github.com/mvanhorn/cli-printing-press/issues/572
+---
+
+# Generator template polish — Usage line, framework Examples, framework JSON envelopes, sync trailing line
+
+## Summary
+
+Patch four defects in `internal/generator/templates/` so future printed CLIs ship with correct usage strings, framework-command Examples, framework-command JSON envelopes, and clean `sync --json` output. Reference shapes are already proven in `~/printing-press/library/movie-goat/` from this run.
+
+---
+
+## Problem Frame
+
+The movie-goat run surfaced four template defects (filed as findings F1, F2, F3, F6 in retro #571, absorbed into WU-1 / sub-issue #572). All four have already been patched directly in the printed CLI, which proved the target shapes work; they recur on every future generation because the templates haven't been touched. The durable fix is mechanical: copy the patched shapes back into the templates so the next CLI lands without these defects.
+
+---
+
+## Requirements
+
+- R1. Generated CLIs emit a single binary name in the usage error for any required-positional command (no `<cli> <cli> <subcmd>` doubling).
+- R2. Generated framework commands (`doctor`, `profile save/use/list/show/delete`, `feedback list`) emit an `Examples:` block in `--help`.
+- R3. Generated framework commands that print human prose by default (`auth status/logout/set-token`, `api`, `import`, `profile delete`, `tail` no-arg, `which`, `multi` no-arg) emit valid JSON when `--json` is set.
+- R4. Generated `sync` command produces output parseable as line-delimited JSON when `--json` is set, with `{"event":"sync_summary",...}` as the last non-empty line. No trailing human prose.
+- R5. Existing non-JSON behavior outside the four fixes (F1, F2, F3, F6) is unchanged across all affected commands. The fixes themselves (usage-line binary name, Examples blocks, JSON envelopes, sync trailing-line gate) are intentional changes and do not count as regressions under R5.
+- R6. The existing `testdata/golden/cases/generate-golden-api/` fixture is extended with new rendered-Go-source artifacts to lock these four contracts as regression guards. No new fixture case is added; byte-comparison of generated `internal/cli/*.go` files is the enforcement mechanism (see U5).
+
+---
+
+## Scope Boundaries
+
+- Generator templates only. No changes to printed CLIs already in `~/printing-press/library/`; backports are explicitly excluded — those are printed-CLI fixes outside this WU's scope.
+- Live dogfood matrix accuracy fixes (F4, F5 from the parent retro) are out of scope; they live in WU-2 / sub-issue #573 with its own plan.
+- F7 (inline `#` shell-comment in Cobra Examples) is out of scope; skipped at retro triage as an authoring-guidance issue rather than a template change.
+- No refactor of the `flags.asJSON` pattern or `printJSONFiltered` helper. The existing helpers and patterns are extended in place; no new helpers are introduced.
+
+### Deferred to Follow-Up Work
+
+- Backporting the new JSON envelope shapes into already-published library CLIs — separate concern, follow-up PRs against `printing-press-library` per CLI when (or if) MCP hosts depend on the new shapes.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/generator/templates/` — all 11 template files in scope:
+ - F1: `command_promoted.go.tmpl:95`, `command_endpoint.go.tmpl:128`
+ - F2: `doctor.go.tmpl`, `profile.go.tmpl`, `feedback.go.tmpl`
+ - F3: `auth.go.tmpl`, `api_discovery.go.tmpl`, `import.go.tmpl`, `profile.go.tmpl` (delete subcommand), `tail.go.tmpl`, `which.go.tmpl`, `command_promoted.go.tmpl`
+ - F6: `sync.go.tmpl` (and `graphql_sync.go.tmpl` if it has the same trailing-line issue)
+- `printJSONFiltered` helper — already used in `analytics.go.tmpl`, `feedback.go.tmpl`, `profile.go.tmpl`, `which.go.tmpl`, `share_commands.go.tmpl`, etc. The canonical shape is `return printJSONFiltered(cmd.OutOrStdout(), value, flags)` inside an `if flags.asJSON { ... }` branch. Extends without modification.
+- Binary-name placeholder convention — templates already use `{{ .Name }}-pp-cli` for in-text references (e.g., `doctor.go.tmpl` has `"{{ .Name }}-pp-cli auth login --chrome"`). New `Example:` strings follow the same pattern.
+- Reference shapes proven in this run — every JSON envelope and Examples block from movie-goat's printed CLI:
+ - `internal/cli/auth.go` (status / logout / set-token JSON envelopes)
+ - `internal/cli/api_discovery.go` (`{interfaces, note}`)
+ - `internal/cli/import.go` (`{succeeded, failed, skipped}`)
+ - `internal/cli/profile.go` (delete envelope + Examples on save/use/list/show/delete)
+ - `internal/cli/tail.go` (no-arg JSON help envelope)
+ - `internal/cli/which.go` (matches envelope)
+ - `internal/cli/sync.go` (suppress trailing human line under --json)
+ - `internal/cli/promoted_multi.go` (no-query JSON envelope)
+ - `internal/cli/doctor.go`, `feedback.go` (Examples)
+
+### Institutional Learnings
+
+- `docs/solutions/best-practices/adaptive-rate-limiting-sniffed-apis.md` — adjacent (rate limiting, not template polish); not directly applicable but confirms the pattern of capturing learnings from per-CLI patches that became generator fixes.
+- AGENTS.md golden harness section — "Golden cases must be deterministic, offline, and auth-free." Extending the existing `golden-api` fixture is the right shape; new fixtures only when current ones can't exercise the contract.
+
+### External References
+
+- None required. The WU is mechanical template polish with all shapes already proven in a printed CLI.
+
+---
+
+## Key Technical Decisions
+
+- **Use `printJSONFiltered`, not `json.NewEncoder`, for new JSON envelope branches.** AGENTS.md Phase 3 rule #2 mandates `printJSONFiltered` for hand-written novel commands so `--select`/`--compact`/`--csv` work; the same rule applies to generator-emitted framework commands. `which.go.tmpl` currently uses bare `json.NewEncoder` — bring it into line with the rest. Rationale: consistent flag behavior across every command in every printed CLI.
+- **Treat new JSON envelope shapes as best-effort-stable contracts, regression-guarded by U5's golden snapshots.** Once shipped, MCP hosts and agents will depend on the keys: `{authenticated, source, config}`, `{cleared, note}`, `{saved, config_path}`, `{interfaces, note}`, `{succeeded, failed, skipped}`, `{deleted}`, `{resources, note}`, `{matches}`, `{error, usage}`, and `sync`'s `sync_summary` event. Future changes additive only (add fields, never rename or remove). Two enforcement layers: (1) document the canonical shapes inline in template comments so the contract is visible at the emission site, (2) U5's byte-comparison of rendered Go source (`expected/<case>/library/.../internal/cli/<file>.go`) will fail CI if any key is renamed or removed, since the literal map keys appear in the snapshot.
+- **Commit structure: one PR, two logical commits (default).** Commit 1: F1 + F6 + F2 (mechanical fixes — usage line, sync trailing-line gate, Examples additions). Commit 2: F3 (per-template JSON envelope shapes, including the auth-flavor expansion across `auth_simple.go.tmpl` / `auth_client_credentials.go.tmpl` / `auth.go.tmpl`). The split reduces per-commit diff size without fragmenting the delivery. Exception: if review feedback suggests splitting into two sequential PRs, that's acceptable and commit 2 can be hoisted to a follow-up. Default remains one PR.
+- **Extend `testdata/golden/cases/generate-golden-api/` rather than add a new case.** The existing fixture already exercises the full template render path. Adding probe lines to `command.txt` and updating `expected/stdout.txt` is cheaper than scaffolding a new case and gives the same regression coverage. New case only if the contracts genuinely don't fit the existing one (they do).
+- **Golden update cadence: run `scripts/golden.sh update` once after all four fixes land.** Inspect the diff manually, confirm only the expected output strings changed, document in the PR description what shifted. Avoids per-commit golden churn.
+- **graphql_sync.go.tmpl gets the same F6 fix.** If the trailing human line exists there too (likely — it parallels sync.go.tmpl), apply the same `if !flags.asJSON` gate. If it doesn't, no change.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should F3's JSON shapes match the printed CLI shapes I already shipped?** Yes — those shapes were vetted by the live dogfood matrix and shipped to the public library in PR #229. Re-using them ensures any agents that already adapted to movie-goat's outputs see the same shape from future CLIs. If a different shape is preferred for a specific command, that's a separate decision but defers to the shipped form by default.
+- **Should `which.go.tmpl`'s shift from `json.NewEncoder` to `printJSONFiltered` be in scope?** Yes. It's a one-line swap that aligns with the rest of the codebase and gets `--select`/`--compact`/`--csv` for free. The current `json.NewEncoder` was technically a pre-existing tech-debt issue but addressing it inside this WU is right-sized.
+- **Should the doctor template emit a JSON envelope for --json?** It already produces structured output via `report := map[string]any{}` + `json.Marshal`. F3 doesn't include doctor; only Examples (F2) is needed for doctor. Confirmed by re-reading the printed CLI's `doctor.go` — JSON works there already.
+
+### Deferred to Implementation
+
+- _(Resolved during ce-doc-review: `graphql_sync.go.tmpl` has the same trailing-human-line pattern at lines 184-194. U2 now scopes both files explicitly with the `humanFriendly` gate.)_
+- Exact wording of the inline template comments documenting the JSON envelope contracts. The comments serve as both maintainer cues and PR review anchor; finalize during U4.
+- _(Resolved during ce-doc-review: neither stdout assertions nor live CLI probes — the approach is byte-comparison of rendered Go source files added to `artifacts.txt`. See U5.)_
+
+---
+
+## Implementation Units
+
+- U1. **Fix usage-line double-print (F1)**
+
+**Goal:** Generated CLIs emit a single binary name in the usage error for any required-positional command.
+
+**Requirements:** R1, R5
+
+**Dependencies:** None (U1–U4 are independent; U5 depends on all four).
+
+**Files:**
+- Modify: `internal/generator/templates/command_promoted.go.tmpl`
+- Modify: `internal/generator/templates/command_endpoint.go.tmpl`
+- Test: `internal/generator/template_usage_error_test.go` (create)
+
+**Approach:**
+- Replace the format string and arg list at line 95 of `command_promoted.go.tmpl` and line 128 of `command_endpoint.go.tmpl`. Drop `cmd.Root().Name(),` and the leading `%s ` so the format reads `"%s is required\nUsage: %s <%s>"` with arg list `cmd.CommandPath(), "{{.Name}}"`.
+- `cmd.CommandPath()` already starts with the root command's name; concatenating with `cmd.Root().Name()` duplicates it.
+
+**Patterns to follow:**
+- Other templates that already use `cmd.CommandPath()` correctly without prefixing `cmd.Root().Name()` (search the templates dir if a reference is needed).
+
+**Test scenarios:**
+- Happy path: render `command_promoted.go.tmpl` with a representative endpoint having a required positional. Assert the rendered Go source contains `cmd.CommandPath()` exactly once and does not contain `cmd.Root().Name(), cmd.CommandPath()` or `%s %s` together. Covers R1.
+- Happy path: render `command_endpoint.go.tmpl` similarly. Same assertion. Covers R1.
+- Negative: render an endpoint without required positionals. Assert the rendered Go source contains no usage-error block at all (or the existing block is unchanged structurally — whichever the template's current shape produces).
+
+**Verification:**
+- `go test ./internal/generator/...` passes.
+- After landing, regenerate the golden-api fixture: `scripts/golden.sh update` produces a diff in usage-error strings only (binary name appears exactly once in each affected expected line).
+
+---
+
+- U2. **Suppress sync trailing human line under `--json` (F6)**
+
+**Goal:** Generated `sync` command produces parseable JSON when `--json` is set.
+
+**Requirements:** R4, R5
+
+**Dependencies:** None (U1–U4 are independent; U5 depends on all four).
+
+**Files:**
+- Modify: `internal/generator/templates/sync.go.tmpl` (lines 249-255 — the `Sync complete:` trailing block)
+- Modify: `internal/generator/templates/graphql_sync.go.tmpl` (lines 184-194 — confirmed identical pattern)
+- Test: `internal/generator/sync_json_output_test.go` (create)
+
+**Approach:**
+- Locate the trailing `fmt.Fprintf(os.Stderr, "Sync complete: ...")` block at the end of the sync RunE. Wrap it in `if !humanFriendly { ... }` matching the surrounding template idiom — `humanFriendly` is the package-level bool declared in `helpers.go.tmpl:42` and bound to the `--human-friendly` flag in `root.go.tmpl:153`. Every existing output gate in `sync.go.tmpl` (including the `sync_summary` event gate at lines 245-248) uses `humanFriendly`, not `flags.asJSON`.
+- Note: the trailing block writes to `os.Stderr`, not `out`; preserve that.
+- The `sync_summary` event already emitted into the JSON stream carries `total_records`, `resources`, `success`, `warned`, `errored`, `duration_ms` — same data as the human line. No information is lost.
+- Apply the same `if !humanFriendly { ... }` gate to `graphql_sync.go.tmpl:184-194`. The pattern is confirmed identical.
+
+**Patterns to follow:**
+- Existing `humanFriendly` gates throughout `sync.go.tmpl` (30+ sites; the `sync_summary` event gate at lines 245-248 is the closest analogue).
+- `analytics.go.tmpl` and `feedback.go.tmpl` use `flags.asJSON` directly because they're in different stream contexts; the sync template's idiom is `humanFriendly`.
+
+**Test scenarios:**
+- Happy path: render `sync.go.tmpl` with the canonical golden-api fixture. Assert the rendered Go source contains `if !humanFriendly {` wrapping the trailing `fmt.Fprintf(os.Stderr, ...)` for the human "Sync complete" line. Covers R4.
+- Happy path: same assertion for `graphql_sync.go.tmpl` (lines 184-194 equivalent). Covers R4.
+- Integration (via golden harness in U5 — see U5's source-grep approach): generated sync's rendered Go source contains the `humanFriendly` gate wrapping the trailing block. Covers R4 end-to-end.
+- Negative: rendered sync command run without `--json` (and with default `humanFriendly=true`) still prints "Sync complete: ..." to stderr as today. Covers R5.
+
+**Verification:**
+- `go test ./internal/generator/...` passes.
+- `scripts/golden.sh verify` shows expected diff only on sync-related output lines.
+
+---
+
+- U3. **Add `Example:` blocks to framework command templates (F2)**
+
+**Goal:** Generated framework commands (`doctor`, `profile save/use/list/show/delete`, `feedback list`) carry `Examples:` blocks in `--help`.
+
+**Requirements:** R2, R5
+
+**Dependencies:** None (U1–U4 are independent; U5 depends on all four).
+
+**Files:**
+- Modify: `internal/generator/templates/doctor.go.tmpl`
+- Modify: `internal/generator/templates/profile.go.tmpl`
+- Modify: `internal/generator/templates/feedback.go.tmpl`
+- Test: `internal/generator/framework_examples_test.go` (create)
+
+**Approach:**
+- Add an `Example:` field to each cobra command literal in the named templates, using the `{{ .Name }}-pp-cli` placeholder convention already established in those templates.
+- Reference shapes (proven in this run's printed CLI):
+ - doctor: `{{ .Name }}-pp-cli doctor` / `... doctor --json` / `... doctor --fail-on warn`
+ - profile save: `... profile save my-defaults --json --compact` / `... profile save tonight-defaults --region US`
+ - profile use: `... profile use my-defaults` / `... profile use tonight-defaults --json`
+ - profile list: `... profile list` / `... profile list --json`
+ - profile show: `... profile show my-defaults` / `... profile show tonight-defaults --json`
+ - profile delete: `... profile delete my-defaults --yes` / `... profile delete old-profile --yes --json`
+ - feedback list: `... feedback list` / `... feedback list --limit 5` / `... feedback list --json`
+- Keep examples generic enough that they apply to any printed CLI's profile/feedback semantics, not movie-goat-specific. `tonight-defaults` is generic enough as a profile name; if reviewers prefer `my-profile`/`other-profile`, swap during code review.
+
+**Patterns to follow:**
+- Existing `Example:` fields in `analytics.go.tmpl`, `auth.go.tmpl` (status), and `which.go.tmpl`.
+
+**Test scenarios:**
+- Happy path: render each affected template. Assert the rendered Go source contains `Example:` for each cobra command literal. Covers R2.
+- Integration (via golden harness in U5): `<generated-cli> doctor --help`, `<generated-cli> profile save --help`, `<generated-cli> feedback list --help` (and the other profile subcommands) all contain `Examples:` in stdout. Covers R2 end-to-end.
+- Negative: existing Examples on commands that already had them (e.g., `auth status`) are unchanged. Covers R5.
+
+**Verification:**
+- `go test ./internal/generator/...` passes.
+- Live dogfood help-check failures for these 7 commands resolve when running against a freshly-generated CLI.
+
+---
+
+- U4. **Add JSON envelopes to framework command templates (F3)**
+
+**Goal:** Generated framework commands respect `--json` by emitting a documented JSON envelope.
+
+**Requirements:** R3, R5
+
+**Dependencies:** None (U1–U4 are independent; U5 depends on all four).
+
+**Files:**
+- Modify: `internal/generator/templates/auth_simple.go.tmpl` (status at line 37, logout at line 100, set-token at line 69) — token-auth flavor; this is what movie-goat used
+- Modify: `internal/generator/templates/auth_client_credentials.go.tmpl` (status at line 166, logout at line 225, set-token at line 204) — client-credentials flavor
+- Modify: `internal/generator/templates/auth.go.tmpl` (status / logout only — this is the OAuth-flavored path; no set-token here)
+- Modify: `internal/generator/templates/api_discovery.go.tmpl`
+- Modify: `internal/generator/templates/import.go.tmpl`
+- Modify: `internal/generator/templates/profile.go.tmpl` (delete subcommand)
+- Modify: `internal/generator/templates/tail.go.tmpl` (no-arg path)
+- Modify: `internal/generator/templates/which.go.tmpl` (swap `json.NewEncoder` for `printJSONFiltered`, wrap matches in `{matches: ...}` envelope)
+- Modify: `internal/generator/templates/command_promoted.go.tmpl` (no-query path emits JSON envelope under `--json`)
+- Test: `internal/generator/framework_json_envelopes_test.go` (create)
+
+The auth flavor is selected by spec's `auth.type` (oauth vs api_key vs client_credentials → different template renders). The same JSON envelope shapes (`{authenticated, source, config}`, `{cleared, note}`, `{saved, config_path}`) land in every flavor so the agent-facing contract is consistent regardless of which is rendered. Total emission sites for auth: status × 3, logout × 3, set-token × 2 (the OAuth flavor doesn't have a set-token subcommand).
+
+**Approach:**
+- For each affected template, add an `if flags.asJSON { return printJSONFiltered(cmd.OutOrStdout(), envelope, flags) }` branch BEFORE the existing human prose path. Existing behavior is preserved when `--json` is absent.
+- Canonical envelope shapes (matching the proven shapes from this run's printed CLI; document each in an inline template comment at the emission site):
+ - `auth status`: `{authenticated: bool, source: cfg.AuthSource, config: cfg.Path}`. Preserve existing exit-non-zero-on-not-authenticated semantics under `--json` — write JSON, then return `authErr`.
+ - `auth logout`: `{cleared: true, note: "<env_var> env var is still set"}` when env still set; `{cleared: true}` otherwise. The env var name is template-substituted via existing auth env-vars list.
+ - `auth set-token`: `{saved: true, config_path: cfg.Path}`.
+ - `api` (root path): `{interfaces: [...], note: "<message>"}` (interfaces empty if none, with a note). **Implementation note:** the current template (lines 54-59) builds interfaces as pre-formatted strings via `fmt.Sprintf(" %-45s %s", ...)`. Restructure to assemble a typed `[]struct{Name, Short string}` once, then use it for both the human format loop AND the JSON marshal — not a one-line branch addition. Same restructuring for the methods sub-path.
+ - `api <interface>`: `{interface: name, methods: [...]}`.
+ - `import`: `{succeeded: N, failed: M, skipped: K}`.
+ - `profile delete`: `{deleted: name}`.
+ - `tail` (no resource arg + `--json`): `{resources: [...], note: "tail requires a resource name; pass one of the listed names"}`, return nil (exit 0). Existing happy path with a resource arg continues to stream NDJSON. **Implementation note:** verify whether the resource list is already a typed `[]string` or requires the same restructuring as `api_discovery` — if the human path currently builds resource names via formatted strings, assemble a typed slice once for both paths.
+ - `which`: wrap matches as `{matches: [...]}`. Empty match under `--json` returns `{matches: []}` exit 0; non-JSON path keeps existing exit-2 on no-match human behavior.
+ - `command_promoted` (no query + `--json`): write `{error: "<thing> is required", usage: "<usage string>"}` to stdout, then return `usageErr(...)` so exit code stays 2 — same as the non-JSON path. The envelope is informational about the error; the exit code carries the actual status. Matches the `auth status` not-authenticated pattern (write JSON, return authErr).
+- For `which.go.tmpl`, replace the bare `json.NewEncoder` block with `printJSONFiltered` so `--select`/`--compact`/`--csv` work consistently.
+- Inline template comment at each emission site documenting the canonical shape, e.g., `// JSON envelope: {authenticated, source, config} — see WU-1 / issue #572`.
+
+**Patterns to follow:**
+- `feedback.go.tmpl`'s existing JSON branch (`if flags.asJSON { return printJSONFiltered(... , map[string]any{...}, flags) }`) — exact shape to clone for the envelope-only commands.
+- `analytics.go.tmpl`'s `if flags.asJSON { ... } else { ... }` gate — exact shape for commands that have human prose to preserve under `else`.
+- For commands that need to return non-zero after writing JSON (auth status not-authenticated): write JSON first, then return the typed error — same as the existing patched shape in movie-goat's `auth.go`.
+
+**Test scenarios:**
+- Happy path: render each affected template. Assert each cobra command literal in scope contains an `if flags.asJSON {` branch and references `printJSONFiltered`. Covers R3.
+- Edge case: `auth status` not-authenticated under `--json`: assert the rendered source writes the JSON envelope before returning `authErr`. Covers R3 + the not-authenticated semantic.
+- Edge case: `which` with no match under `--json`: assert the rendered source emits `{matches: []}` and returns nil (exit 0); under non-JSON the existing exit-2 path is preserved. Covers R3 + R5.
+- Edge case: `which --json --compact` against the generated CLI: assert each match in the `matches` array preserves `entry`/`score` keys (i.e., the envelope wrap routes through `compactObjectFields`'s blocklist, not `compactListFields`'s allowlist). Covers the printJSONFiltered swap correctness.
+- Edge case: `which --csv` against the generated CLI: assert the output is CSV-shaped (the bare `json.NewEncoder` baseline ignored `--csv`; the new path honors it).
+- Integration (via U5 golden harness): rendered Go source for each affected file in `internal/cli/` contains the expected `if flags.asJSON {` block per the byte-comparison snapshot. Covers R3 end-to-end.
+- Negative: each named command without `--json` produces the same human prose as today. Covers R5.
+
+**Verification:**
+- `go test ./internal/generator/...` passes.
+- Live dogfood `json_fidelity` failures for the 9 framework commands listed in F3 resolve when running against a freshly-generated CLI.
+
+---
+
+- U5. **Extend `testdata/golden/cases/generate-golden-api/` to lock the four contracts (R6)**
+
+**Goal:** Golden harness regression-guards the four fixes so future template churn can't silently regress them.
+
+**Requirements:** R6
+
+**Dependencies:** U1, U2, U3, U4 (all four fixes must be in place before the golden update accurately captures the new expected output).
+
+**Files:**
+- Modify: `testdata/golden/cases/generate-golden-api/artifacts.txt` (add the new emission-bearing rendered files for byte-comparison)
+- Add: `testdata/golden/expected/generate-golden-api/library/golden-api-pp-cli/internal/cli/{auth_simple,api_discovery,import,profile,tail,which,promoted_multi,sync,doctor,feedback}.go` (snapshots of the rendered Go source, captured by `golden.sh update` once U1-U4 land)
+
+**Approach:**
+- The golden harness today runs `printing-press generate/dogfood/scorecard` and compares stdout/stderr/exit + listed artifacts. It does NOT chdir into the generated CLI, build it, or invoke its commands. Pivoting U5 to source-grep / artifact-byte assertions stays inside that envelope: the contracts being locked are template-emission contracts, and rendered Go source is the right artifact to snapshot.
+- Add the rendered Go source files listed above to `artifacts.txt`. The byte-comparison mechanism already in place will then catch:
+ - F1: missing `cmd.Root().Name(),` regression in `usageErr` format strings (single binary name)
+ - F2: presence of `Example:` blocks in framework commands (`doctor.go`, `profile.go`, `feedback.go`)
+ - F3: presence of `if flags.asJSON {` branches referencing `printJSONFiltered` in framework commands (`auth_simple.go`, `api_discovery.go`, `import.go`, `profile.go` delete, `tail.go`, `which.go`, `promoted_multi.go`)
+ - F6: `if !humanFriendly {` gate wrapping the trailing `Sync complete:` block in `sync.go` (and same for `graphql_sync.go` if a graphql-flavored fixture is added later)
+- Run `scripts/golden.sh update` once to capture the new expected snapshots. Inspect the diff manually before committing — confirm only the four contract changes appear and no unrelated incidental shifts (template churn elsewhere) bleed in.
+- Document the new artifacts in the PR description so reviewers can map each captured file back to a finding.
+
+**Patterns to follow:**
+- AGENTS.md "Golden Output Harness" section — keep cases deterministic, offline, auth-free. The source-grep / byte-comparison approach honors all three (no live CLI execution, no auth, deterministic generator output).
+- Existing case structure: `artifacts.txt` lists files for byte-comparison against `expected/<case>/<path>`; the harness diffs them at verify time.
+- AGENTS.md guidance: "Snapshot the specific files or output fields that demonstrate the stable behavior. Do not include broad reports, whole generated trees, or incidental diagnostics."
+
+**Test scenarios:**
+- Integration: `scripts/golden.sh verify` passes after the four template fixes land and the golden-api expected snapshots are regenerated. Covers R6.
+- Integration: with U1's fix in place, intentionally revert one of the two template lines and re-run `scripts/golden.sh verify`. The verifier should fail with a diff showing the doubled `cmd.Root().Name(), cmd.CommandPath()` reverting in the captured `promoted_multi.go`. Confirms the regression guard catches the F1 regression.
+- Integration: same revert test for F2 (delete an `Example:` block from a framework template), F3 (remove an `if flags.asJSON` branch), F6 (remove the `if !humanFriendly` gate). All should produce verifiable diffs in `expected/<file>.go`.
+
+**Verification:**
+- `scripts/golden.sh verify` passes.
+- Diff between old and new `expected/stdout.txt` contains only the new probe outputs and the four expected behavior changes; no unrelated rendered-file shifts.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph:** The four template fixes affect any newly-generated printed CLI's framework command surface — future generations plus any CLI explicitly regenerated. Already-published CLIs in the `printing-press-library` repo are unaffected until backported via separate follow-up PRs (explicitly deferred — see Scope Boundaries). No runtime effect on existing published CLIs.
+- **Error propagation:** F3's `auth status` change preserves the existing typed `authErr` return when not authenticated, ensuring callers who depend on exit codes still see the right exit even when JSON is requested. No new error paths introduced.
+- **State lifecycle risks:** None. All four fixes are pure-output changes in commands that don't mutate persisted state.
+- **API surface parity:** The new JSON envelope shapes for `auth status`, `auth logout`, `auth set-token`, `api`, `import`, `profile delete`, `tail` (no-arg), `which`, `command_promoted` (no-query), and `sync_summary` become Tier-1 stable contracts. Future changes additive only (add fields, never rename or remove existing keys). MCP hosts and agents may depend on these shapes.
+- **Integration coverage:** The golden harness extension in U5 covers cross-template integration — exercises the full generator render pipeline against a representative spec and verifies the output contracts. Unit tests in U1-U4 cover per-template emission, but the full integration is the golden harness.
+- **Unchanged invariants:** The existing `printJSONFiltered` helper, `flags.asJSON` field, `cmd.CommandPath()` semantics, all existing template `Example:` blocks on commands that already had them, and all existing non-JSON output paths are explicitly unchanged.
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| New JSON envelope shapes lock in contracts that future maintainers may want to change. | Two-layer mitigation: (1) inline template comments document each shape at the emission site; (2) U5's `expected/<file>.go` byte-snapshots include the literal map keys, so any rename/removal fails CI's golden verify. PR description states the contracts explicitly. Future changes follow additive-only rule (add fields, never rename or remove). |
+| Golden harness diff in U5 is noisier than expected (incidental output shifts from another change merging concurrently). | Run `scripts/golden.sh verify` once before starting U5 to confirm baseline is clean. If unexpected diffs appear, isolate to this WU's changes before committing the golden update. |
+| `graphql_sync.go.tmpl` may have a different trailing-line shape than `sync.go.tmpl`, requiring a non-mechanical fix. | U2's approach allows skip-if-not-applicable. If the GraphQL sync template's trailing path differs significantly, file a follow-up sub-issue under #571 rather than expanding this WU. |
+| `which.go.tmpl`'s shift from `json.NewEncoder` to `printJSONFiltered` is NOT a 1:1 output-shape swap. | `printJSONFiltered` routes through `printOutputWithFlags` (`helpers.go.tmpl:592`), which applies `--compact` allowlisting via `compactListFields` (line 662) — for a bare array of `whichMatch{Entry, Score}`, `--compact` would strip every entry. The plan's `{matches: [...]}` wrap routes through `compactObjectFields` (blocklist), which preserves the data. The wrap is what makes the swap safe, not output-format equivalence. Add a U4 test asserting `which --json` produces the wrapped envelope shape, plus separate tests for `--compact` and `--csv` (which the bare `json.NewEncoder` did not honor). |
+| `printJSONFiltered`'s `--select`/`--compact`/`--csv` behaviors apply to framework commands that previously didn't support them; users may see surprising filter behavior. | Acceptable — global flags should affect all commands consistently. Document in PR description as a behavior consistency improvement. The U4 test scenarios now exercise `--compact` and `--csv` for `which` so the new behavior is captured rather than discovered. |
+
+---
+
+## Documentation / Operational Notes
+
+- PR description lists each finding (F1, F2, F3, F6) and the exact JSON envelope shapes introduced. This is the canonical reference for downstream MCP host implementations.
+- AGENTS.md "Code & Comment Hygiene" rule allows inline template comments documenting WHY (a hidden constraint or stable contract); add comments at each new JSON envelope emission site naming the contract.
+- After landing, the next regeneration of any published library CLI (via `/printing-press-reprint`) will pick up these fixes automatically. No coordination needed.
+
+---
+
+## Sources & References
+
+- **Sub-issue:** [#572 — WU-1: Generator template polish](https://github.com/mvanhorn/cli-printing-press/issues/572)
+- **Parent retro:** [#571 — Movie Goat retro](https://github.com/mvanhorn/cli-printing-press/issues/571)
+- **Reference patches:** `~/printing-press/library/movie-goat/internal/cli/{auth,api_discovery,import,profile,tail,which,sync,promoted_multi,doctor,feedback}.go`
+- **Golden harness:** `scripts/golden.sh`, `testdata/golden/cases/generate-golden-api/`
+- **Existing JSON envelope pattern:** `internal/generator/templates/feedback.go.tmpl`, `internal/generator/templates/analytics.go.tmpl`
+- **AGENTS.md sections:** "Golden Output Harness", "Code & Comment Hygiene", "Phase 3 rule #2 (printJSONFiltered)"
diff --git a/internal/generator/templates/api_discovery.go.tmpl b/internal/generator/templates/api_discovery.go.tmpl
index a6a4b96f..2f27195e 100644
--- a/internal/generator/templates/api_discovery.go.tmpl
+++ b/internal/generator/templates/api_discovery.go.tmpl
@@ -37,6 +37,21 @@ Run 'api <interface>' to see that interface's methods.`,
for _, child := range root.Commands() {
if child.Hidden && strings.ToLower(child.Name()) == target {
methods := child.Commands()
+ // JSON envelope: {interface, short, methods: [{name, short}, ...]}.
+ if flags.asJSON {
+ methodList := make([]map[string]any, 0, len(methods))
+ for _, method := range methods {
+ methodList = append(methodList, map[string]any{
+ "name": method.Name(),
+ "short": method.Short,
+ })
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "interface": child.Name(),
+ "short": child.Short,
+ "methods": methodList,
+ }, flags)
+ }
if len(methods) == 0 {
return child.Help()
}
@@ -51,22 +66,39 @@ Run 'api <interface>' to see that interface's methods.`,
return fmt.Errorf("interface %q not found. Run '%s-pp-cli api' to list all interfaces", args[0], "{{.Name}}")
}
- var interfaces []string
+ // Pre-formatting human strings ahead of time would block the JSON
+ // path from emitting clean field values; build the typed slice and
+ // derive human format on print.
+ type ifaceEntry struct {
+ Name string `json:"name"`
+ Short string `json:"short"`
+ }
+ var ifaces []ifaceEntry
for _, child := range root.Commands() {
if child.Hidden {
- interfaces = append(interfaces, fmt.Sprintf(" %-45s %s", child.Name(), child.Short))
+ ifaces = append(ifaces, ifaceEntry{Name: child.Name(), Short: child.Short})
+ }
+ }
+ sort.Slice(ifaces, func(i, j int) bool { return ifaces[i].Name < ifaces[j].Name })
+
+ // JSON envelope: {interfaces: [...], note?: "..."}.
+ if flags.asJSON {
+ out := map[string]any{"interfaces": ifaces}
+ if len(ifaces) == 0 {
+ out["interfaces"] = []ifaceEntry{}
+ out["note"] = "No hidden API interfaces found."
}
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
}
- sort.Strings(interfaces)
- if len(interfaces) == 0 {
+ if len(ifaces) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No hidden API interfaces found.")
return nil
}
- fmt.Fprintf(cmd.OutOrStdout(), "Available API interfaces (%d):\n\n", len(interfaces))
- for _, line := range interfaces {
- fmt.Fprintln(cmd.OutOrStdout(), line)
+ fmt.Fprintf(cmd.OutOrStdout(), "Available API interfaces (%d):\n\n", len(ifaces))
+ for _, e := range ifaces {
+ fmt.Fprintf(cmd.OutOrStdout(), " %-45s %s\n", e.Name, e.Short)
}
fmt.Fprintf(cmd.OutOrStdout(), "\nUse '%s-pp-cli api <interface>' to see methods.\n", "{{.Name}}")
return nil
diff --git a/internal/generator/templates/auth.go.tmpl b/internal/generator/templates/auth.go.tmpl
index fccd05a8..bf4ec25b 100644
--- a/internal/generator/templates/auth.go.tmpl
+++ b/internal/generator/templates/auth.go.tmpl
@@ -200,7 +200,18 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
}
w := cmd.OutOrStdout()
- if cfg.AccessToken == "" {
+ authed := cfg.AccessToken != ""
+ // JSON envelope: {authenticated, source, config}.
+ if flags.asJSON {
+ out := map[string]any{
+ "authenticated": authed,
+ "source": cfg.AuthSource,
+ "config": cfg.Path,
+ }
+ return printJSONFiltered(w, out, flags)
+ }
+
+ if !authed {
fmt.Fprintf(w, " %s Not authenticated. Run 'auth login' to authenticate.\n", red("FAIL"))
return nil
}
@@ -237,6 +248,14 @@ func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
if err := cfg.ClearTokens(); err != nil {
return fmt.Errorf("clearing tokens: %w", err)
}
+ // JSON envelope: {cleared: true}. The OAuth flavor does not
+ // surface a "note" key because env-var creds aren't part of
+ // this auth flow's contract.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "cleared": true,
+ }, flags)
+ }
fmt.Fprintf(os.Stderr, "Logged out. Tokens removed.\n")
return nil
},
diff --git a/internal/generator/templates/auth_client_credentials.go.tmpl b/internal/generator/templates/auth_client_credentials.go.tmpl
index d3bdd719..d3522b93 100644
--- a/internal/generator/templates/auth_client_credentials.go.tmpl
+++ b/internal/generator/templates/auth_client_credentials.go.tmpl
@@ -176,7 +176,25 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
w := cmd.OutOrStdout()
header := cfg.AuthHeader()
- if header == "" {
+ authed := header != ""
+ // JSON envelope: {authenticated, source, config}. When not
+ // authenticated, write the envelope first then return authErr
+ // so exit code carries the auth-failure signal.
+ if flags.asJSON {
+ out := map[string]any{
+ "authenticated": authed,
+ "source": cfg.AuthSource,
+ "config": cfg.Path,
+ }
+ if printErr := printJSONFiltered(w, out, flags); printErr != nil {
+ return printErr
+ }
+ if !authed {
+ return authErr(fmt.Errorf("no credentials configured"))
+ }
+ return nil
+ }
+ if !authed {
fmt.Fprintln(w, red("Not authenticated"))
fmt.Fprintln(w, "")
fmt.Fprintln(w, "Mint a token:")
@@ -216,6 +234,13 @@ func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
return configErr(fmt.Errorf("saving token: %w", err))
}
+ // JSON envelope: {saved, config_path}.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "saved": true,
+ "config_path": cfg.Path,
+ }, flags)
+ }
fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
return nil
},
@@ -235,12 +260,29 @@ func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
if err := cfg.ClearTokens(); err != nil {
return configErr(fmt.Errorf("clearing tokens: %w", err))
}
+
+ // Identify which (if any) auth env var is still exported so the
+ // JSON envelope and the human prose can both surface it.
+ envStillSet := ""
{{- range .Auth.EnvVars}}
- if os.Getenv("{{.}}") != "" {
- fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: {{.}} env var is still set.\n")
- return nil
+ if envStillSet == "" && os.Getenv("{{.}}") != "" {
+ envStillSet = "{{.}}"
}
{{- end}}
+
+ // JSON envelope: {cleared: true, note?: "<env_var> env var is still set"}.
+ if flags.asJSON {
+ out := map[string]any{"cleared": true}
+ if envStillSet != "" {
+ out["note"] = envStillSet + " env var is still set"
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+
+ if envStillSet != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: %s env var is still set.\n", envStillSet)
+ return nil
+ }
fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
return nil
},
diff --git a/internal/generator/templates/auth_simple.go.tmpl b/internal/generator/templates/auth_simple.go.tmpl
index 8cb81351..0fa68588 100644
--- a/internal/generator/templates/auth_simple.go.tmpl
+++ b/internal/generator/templates/auth_simple.go.tmpl
@@ -47,7 +47,25 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
w := cmd.OutOrStdout()
header := cfg.AuthHeader()
- if header == "" {
+ authed := header != ""
+ // JSON envelope: {authenticated, source, config}. When not
+ // authenticated, write the envelope first then return authErr
+ // so exit code carries the auth-failure signal.
+ if flags.asJSON {
+ out := map[string]any{
+ "authenticated": authed,
+ "source": cfg.AuthSource,
+ "config": cfg.Path,
+ }
+ if printErr := printJSONFiltered(w, out, flags); printErr != nil {
+ return printErr
+ }
+ if !authed {
+ return authErr(fmt.Errorf("no credentials configured"))
+ }
+ return nil
+ }
+ if !authed {
fmt.Fprintln(w, red("Not authenticated"))
fmt.Fprintln(w, "")
fmt.Fprintln(w, "Set your token:")
@@ -91,6 +109,13 @@ func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
return configErr(fmt.Errorf("saving token: %w", err))
}
+ // JSON envelope: {saved, config_path}.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "saved": true,
+ "config_path": cfg.Path,
+ }, flags)
+ }
fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
return nil
},
@@ -112,13 +137,28 @@ func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
return configErr(fmt.Errorf("clearing tokens: %w", err))
}
- // Warn if env vars still set
+ // Identify which (if any) auth env var is still exported so the
+ // JSON envelope and the human prose can both surface it.
+ envStillSet := ""
{{- range .Auth.EnvVars}}
- if os.Getenv("{{.}}") != "" {
- fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: {{.}} env var is still set.\n")
- return nil
+ if envStillSet == "" && os.Getenv("{{.}}") != "" {
+ envStillSet = "{{.}}"
}
{{- end}}
+
+ // JSON envelope: {cleared: true, note?: "<env_var> env var is still set"}.
+ if flags.asJSON {
+ out := map[string]any{"cleared": true}
+ if envStillSet != "" {
+ out["note"] = envStillSet + " env var is still set"
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+
+ if envStillSet != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: %s env var is still set.\n", envStillSet)
+ return nil
+ }
fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
return nil
},
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 880c3bb2..4c0e0d7d 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -125,7 +125,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- if .Positional}}
{{- if gt $i 0}}
if len(args) < {{add $i 1}} {
- return usageErr(fmt.Errorf("{{.Name}} is required\nUsage: %s %s <%s>", cmd.Root().Name(), cmd.CommandPath(), "{{.Name}}"))
+ return usageErr(fmt.Errorf("{{.Name}} is required\nUsage: %s <%s>", cmd.CommandPath(), "{{.Name}}"))
}
{{- end}}
{{- if pathContainsParam $.Endpoint.Path .Name}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 28aa5bd1..6127d5ef 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -92,7 +92,17 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- range $i, $p := .Endpoint.Params}}
{{- if .Positional}}
if len(args) < {{add $i 1}} {
- return usageErr(fmt.Errorf("{{.Name}} is required\nUsage: %s %s <%s>", cmd.Root().Name(), cmd.CommandPath(), "{{.Name}}"))
+ // JSON envelope: {error, usage}. Written first; the
+ // usageErr return preserves exit code 2 across modes.
+ if flags.asJSON {
+ if printErr := printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "error": "{{.Name}} is required",
+ "usage": fmt.Sprintf("%s <%s>", cmd.CommandPath(), "{{.Name}}"),
+ }, flags); printErr != nil {
+ return printErr
+ }
+ }
+ return usageErr(fmt.Errorf("{{.Name}} is required\nUsage: %s <%s>", cmd.CommandPath(), "{{.Name}}"))
}
{{- if pathContainsParam $.Endpoint.Path .Name}}
path = replacePathParam(path, "{{.Name}}", args[{{$i}}])
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 4c49e24c..1bb0107a 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -91,6 +91,9 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "Check CLI health",
+ Example: ` {{.Name}}-pp-cli doctor
+ {{.Name}}-pp-cli doctor --json
+ {{.Name}}-pp-cli doctor --fail-on warn`,
RunE: func(cmd *cobra.Command, args []string) error {
report := map[string]any{}
diff --git a/internal/generator/templates/feedback.go.tmpl b/internal/generator/templates/feedback.go.tmpl
index 1a45e19e..a9b84ed8 100644
--- a/internal/generator/templates/feedback.go.tmpl
+++ b/internal/generator/templates/feedback.go.tmpl
@@ -184,6 +184,9 @@ func newFeedbackListCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List recent feedback entries",
+ Example: ` {{.Name}}-pp-cli feedback list
+ {{.Name}}-pp-cli feedback list --limit 5
+ {{.Name}}-pp-cli feedback list --json`,
RunE: func(cmd *cobra.Command, _ []string) error {
p, err := feedbackFilePath()
if err != nil {
diff --git a/internal/generator/templates/graphql_sync.go.tmpl b/internal/generator/templates/graphql_sync.go.tmpl
index 7b5a2ae7..0525ec57 100644
--- a/internal/generator/templates/graphql_sync.go.tmpl
+++ b/internal/generator/templates/graphql_sync.go.tmpl
@@ -181,17 +181,18 @@ Exit codes & warnings:
elapsed := time.Since(started)
totalResources := successCount + warnCount + errCount
- if !humanFriendly {
+ if humanFriendly {
+ if warnCount > 0 {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+ totalSynced, totalResources, warnCount, elapsed.Seconds())
+ } else {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+ totalSynced, totalResources, elapsed.Seconds())
+ }
+ } else {
fmt.Fprintf(os.Stderr, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
}
- if warnCount > 0 {
- fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
- totalSynced, totalResources, warnCount, elapsed.Seconds())
- } else {
- fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
- totalSynced, totalResources, elapsed.Seconds())
- }
if errCount > 0 {
return fmt.Errorf("%d resource(s) failed to sync", errCount)
diff --git a/internal/generator/templates/import.go.tmpl b/internal/generator/templates/import.go.tmpl
index 6b5249b0..26c0d2f2 100644
--- a/internal/generator/templates/import.go.tmpl
+++ b/internal/generator/templates/import.go.tmpl
@@ -87,6 +87,14 @@ but do not stop the import.`,
return fmt.Errorf("reading input: %w", err)
}
+ // JSON envelope: {succeeded, failed, skipped}.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "succeeded": success,
+ "failed": failed,
+ "skipped": skipped,
+ }, flags)
+ }
fmt.Fprintf(os.Stderr, "Import complete: %d succeeded, %d failed, %d skipped\n", success, failed, skipped)
return nil
},
diff --git a/internal/generator/templates/profile.go.tmpl b/internal/generator/templates/profile.go.tmpl
index 66f736a8..1296e472 100644
--- a/internal/generator/templates/profile.go.tmpl
+++ b/internal/generator/templates/profile.go.tmpl
@@ -173,6 +173,8 @@ entry is replaced.
To avoid creating empty profiles, at least one non-default flag must be
present (other than --profile and --config).`,
+ Example: ` {{.Name}}-pp-cli profile save my-defaults --json --compact
+ {{.Name}}-pp-cli profile save tonight-defaults --region US`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
@@ -215,7 +217,9 @@ func newProfileUseCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "use <name>",
Short: "Print the flag values a profile will apply (does not execute anything)",
- Args: cobra.ExactArgs(1),
+ Example: ` {{.Name}}-pp-cli profile use my-defaults
+ {{.Name}}-pp-cli profile use tonight-defaults --json`,
+ Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p, err := GetProfile(args[0])
if err != nil {
@@ -248,6 +252,8 @@ func newProfileListCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List saved profiles",
+ Example: ` {{.Name}}-pp-cli profile list
+ {{.Name}}-pp-cli profile list --json`,
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := loadProfileStore()
if err != nil {
@@ -285,7 +291,9 @@ func newProfileShowCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "show <name>",
Short: "Show a profile's values as JSON",
- Args: cobra.ExactArgs(1),
+ Example: ` {{.Name}}-pp-cli profile show my-defaults
+ {{.Name}}-pp-cli profile show tonight-defaults --json`,
+ Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p, err := GetProfile(args[0])
if err != nil {
@@ -303,7 +311,9 @@ func newProfileDeleteCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "delete <name>",
Short: "Remove a profile",
- Args: cobra.ExactArgs(1),
+ Example: ` {{.Name}}-pp-cli profile delete my-defaults --yes
+ {{.Name}}-pp-cli profile delete old-profile --yes --json`,
+ Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
s, err := loadProfileStore()
@@ -321,6 +331,12 @@ func newProfileDeleteCmd(flags *rootFlags) *cobra.Command {
if err := saveProfileStore(s); err != nil {
return err
}
+ // JSON envelope: {deleted: name}.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "deleted": name,
+ }, flags)
+ }
fmt.Fprintf(cmd.OutOrStdout(), "deleted profile %q\n", name)
return nil
},
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index ef5b2a5a..73f837c1 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -242,17 +242,18 @@ Exit codes & warnings:
elapsed := time.Since(started)
totalResources := successCount + warnCount + errCount
- if !humanFriendly {
+ if humanFriendly {
+ if warnCount > 0 {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+ totalSynced, totalResources, warnCount, elapsed.Seconds())
+ } else {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+ totalSynced, totalResources, elapsed.Seconds())
+ }
+ } else {
fmt.Fprintf(os.Stderr, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
}
- if warnCount > 0 {
- fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
- totalSynced, totalResources, warnCount, elapsed.Seconds())
- } else {
- fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
- totalSynced, totalResources, elapsed.Seconds())
- }
// Exit-code policy:
// 1. --strict + any error -> non-zero (legacy contract)
diff --git a/internal/generator/templates/tail.go.tmpl b/internal/generator/templates/tail.go.tmpl
index ca82bb9a..95764eb3 100644
--- a/internal/generator/templates/tail.go.tmpl
+++ b/internal/generator/templates/tail.go.tmpl
@@ -38,19 +38,29 @@ native streaming instead of polling.`,
# Pipe to jq for filtering
{{.Name}}-pp-cli tail events --interval 30s | jq 'select(.type == "error")'`,
RunE: func(cmd *cobra.Command, args []string) error {
- c, err := flags.newClient()
- if err != nil {
- return err
- }
- c.NoCache = true
-
if len(args) > 0 {
resource = args[0]
}
+ // JSON help envelope: when called with no resource AND --json,
+ // surface the list of known resources so agents can discover
+ // what to pass without parsing a usage error message.
+ // Envelope: {resources: [...], note}.
+ if resource == "" && flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "resources": tailKnownResources(),
+ "note": "tail requires a resource name; pass one of the listed names",
+ }, flags)
+ }
if resource == "" {
return fmt.Errorf("resource name required (e.g., 'tail messages')")
}
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c.NoCache = true
+
path := "/" + resource
sig := make(chan os.Signal, 1)
@@ -89,6 +99,17 @@ native streaming instead of polling.`,
return cmd
}
+// tailKnownResources returns the resource names this CLI exposes, so the
+// no-arg JSON help envelope can list them without depending on sync's
+// defaultSyncResources (which only exists when sync is generated).
+func tailKnownResources() []string {
+ return []string{
+{{- range $name, $resource := .Resources}}
+ "{{$name}}",
+{{- end}}
+ }
+}
+
func fetchAndEmit(c interface{ Get(string, map[string]string) (json.RawMessage, error) }, path string, enc *json.Encoder) error {
data, err := c.Get(path, nil)
if err != nil {
diff --git a/internal/generator/templates/which.go.tmpl b/internal/generator/templates/which.go.tmpl
index f3940862..02cc662e 100644
--- a/internal/generator/templates/which.go.tmpl
+++ b/internal/generator/templates/which.go.tmpl
@@ -4,7 +4,6 @@
package cli
import (
- "encoding/json"
"fmt"
"sort"
"strings"
@@ -162,6 +161,15 @@ Exit codes:
}
if len(matches) == 0 {
+ // Under --json, return an empty matches envelope at exit 0
+ // so agents can branch on `matches.length == 0` instead of
+ // parsing a usage error message. Non-JSON keeps the typed
+ // exit-2 path so terminal users see the help hint.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "matches": []whichMatch{},
+ }, flags)
+ }
return usageErr(fmt.Errorf("no match for %q; try '%s --help' for the full command list", query, cmd.Root().Name()))
}
return renderWhich(cmd, flags, matches)
@@ -192,9 +200,15 @@ func renderWhich(cmd *cobra.Command, flags *rootFlags, matches []whichMatch) err
asJSON = true
}
if asJSON {
- enc := json.NewEncoder(w)
- enc.SetIndent("", " ")
- return enc.Encode(matches)
+ // JSON envelope: {matches: [...]}. The wrap is critical:
+ // printJSONFiltered's --compact path uses compactListFields
+ // (allowlist) for top-level arrays, which would strip
+ // entry/score keys; routing through compactObjectFields
+ // (blocklist) via an object envelope preserves them.
+ if matches == nil {
+ matches = []whichMatch{}
+ }
+ return printJSONFiltered(w, map[string]any{"matches": matches}, flags)
}
fmt.Fprintf(w, "%-24s %-8s %s\n", "COMMAND", "SCORE", "DESCRIPTION")
for _, m := range matches {
diff --git a/testdata/golden/cases/generate-golden-api/artifacts.txt b/testdata/golden/cases/generate-golden-api/artifacts.txt
index 7a965949..8f334d66 100644
--- a/testdata/golden/cases/generate-golden-api/artifacts.txt
+++ b/testdata/golden/cases/generate-golden-api/artifacts.txt
@@ -29,3 +29,9 @@ printing-press-golden/README.md
printing-press-golden/SKILL.md
printing-press-golden/internal/cli/doctor.go
printing-press-golden/internal/cliutil/verifyenv.go
+printing-press-golden/internal/cli/profile.go
+printing-press-golden/internal/cli/feedback.go
+printing-press-golden/internal/cli/sync.go
+printing-press-golden/internal/cli/api_discovery.go
+printing-press-golden/internal/cli/import.go
+printing-press-golden/internal/cli/which.go
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
index acaddf70..db801a1a 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/cli/auth.go
@@ -153,7 +153,25 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
w := cmd.OutOrStdout()
header := cfg.AuthHeader()
- if header == "" {
+ authed := header != ""
+ // JSON envelope: {authenticated, source, config}. When not
+ // authenticated, write the envelope first then return authErr
+ // so exit code carries the auth-failure signal.
+ if flags.asJSON {
+ out := map[string]any{
+ "authenticated": authed,
+ "source": cfg.AuthSource,
+ "config": cfg.Path,
+ }
+ if printErr := printJSONFiltered(w, out, flags); printErr != nil {
+ return printErr
+ }
+ if !authed {
+ return authErr(fmt.Errorf("no credentials configured"))
+ }
+ return nil
+ }
+ if !authed {
fmt.Fprintln(w, red("Not authenticated"))
fmt.Fprintln(w, "")
fmt.Fprintln(w, "Mint a token:")
@@ -188,6 +206,13 @@ func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
if err := cfg.SaveTokens("", "", args[0], "", cfg.TokenExpiry); err != nil {
return configErr(fmt.Errorf("saving token: %w", err))
}
+ // JSON envelope: {saved, config_path}.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "saved": true,
+ "config_path": cfg.Path,
+ }, flags)
+ }
fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
return nil
},
@@ -207,8 +232,25 @@ func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
if err := cfg.ClearTokens(); err != nil {
return configErr(fmt.Errorf("clearing tokens: %w", err))
}
- if os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC") != "" {
- fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: PRINTING_PRESS_OAUTH2_OAUTH2_CC env var is still set.\n")
+
+ // Identify which (if any) auth env var is still exported so the
+ // JSON envelope and the human prose can both surface it.
+ envStillSet := ""
+ if envStillSet == "" && os.Getenv("PRINTING_PRESS_OAUTH2_OAUTH2_CC") != "" {
+ envStillSet = "PRINTING_PRESS_OAUTH2_OAUTH2_CC"
+ }
+
+ // JSON envelope: {cleared: true, note?: "<env_var> env var is still set"}.
+ if flags.asJSON {
+ out := map[string]any{"cleared": true}
+ if envStillSet != "" {
+ out["note"] = envStillSet + " env var is still set"
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+
+ if envStillSet != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: %s env var is still set.\n", envStillSet)
return nil
}
fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/api_discovery.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/api_discovery.go
new file mode 100644
index 00000000..3a148fb7
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/api_discovery.go
@@ -0,0 +1,109 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+func newAPICmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "api [interface]",
+ Short: "Browse all API endpoints by interface name",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ Long: `Browse and call any API endpoint using the raw interface names.
+
+The friendly top-level commands cover the most common operations.
+This command provides access to ALL endpoints for power users and
+agents that need full API coverage.
+
+Run 'api' with no arguments to list all interfaces.
+Run 'api <interface>' to see that interface's methods.`,
+ Example: ` # List all available interfaces
+ printing-press-golden-pp-cli api
+
+ # Show methods for a specific interface
+ printing-press-golden-pp-cli api <interface-name>`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ root := cmd.Root()
+
+ if len(args) > 0 {
+ target := strings.ToLower(args[0])
+ for _, child := range root.Commands() {
+ if child.Hidden && strings.ToLower(child.Name()) == target {
+ methods := child.Commands()
+ // JSON envelope: {interface, short, methods: [{name, short}, ...]}.
+ if flags.asJSON {
+ methodList := make([]map[string]any, 0, len(methods))
+ for _, method := range methods {
+ methodList = append(methodList, map[string]any{
+ "name": method.Name(),
+ "short": method.Short,
+ })
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "interface": child.Name(),
+ "short": child.Short,
+ "methods": methodList,
+ }, flags)
+ }
+ if len(methods) == 0 {
+ return child.Help()
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "%s — %s\n\nMethods:\n", child.Name(), child.Short)
+ for _, method := range methods {
+ fmt.Fprintf(cmd.OutOrStdout(), " %-50s %s\n", child.Name()+" "+method.Name(), method.Short)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "\nUse '%s-pp-cli %s <method> --help' for details.\n", "printing-press-golden", child.Name())
+ return nil
+ }
+ }
+ return fmt.Errorf("interface %q not found. Run '%s-pp-cli api' to list all interfaces", args[0], "printing-press-golden")
+ }
+
+ // Pre-formatting human strings ahead of time would block the JSON
+ // path from emitting clean field values; build the typed slice and
+ // derive human format on print.
+ type ifaceEntry struct {
+ Name string `json:"name"`
+ Short string `json:"short"`
+ }
+ var ifaces []ifaceEntry
+ for _, child := range root.Commands() {
+ if child.Hidden {
+ ifaces = append(ifaces, ifaceEntry{Name: child.Name(), Short: child.Short})
+ }
+ }
+ sort.Slice(ifaces, func(i, j int) bool { return ifaces[i].Name < ifaces[j].Name })
+
+ // JSON envelope: {interfaces: [...], note?: "..."}.
+ if flags.asJSON {
+ out := map[string]any{"interfaces": ifaces}
+ if len(ifaces) == 0 {
+ out["interfaces"] = []ifaceEntry{}
+ out["note"] = "No hidden API interfaces found."
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+
+ if len(ifaces) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No hidden API interfaces found.")
+ return nil
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Available API interfaces (%d):\n\n", len(ifaces))
+ for _, e := range ifaces {
+ fmt.Fprintf(cmd.OutOrStdout(), " %-45s %s\n", e.Name, e.Short)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "\nUse '%s-pp-cli api <interface>' to see methods.\n", "printing-press-golden")
+ return nil
+ },
+ }
+
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go
index 62f0a258..48876560 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/auth.go
@@ -37,7 +37,25 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
w := cmd.OutOrStdout()
header := cfg.AuthHeader()
- if header == "" {
+ authed := header != ""
+ // JSON envelope: {authenticated, source, config}. When not
+ // authenticated, write the envelope first then return authErr
+ // so exit code carries the auth-failure signal.
+ if flags.asJSON {
+ out := map[string]any{
+ "authenticated": authed,
+ "source": cfg.AuthSource,
+ "config": cfg.Path,
+ }
+ if printErr := printJSONFiltered(w, out, flags); printErr != nil {
+ return printErr
+ }
+ if !authed {
+ return authErr(fmt.Errorf("no credentials configured"))
+ }
+ return nil
+ }
+ if !authed {
fmt.Fprintln(w, red("Not authenticated"))
fmt.Fprintln(w, "")
fmt.Fprintln(w, "Set your token:")
@@ -79,6 +97,13 @@ func newAuthSetTokenCmd(flags *rootFlags) *cobra.Command {
return configErr(fmt.Errorf("saving token: %w", err))
}
+ // JSON envelope: {saved, config_path}.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "saved": true,
+ "config_path": cfg.Path,
+ }, flags)
+ }
fmt.Fprintf(cmd.OutOrStdout(), "Token saved to %s\n", cfg.Path)
return nil
},
@@ -100,9 +125,24 @@ func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
return configErr(fmt.Errorf("clearing tokens: %w", err))
}
- // Warn if env vars still set
- if os.Getenv("PRINTING_PRESS_GOLDEN_API_KEY") != "" {
- fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: PRINTING_PRESS_GOLDEN_API_KEY env var is still set.\n")
+ // Identify which (if any) auth env var is still exported so the
+ // JSON envelope and the human prose can both surface it.
+ envStillSet := ""
+ if envStillSet == "" && os.Getenv("PRINTING_PRESS_GOLDEN_API_KEY") != "" {
+ envStillSet = "PRINTING_PRESS_GOLDEN_API_KEY"
+ }
+
+ // JSON envelope: {cleared: true, note?: "<env_var> env var is still set"}.
+ if flags.asJSON {
+ out := map[string]any{"cleared": true}
+ if envStillSet != "" {
+ out["note"] = envStillSet + " env var is still set"
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+
+ if envStillSet != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), "Config cleared. Note: %s env var is still set.\n", envStillSet)
return nil
}
fmt.Fprintln(cmd.OutOrStdout(), "Logged out. Credentials cleared.")
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
index 1f83b0be..8ca98b6c 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/doctor.go
@@ -68,6 +68,9 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "Check CLI health",
+ Example: ` printing-press-golden-pp-cli doctor
+ printing-press-golden-pp-cli doctor --json
+ printing-press-golden-pp-cli doctor --fail-on warn`,
RunE: func(cmd *cobra.Command, args []string) error {
report := map[string]any{}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/feedback.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/feedback.go
new file mode 100644
index 00000000..c8be00b9
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/feedback.go
@@ -0,0 +1,225 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+// FeedbackEntry is one line in the local feedback ledger. Every run of
+// the feedback command appends one entry; upstream POST is a separate,
+// optional step.
+type FeedbackEntry struct {
+ Text string `json:"text"`
+ CLI string `json:"cli"`
+ Version string `json:"version"`
+ AgentID string `json:"agent_id,omitempty"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+const feedbackMaxTextLen = 4096
+
+func feedbackFilePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolving home dir: %w", err)
+ }
+ dir := filepath.Join(home, ".printing-press-golden-pp-cli")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("creating state dir: %w", err)
+ }
+ return filepath.Join(dir, "feedback.jsonl"), nil
+}
+
+// FeedbackEndpointConfigured reports whether an upstream feedback URL
+// is available. Surfaced via agent-context so introspecting agents know
+// whether their feedback will ship upstream.
+func FeedbackEndpointConfigured() bool {
+ return os.Getenv("PRINTING_PRESS_GOLDEN_FEEDBACK_ENDPOINT") != ""
+}
+
+func feedbackEndpoint() string {
+ return os.Getenv("PRINTING_PRESS_GOLDEN_FEEDBACK_ENDPOINT")
+}
+
+func feedbackAutoSend() bool {
+ v := strings.ToLower(strings.TrimSpace(os.Getenv("PRINTING_PRESS_GOLDEN_FEEDBACK_AUTO_SEND")))
+ return v == "1" || v == "true" || v == "yes"
+}
+
+func appendFeedback(entry FeedbackEntry) error {
+ p, err := feedbackFilePath()
+ if err != nil {
+ return err
+ }
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("opening feedback ledger: %w", err)
+ }
+ defer f.Close()
+ return json.NewEncoder(f).Encode(entry)
+}
+
+func postFeedback(url string, entry FeedbackEntry) error {
+ body, err := json.Marshal(entry)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("building feedback request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "printing-press-golden-pp-cli/feedback")
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("posting feedback: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("feedback endpoint returned %s", resp.Status)
+ }
+ return nil
+}
+
+func newFeedbackCmd(flags *rootFlags) *cobra.Command {
+ var useStdin bool
+ var send bool
+ cmd := &cobra.Command{
+ Use: "feedback [text]",
+ Short: "Record feedback about this CLI (local by default; upstream opt-in)",
+ Long: `Feedback is captured locally first at ~/.printing-press-golden-pp-cli/feedback.jsonl.
+When ` + "`PRINTING_PRESS_GOLDEN_FEEDBACK_ENDPOINT`" + ` is set and either --send is
+passed or ` + "`PRINTING_PRESS_GOLDEN_FEEDBACK_AUTO_SEND=true`" + `, the entry is
+POSTed as JSON after the local write.
+
+Write what surprised you or tripped you up, not a bug report. The
+loop is: agent notices friction -> one invocation -> captured -> the
+maintainer sees it.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ var text string
+ if useStdin {
+ data, err := io.ReadAll(cmd.InOrStdin())
+ if err != nil {
+ return fmt.Errorf("reading stdin: %w", err)
+ }
+ text = strings.TrimSpace(string(data))
+ } else if len(args) > 0 {
+ text = strings.Join(args, " ")
+ }
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return fmt.Errorf("feedback text is empty (pass arguments or --stdin)")
+ }
+ truncated := false
+ if len(text) > feedbackMaxTextLen {
+ text = text[:feedbackMaxTextLen]
+ truncated = true
+ }
+
+ entry := FeedbackEntry{
+ Text: text,
+ CLI: "printing-press-golden-pp-cli",
+ Version: version,
+ AgentID: os.Getenv("AGENT_ID"),
+ Timestamp: time.Now().UTC(),
+ }
+ if err := appendFeedback(entry); err != nil {
+ return err
+ }
+
+ upstreamResult := map[string]any{"sent": false}
+ if endpoint := feedbackEndpoint(); endpoint != "" && (send || feedbackAutoSend()) {
+ if err := postFeedback(endpoint, entry); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "warning: feedback upstream POST failed: %v\n", err)
+ upstreamResult["sent"] = false
+ upstreamResult["error"] = err.Error()
+ } else {
+ upstreamResult["sent"] = true
+ upstreamResult["endpoint"] = endpoint
+ }
+ }
+
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "recorded": true,
+ "truncated": truncated,
+ "upstream": upstreamResult,
+ "entry": entry,
+ }, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "feedback recorded locally (%d chars%s)\n", len(text), func() string {
+ if truncated {
+ return ", truncated"
+ }
+ return ""
+ }())
+ if sent, _ := upstreamResult["sent"].(bool); sent {
+ fmt.Fprintf(cmd.OutOrStdout(), "upstream POST: %v\n", upstreamResult["endpoint"])
+ }
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&useStdin, "stdin", false, "Read feedback body from stdin rather than arguments")
+ cmd.Flags().BoolVar(&send, "send", false, "POST to the configured feedback endpoint in addition to local write")
+
+ cmd.AddCommand(newFeedbackListCmd(flags))
+ return cmd
+}
+
+func newFeedbackListCmd(flags *rootFlags) *cobra.Command {
+ var limit int
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List recent feedback entries",
+ Example: ` printing-press-golden-pp-cli feedback list
+ printing-press-golden-pp-cli feedback list --limit 5
+ printing-press-golden-pp-cli feedback list --json`,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ p, err := feedbackFilePath()
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ if os.IsNotExist(err) {
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), []FeedbackEntry{}, flags)
+ }
+ return nil
+ }
+ return err
+ }
+ var entries []FeedbackEntry
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var e FeedbackEntry
+ if err := json.Unmarshal([]byte(line), &e); err != nil {
+ continue
+ }
+ entries = append(entries, e)
+ }
+ if limit > 0 && limit < len(entries) {
+ entries = entries[len(entries)-limit:]
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), entries, flags)
+ },
+ }
+ cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of recent entries to return")
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/import.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/import.go
new file mode 100644
index 00000000..96060e38
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/import.go
@@ -0,0 +1,109 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+func newImportCmd(flags *rootFlags) *cobra.Command {
+ var inputFile string
+ var dryRun bool
+ var batchSize int
+
+ cmd := &cobra.Command{
+ Use: "import <resource>",
+ Short: "Import data from JSONL file via API create/upsert calls",
+ Long: `Import data from a JSONL file by issuing POST requests for each record.
+Each line must be a valid JSON object. Failed records are logged to stderr
+but do not stop the import.`,
+ Example: ` # Import from a JSONL file
+ printing-press-golden-pp-cli import <resource> --input data.jsonl
+
+ # Dry-run to preview without sending
+ printing-press-golden-pp-cli import <resource> --input data.jsonl --dry-run
+
+ # Import from stdin
+ cat data.jsonl | printing-press-golden-pp-cli import <resource> --input -`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c.DryRun = dryRun
+
+ resource := args[0]
+ path := "/" + resource
+
+ var reader io.Reader
+ if inputFile == "-" || inputFile == "" {
+ reader = os.Stdin
+ } else {
+ f, err := os.Open(inputFile)
+ if err != nil {
+ return fmt.Errorf("opening input file: %w", err)
+ }
+ defer f.Close()
+ reader = f
+ }
+
+ scanner := bufio.NewScanner(reader)
+ scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB line buffer
+
+ var success, failed, skipped int
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || line[0] == '#' {
+ skipped++
+ continue
+ }
+
+ var body map[string]any
+ if err := json.Unmarshal([]byte(line), &body); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: skipping invalid JSON line: %v\n", err)
+ failed++
+ continue
+ }
+
+ _, _, err := c.Post(path, body)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: failed to import record: %v\n", err)
+ failed++
+ continue
+ }
+ success++
+ }
+
+ if err := scanner.Err(); err != nil {
+ return fmt.Errorf("reading input: %w", err)
+ }
+
+ // JSON envelope: {succeeded, failed, skipped}.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "succeeded": success,
+ "failed": failed,
+ "skipped": skipped,
+ }, flags)
+ }
+ fmt.Fprintf(os.Stderr, "Import complete: %d succeeded, %d failed, %d skipped\n", success, failed, skipped)
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVarP(&inputFile, "input", "i", "", "Input JSONL file path (use - for stdin)")
+ _ = cmd.MarkFlagRequired("input")
+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview import without sending requests")
+ cmd.Flags().IntVar(&batchSize, "batch-size", 1, "Records per batch (future: batch API support)")
+
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/profile.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/profile.go
new file mode 100644
index 00000000..f3b60421
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/profile.go
@@ -0,0 +1,344 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+// Profile is a named set of flag values saved for reuse across invocations.
+// HeyGen's "Beacon" pattern: one named context that a scheduled agent reuses
+// day after day with the same voice/format but different input each run.
+type Profile struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Values map[string]string `json:"values"`
+}
+
+type profileStore struct {
+ Profiles map[string]Profile `json:"profiles"`
+}
+
+func profileStorePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolving home dir: %w", err)
+ }
+ dir := filepath.Join(home, ".printing-press-golden-pp-cli")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("creating state dir: %w", err)
+ }
+ return filepath.Join(dir, "profiles.json"), nil
+}
+
+func loadProfileStore() (*profileStore, error) {
+ p, err := profileStorePath()
+ if err != nil {
+ return nil, err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return &profileStore{Profiles: map[string]Profile{}}, nil
+ }
+ return nil, fmt.Errorf("reading profiles: %w", err)
+ }
+ var s profileStore
+ if err := json.Unmarshal(data, &s); err != nil {
+ return nil, fmt.Errorf("parsing profiles: %w", err)
+ }
+ if s.Profiles == nil {
+ s.Profiles = map[string]Profile{}
+ }
+ return &s, nil
+}
+
+func saveProfileStore(s *profileStore) error {
+ p, err := profileStorePath()
+ if err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(s, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling profiles: %w", err)
+ }
+ tmp := p + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
+ return fmt.Errorf("writing profiles: %w", err)
+ }
+ return os.Rename(tmp, p)
+}
+
+// GetProfile returns a profile by name, or (nil, nil) if not found.
+func GetProfile(name string) (*Profile, error) {
+ s, err := loadProfileStore()
+ if err != nil {
+ return nil, err
+ }
+ if p, ok := s.Profiles[name]; ok {
+ return &p, nil
+ }
+ return nil, nil
+}
+
+// ApplyProfileToFlags overlays profile values onto flags that the user has
+// not set explicitly on the command line. Used from root.go's
+// PersistentPreRunE so profile values feed the whole command tree.
+func ApplyProfileToFlags(cmd *cobra.Command, profile *Profile) error {
+ if profile == nil || len(profile.Values) == 0 {
+ return nil
+ }
+ // Reserved flags that never come from a profile - they control profile
+ // resolution itself or are dangerous to overlay.
+ reserved := map[string]bool{
+ "profile": true, "config": true, "help": true,
+ }
+ for name, value := range profile.Values {
+ if reserved[name] {
+ continue
+ }
+ flag := cmd.Flags().Lookup(name)
+ if flag == nil {
+ flag = cmd.InheritedFlags().Lookup(name)
+ }
+ if flag == nil {
+ continue
+ }
+ if flag.Changed {
+ continue
+ }
+ if err := flag.Value.Set(value); err != nil {
+ return fmt.Errorf("applying profile value %s=%q: %w", name, value, err)
+ }
+ }
+ return nil
+}
+
+// ListProfileNames returns profile names sorted alphabetically. Used by the
+// agent-context subcommand to expose available_profiles at runtime.
+func ListProfileNames() []string {
+ s, err := loadProfileStore()
+ if err != nil {
+ return nil
+ }
+ names := make([]string, 0, len(s.Profiles))
+ for name := range s.Profiles {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
+
+func newProfileCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "profile",
+ Short: "Named sets of flags saved for reuse",
+ Long: `Profiles capture a set of flag values under a name so a scheduled
+agent can invoke the same command with the same configuration each run.
+
+ profile save <name> captures the current invocation's set flags
+ profile use <name> prints the values (for inspection)
+ profile list lists all saved profiles
+ profile show <name> shows the values of one profile
+ profile delete <name> removes a profile
+
+Use --profile <name> on any command to apply that profile's values.
+Explicit flags override profile values.`,
+ }
+ cmd.AddCommand(newProfileSaveCmd(flags))
+ cmd.AddCommand(newProfileUseCmd(flags))
+ cmd.AddCommand(newProfileListCmd(flags))
+ cmd.AddCommand(newProfileShowCmd(flags))
+ cmd.AddCommand(newProfileDeleteCmd(flags))
+ return cmd
+}
+
+func newProfileSaveCmd(flags *rootFlags) *cobra.Command {
+ var description string
+ cmd := &cobra.Command{
+ Use: "save <name> [--<flag> <value> ...]",
+ Short: "Save the current invocation's non-default flags as a named profile",
+ Long: `Captures every flag explicitly set on the invocation and stores
+them under <name>. To update an existing profile, run save again; the
+entry is replaced.
+
+To avoid creating empty profiles, at least one non-default flag must be
+present (other than --profile and --config).`,
+ Example: ` printing-press-golden-pp-cli profile save my-defaults --json --compact
+ printing-press-golden-pp-cli profile save tonight-defaults --region US`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ name := args[0]
+ if strings.ContainsAny(name, `/\: `) {
+ return fmt.Errorf("profile name %q contains reserved characters", name)
+ }
+ values := map[string]string{}
+ // Walk inherited + local flags, capture only those the user set.
+ skip := map[string]bool{"profile": true, "config": true, "help": true, "description": true}
+ visit := func(fl *pflag.Flag) {
+ if fl.Changed && !skip[fl.Name] {
+ values[fl.Name] = fl.Value.String()
+ }
+ }
+ cmd.InheritedFlags().VisitAll(visit)
+ cmd.Flags().VisitAll(visit)
+ if len(values) == 0 {
+ return fmt.Errorf("no non-default flags set - pass at least one flag to save into %q", name)
+ }
+ s, err := loadProfileStore()
+ if err != nil {
+ return err
+ }
+ s.Profiles[name] = Profile{Name: name, Description: description, Values: values}
+ if err := saveProfileStore(s); err != nil {
+ return err
+ }
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), s.Profiles[name], flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "saved profile %q with %d values\n", name, len(values))
+ return nil
+ },
+ }
+ cmd.Flags().StringVar(&description, "description", "", "Short description shown in 'profile list'")
+ return cmd
+}
+
+func newProfileUseCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "use <name>",
+ Short: "Print the flag values a profile will apply (does not execute anything)",
+ Example: ` printing-press-golden-pp-cli profile use my-defaults
+ printing-press-golden-pp-cli profile use tonight-defaults --json`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ p, err := GetProfile(args[0])
+ if err != nil {
+ return err
+ }
+ if p == nil {
+ return fmt.Errorf("profile %q not found", args[0])
+ }
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), p, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "profile %q:\n", p.Name)
+ if p.Description != "" {
+ fmt.Fprintf(cmd.OutOrStdout(), " description: %s\n", p.Description)
+ }
+ keys := make([]string, 0, len(p.Values))
+ for k := range p.Values {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ fmt.Fprintf(cmd.OutOrStdout(), " --%s %s\n", k, p.Values[k])
+ }
+ return nil
+ },
+ }
+}
+
+func newProfileListCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "list",
+ Short: "List saved profiles",
+ Example: ` printing-press-golden-pp-cli profile list
+ printing-press-golden-pp-cli profile list --json`,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ s, err := loadProfileStore()
+ if err != nil {
+ return err
+ }
+ names := make([]string, 0, len(s.Profiles))
+ for n := range s.Profiles {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ if flags.asJSON {
+ out := make([]map[string]any, 0, len(names))
+ for _, n := range names {
+ p := s.Profiles[n]
+ out = append(out, map[string]any{
+ "name": p.Name,
+ "description": p.Description,
+ "field_count": len(p.Values),
+ })
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), out, flags)
+ }
+ headers := []string{"NAME", "FIELDS", "DESCRIPTION"}
+ rows := make([][]string, 0, len(names))
+ for _, n := range names {
+ p := s.Profiles[n]
+ rows = append(rows, []string{p.Name, fmt.Sprintf("%d", len(p.Values)), p.Description})
+ }
+ return flags.printTable(cmd, headers, rows)
+ },
+ }
+}
+
+func newProfileShowCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "show <name>",
+ Short: "Show a profile's values as JSON",
+ Example: ` printing-press-golden-pp-cli profile show my-defaults
+ printing-press-golden-pp-cli profile show tonight-defaults --json`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ p, err := GetProfile(args[0])
+ if err != nil {
+ return err
+ }
+ if p == nil {
+ return fmt.Errorf("profile %q not found", args[0])
+ }
+ return printJSONFiltered(cmd.OutOrStdout(), p, flags)
+ },
+ }
+}
+
+func newProfileDeleteCmd(flags *rootFlags) *cobra.Command {
+ return &cobra.Command{
+ Use: "delete <name>",
+ Short: "Remove a profile",
+ Example: ` printing-press-golden-pp-cli profile delete my-defaults --yes
+ printing-press-golden-pp-cli profile delete old-profile --yes --json`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ name := args[0]
+ s, err := loadProfileStore()
+ if err != nil {
+ return err
+ }
+ if _, ok := s.Profiles[name]; !ok {
+ return fmt.Errorf("profile %q not found", name)
+ }
+ if !flags.yes {
+ fmt.Fprintf(cmd.ErrOrStderr(), "refusing to delete %q without --yes\n", name)
+ return fmt.Errorf("confirmation required: pass --yes")
+ }
+ delete(s.Profiles, name)
+ if err := saveProfileStore(s); err != nil {
+ return err
+ }
+ // JSON envelope: {deleted: name}.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "deleted": name,
+ }, flags)
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "deleted profile %q\n", name)
+ return nil
+ },
+ }
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
index 142dcc2f..829f1909 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
@@ -38,7 +38,7 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
path := "/projects/{projectId}/tasks/{taskId}"
path = replacePathParam(path, "projectId", args[0])
if len(args) < 2 {
- return usageErr(fmt.Errorf("taskId is required\nUsage: %s %s <%s>", cmd.Root().Name(), cmd.CommandPath(), "taskId"))
+ return usageErr(fmt.Errorf("taskId is required\nUsage: %s <%s>", cmd.CommandPath(), "taskId"))
}
path = replacePathParam(path, "taskId", args[1])
var body map[string]any
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
new file mode 100644
index 00000000..50694e61
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
@@ -0,0 +1,1114 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+ "printing-press-golden-pp-cli/internal/store"
+ "github.com/spf13/cobra"
+)
+
+// syncResult holds the outcome of syncing a single resource.
+type syncResult struct {
+ Resource string
+ Count int
+ Err error
+ Warn error
+ Duration time.Duration
+}
+
+func newSyncCmd(flags *rootFlags) *cobra.Command {
+ var resources []string
+ var full bool
+ var since string
+ var concurrency int
+ var dbPath string
+ var maxPages int
+ var latestOnly bool
+ var strict bool
+
+ cmd := &cobra.Command{
+ Use: "sync",
+ Short: "Sync API data to local SQLite for offline search and analysis",
+ Long: `Sync data from the API into a local SQLite database. Supports resumable
+incremental sync (only fetches new data since last sync) and full resync.
+Once synced, use the 'search' command for instant full-text search.
+
+Exit codes & warnings:
+ Resources the API denies access to (HTTP 403, or HTTP 400 with an
+ access-policy body) are reported as warnings rather than failing the
+ run. In --json mode each is emitted as a {"event":"sync_warning",...}
+ line carrying status, reason, and message fields, and a final
+ {"event":"sync_summary",...} aggregates the run.
+
+ Exit 0 when at least one resource synced and no resource flagged in
+ the spec as critical (x-critical: true) failed; non-critical failures
+ emit {"event":"sync_warning","reason":"exit_policy_default_changed",
+ ...} so callers can detect that a partial failure was tolerated. Pass
+ --strict to exit non-zero on any per-resource failure. Exit is always
+ non-zero when every selected resource failed, regardless of --strict.`,
+ Example: ` # Sync all resources
+ printing-press-golden-pp-cli sync
+
+ # Sync specific resources only
+ printing-press-golden-pp-cli sync --resources channels,messages
+
+ # Full resync (ignore previous checkpoint)
+ printing-press-golden-pp-cli sync --full
+
+ # Incremental sync: only records from the last 7 days
+ printing-press-golden-pp-cli sync --since 7d
+
+ # Parallel sync with 8 workers
+ printing-press-golden-pp-cli sync --concurrency 8
+
+ # Latest-only: refresh head of each resource, no historical backfill
+ printing-press-golden-pp-cli sync --latest-only`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+ c.NoCache = true
+
+ if dbPath == "" {
+ dbPath = defaultDBPath("printing-press-golden-pp-cli")
+ }
+
+ db, err := store.OpenWithContext(cmd.Context(), dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local database: %w", err)
+ }
+ defer db.Close()
+
+ // If no specific resources, sync top-level resources
+ if len(resources) == 0 {
+ resources = defaultSyncResources()
+ }
+
+ // --full: clear all sync cursors before starting
+ if full {
+ for _, resource := range resources {
+ _ = db.SaveSyncState(resource, "", 0)
+ }
+ }
+
+ // --latest-only narrows to the first page of each resource
+ // ignoring the historical resume cursor. We cap maxPages at 1
+ // here rather than re-interpreting it downstream so the rest
+ // of the sync loop stays oblivious. Mutually-useful with
+ // --since: if the user set --since, that threshold still wins
+ // and we don't short-circuit historical context they asked for.
+ if latestOnly {
+ if since == "" {
+ maxPages = 1
+ // Clear the cursor so we start from the head each time;
+ // the goal of --latest-only is "refresh the top" not
+ // "resume from wherever I left off".
+ for _, resource := range resources {
+ existing, _, _, _ := db.GetSyncState(resource)
+ if existing != "" {
+ _ = db.SaveSyncState(resource, "", 0)
+ }
+ }
+ } else if humanFriendly {
+ fmt.Fprintln(os.Stderr, "warning: --latest-only ignored because --since is set; --since takes precedence")
+ }
+ }
+
+ // Resolve --since into an RFC3339 timestamp
+ sinceTS := ""
+ if since != "" {
+ ts, err := parseSinceDuration(since)
+ if err != nil {
+ return fmt.Errorf("invalid --since value %q: %w", since, err)
+ }
+ sinceTS = ts.Format(time.RFC3339)
+ }
+
+ // Worker pool: produce resources, N workers consume
+ if concurrency < 1 {
+ concurrency = 4
+ }
+
+ started := time.Now()
+ work := make(chan string, len(resources))
+ results := make(chan syncResult, len(resources))
+
+ var wg sync.WaitGroup
+ for i := 0; i < concurrency; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for resource := range work {
+ res := syncResource(c, db, resource, sinceTS, full, maxPages)
+ results <- res
+ }
+ }()
+ }
+
+ // Enqueue all resources
+ for _, resource := range resources {
+ work <- resource
+ }
+ close(work)
+
+ // Collect results in a separate goroutine
+ go func() {
+ wg.Wait()
+ close(results)
+ }()
+
+ var totalSynced int
+ var errCount int
+ var criticalErrCount int
+ var warnCount int
+ var successCount int
+ for res := range results {
+ if res.Err != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: error: %v\n", res.Resource, res.Err)
+ }
+ errCount++
+ if criticalResources[res.Resource] {
+ criticalErrCount++
+ }
+ } else if res.Warn != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: warning: %v\n", res.Resource, res.Warn)
+ }
+ warnCount++
+ } else {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: %d synced (done)\n", res.Resource, res.Count)
+ }
+ totalSynced += res.Count
+ successCount++
+ }
+ }
+ // Sync dependent (parent-child) resources sequentially after flat resources.
+ depResults := syncDependentResources(c, db, sinceTS, full, maxPages)
+ for _, res := range depResults {
+ if res.Err != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: error: %v\n", res.Resource, res.Err)
+ }
+ errCount++
+ if criticalResources[res.Resource] {
+ criticalErrCount++
+ }
+ } else if res.Warn != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: warning: %v\n", res.Resource, res.Warn)
+ }
+ warnCount++
+ } else {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: %d synced (done)\n", res.Resource, res.Count)
+ }
+ totalSynced += res.Count
+ successCount++
+ }
+ }
+
+ elapsed := time.Since(started)
+ totalResources := successCount + warnCount + errCount
+ if humanFriendly {
+ if warnCount > 0 {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+ totalSynced, totalResources, warnCount, elapsed.Seconds())
+ } else {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+ totalSynced, totalResources, elapsed.Seconds())
+ }
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
+ totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
+ }
+
+ // Exit-code policy:
+ // 1. --strict + any error -> non-zero (legacy contract)
+ // 2. any critical failure -> non-zero regardless of --strict
+ // 3. nothing synced -> non-zero (preserves "all-warned" / "all-errored" exit)
+ // 4. otherwise -> exit 0 (any data synced + no critical failed)
+ // When branch 4 suppresses what branch 1 would have rejected, emit a
+ // one-shot sync_warning with reason "exit_policy_default_changed" so
+ // CI scripts that depend on $? != 0 can discover the contract change
+ // without reading the CHANGELOG.
+ if strict && errCount > 0 {
+ return fmt.Errorf("%d resource(s) failed to sync", errCount)
+ }
+ if criticalErrCount > 0 {
+ return fmt.Errorf("%d critical resource(s) failed to sync", criticalErrCount)
+ }
+ if successCount == 0 {
+ if warnCount > 0 && errCount == 0 {
+ return fmt.Errorf("%d resource(s) skipped due to insufficient access", warnCount)
+ }
+ if errCount > 0 {
+ return fmt.Errorf("%d resource(s) failed to sync", errCount)
+ }
+ }
+ if errCount > 0 && !strict && criticalErrCount == 0 && successCount > 0 {
+ if !humanFriendly {
+ msg := fmt.Sprintf("%d resource(s) failed but exit code is 0 because the new default treats non-critical failures as warnings. Pass --strict to restore the old behavior, or annotate critical resources with x-critical: true. See CHANGELOG.", errCount)
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","reason":"exit_policy_default_changed","errored":%d,"message":"%s"}`+"\n",
+ errCount, strings.ReplaceAll(msg, `"`, `\"`))
+ } else {
+ fmt.Fprintf(os.Stderr, "warning: %d resource(s) failed but exit code is 0 because the new default treats non-critical failures as warnings. Pass --strict to restore the old behavior, or annotate critical resources with x-critical: true.\n", errCount)
+ }
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().StringSliceVar(&resources, "resources", nil, "Comma-separated resource types to sync")
+ cmd.Flags().BoolVar(&full, "full", false, "Full resync (ignore previous checkpoint)")
+ cmd.Flags().StringVar(&since, "since", "", "Incremental sync duration (e.g. 7d, 24h, 1w, 30m)")
+ cmd.Flags().IntVar(&concurrency, "concurrency", 4, "Number of parallel sync workers")
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.local/share/printing-press-golden-pp-cli/data.db)")
+ cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
+ cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
+ cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
+
+ return cmd
+}
+
+// syncResource handles the full paginated sync of a single resource.
+// It resumes from the last cursor unless sinceTS or full mode overrides it.
+func syncResource(c interface {
+ Get(string, map[string]string) (json.RawMessage, error)
+ RateLimit() float64
+}, db *store.Store, resource, sinceTS string, full bool, maxPages int) syncResult {
+ started := time.Now()
+
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_start","resource":"%s"}`+"\n", resource)
+ }
+
+ path, err := syncResourcePath(resource)
+ if err != nil {
+ return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
+ }
+ var totalCount int
+
+ // Resume cursor from sync_state (unless --full cleared it)
+ existingCursor, lastSynced, _, _ := db.GetSyncState(resource)
+
+ // Determine the since param value:
+ // 1. Explicit --since flag takes priority
+ // 2. Otherwise use last_synced_at from sync_state for incremental sync
+ sinceParam := determineSinceParam()
+ effectiveSince := sinceTS
+ if effectiveSince == "" && !lastSynced.IsZero() && !full {
+ effectiveSince = lastSynced.Format(time.RFC3339)
+ }
+
+ cursor := existingCursor
+ pageSize := determinePaginationDefaults()
+
+ var progressCount int64
+ pagesFetched := 0
+ lastNextCursor := ""
+ // extractFailureTotal accumulates per-item primary-key extraction
+ // misses across pages within this resource sync. Resource-level
+ // concurrency is 1 (one goroutine per resource via the work channel)
+ // so this counter cannot race. We emit one primary_key_unresolved
+ // sync_anomaly per resource per run when there's at least one miss
+ // (rate-limited via the anomalyEmitted flag) and a roll-up
+ // all_items_failed_id_extraction event when 100% of a single page
+ // failed extraction.
+ var extractFailureTotal int
+ var consumedTotal int
+ anomalyEmitted := false
+
+ for {
+ params := map[string]string{}
+
+ // Set page size
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+
+ // Set cursor for resume
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
+
+ // Set since filter
+ if effectiveSince != "" {
+ params[sinceParam] = effectiveSince
+ }
+
+ data, err := c.Get(path, params)
+ if err != nil {
+ if w, ok := isSyncAccessWarning(err); ok {
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","status":%d,"reason":"%s","message":"%s"}`+"\n",
+ resource, w.Status, w.Reason, strings.ReplaceAll(w.Message, `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Warn: fmt.Errorf("skipped %s: %s", resource, w.Reason), Duration: time.Since(started)}
+ }
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf("fetching %s: %w", resource, err), Duration: time.Since(started)}
+ }
+
+ // Try to extract items from the response.
+ // Strategy: try array first, then common wrapper keys.
+ items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
+
+ if len(items) == 0 {
+ // Single object response - try to store as-is
+ if err := upsertSingleObject(db, resource, data); err != nil {
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Err: err, Duration: time.Since(started)}
+ }
+ totalCount++
+ break
+ }
+
+ // Batch upsert all items from this page. UpsertBatch returns
+ // (stored, extractFailures, err): stored counts rows actually
+ // landed; extractFailures counts items that survived JSON
+ // unmarshal but had no extractable primary key (templated
+ // IDField AND generic fallback both missed). Tracking these
+ // separately lets us emit precise sync_anomaly events: a
+ // roll-up "all_items_failed_id_extraction" when an entire
+ // page yields zero stored, a per-resource
+ // "primary_key_unresolved" the first time any single item
+ // fails, and the F4b "stored_count_zero_after_extraction"
+ // probe when extraction succeeded but rows still didn't land.
+ stored, extractFailures, err := upsertResourceBatch(db, resource, items)
+ if err != nil {
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
+ }
+ return syncResult{Resource: resource, Count: totalCount, Err: fmt.Errorf("upserting batch for %s: %w", resource, err), Duration: time.Since(started)}
+ }
+
+ consumedTotal += len(items)
+ extractFailureTotal += extractFailures
+
+ // When a non-empty page yielded zero stored rows, the API
+ // returned items in a shape we couldn't extract IDs from —
+ // likely scalar IDs (Firebase /topstories.json, GitHub user-
+ // repo lists) where the spec author should declare a hydration
+ // pattern, or an unrecognized primary-key field name.
+ if len(items) > 0 && stored == 0 {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "warning: %s returned %d items but stored 0 — the local store will be empty for this resource. Likely cause: scalar item shape rather than objects with extractable IDs.\n", resource, len(items))
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":0,"reason":"all_items_failed_id_extraction"}`+"\n", resource, len(items))
+ }
+ anomalyEmitted = true
+ } else if extractFailures > 0 && !anomalyEmitted {
+ // Per-item primary-key resolution failure but at least one
+ // item landed — emit one structured warning per resource per
+ // sync run so users see the first occurrence of silent drops
+ // instead of waiting for total failure.
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s had %d item(s) on this page with no extractable primary key — those rows were dropped silently. Annotate the spec with x-resource-id to fix.\n", resource, extractFailures)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":%d,"count":%d,"reason":"primary_key_unresolved"}`+"\n", resource, len(items), stored, extractFailures)
+ }
+ anomalyEmitted = true
+ }
+
+ totalCount += stored
+ atomic.AddInt64(&progressCount, int64(stored))
+
+ // Progress reporting (include rate limit info when active)
+ currentRate := c.RateLimit()
+ if humanFriendly {
+ if currentRate > 0 {
+ fmt.Fprintf(os.Stderr, "\r %s: %d synced [%.1f req/s]", resource, atomic.LoadInt64(&progressCount), currentRate)
+ } else {
+ fmt.Fprintf(os.Stderr, "\r %s: %d synced", resource, atomic.LoadInt64(&progressCount))
+ }
+ } else {
+ if currentRate > 0 {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_progress","resource":"%s","fetched":%d,"rate_rps":%.1f}`+"\n", resource, atomic.LoadInt64(&progressCount), currentRate)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_progress","resource":"%s","fetched":%d}`+"\n", resource, atomic.LoadInt64(&progressCount))
+ }
+ }
+
+ // Save cursor after each page for resumability
+ if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+ // Non-fatal: log and continue
+ fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+ }
+
+ pagesFetched++
+
+ // Enforce page ceiling to prevent runaway syncs on large-catalog APIs
+ if maxPages > 0 && pagesFetched >= maxPages {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: reached --max-pages limit (%d pages, %d items)\n", resource, maxPages, totalCount)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","reason":"max_pages_cap_hit","message":"reached --max-pages cap of %d; data may be truncated. Re-run with --max-pages 0 (unlimited) or higher to verify."}`+"\n", resource, maxPages)
+ }
+ break
+ }
+
+ // Sticky-cursor detector: if the API echoes the same next cursor across
+ // consecutive pages without advancing, abort to prevent burning the
+ // --max-pages budget on a non-terminating loop. Checked AFTER the cap
+ // guard so cap-hit takes precedence; checked BEFORE the natural-end
+ // check below because the natural-end check would not catch a sticky
+ // non-empty cursor on its own.
+ if nextCursor != "" && nextCursor == lastNextCursor {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: API returned the same next cursor across two pages; aborting to prevent budget waste.\n", resource)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","reason":"stuck_pagination","message":"API returned the same next cursor across two pages for resource %s; aborting to prevent budget waste."}`+"\n", resource, resource)
+ }
+ break
+ }
+ lastNextCursor = nextCursor
+
+ // Determine if there are more pages
+ if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ break
+ }
+
+ cursor = nextCursor
+ }
+
+ // Final sync state: clear cursor (sync is complete), update count
+ _ = db.SaveSyncState(resource, "", totalCount)
+
+ // F4b symptom probe: if items were consumed and successfully
+ // extracted (extractFailures < consumed) but nothing landed in
+ // the store, something downstream of extraction silently dropped
+ // rows — FTS5 trigger error, transaction rollback, character
+ // encoding. Emit a sync_anomaly so the symptom is visible the
+ // next time it recurs; the underlying root cause is held out for
+ // controlled repro.
+ if consumedTotal > 0 && totalCount == 0 && extractFailureTotal < consumedTotal {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s consumed %d items, extracted %d primary keys, but stored 0 rows — extraction succeeded yet nothing landed. Investigate FTS triggers / transaction rollback / encoding.\n", resource, consumedTotal, consumedTotal-extractFailureTotal)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":0,"extract_failures":%d,"reason":"stored_count_zero_after_extraction"}`+"\n", resource, consumedTotal, extractFailureTotal)
+ }
+ }
+
+ if !humanFriendly {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_complete","resource":"%s","total":%d,"duration_ms":%d}`+"\n", resource, totalCount, time.Since(started).Milliseconds())
+ }
+
+ return syncResult{Resource: resource, Count: totalCount, Duration: time.Since(started)}
+}
+
+// paginationDefaults holds the resolved pagination parameter names and page size.
+type paginationDefaults struct {
+ cursorParam string
+ limitParam string
+ limit int
+}
+
+// determinePaginationDefaults returns the pagination parameter names to use.
+// Values are detected from the API spec by the profiler at generation time.
+func determinePaginationDefaults() paginationDefaults {
+ return paginationDefaults{
+ cursorParam: "cursor",
+ limitParam: "limit",
+ limit: 100,
+ }
+}
+
+// determineSinceParam returns the query parameter name for incremental sync filtering.
+func determineSinceParam() string {
+ return "since"
+}
+
+// extractPageItems attempts to extract an array of items and pagination cursor from a response.
+// It tries multiple strategies:
+// 1. Direct JSON array
+// 2. Common wrapper keys: "data", "results", "items", "records", "nodes", "entries"
+// It also extracts the next cursor from common response fields.
+func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessage, string, bool) {
+ // Strategy 1: direct array
+ var items []json.RawMessage
+ if err := json.Unmarshal(data, &items); err == nil {
+ return items, "", false
+ }
+
+ // Strategy 2: object with known wrapper keys
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return nil, "", false
+ }
+
+ // Try common item keys first (fast path)
+ itemKeys := []string{"data", "results", "items", "records", "nodes", "entries"}
+ for _, key := range itemKeys {
+ if raw, ok := envelope[key]; ok {
+ if err := json.Unmarshal(raw, &items); err == nil && len(items) > 0 {
+ nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ return items, nextCursor, hasMore
+ }
+ }
+ }
+
+ // Fallback: try every key in the envelope. If exactly one maps to a JSON
+ // array with items, use it. This handles APIs that wrap responses with the
+ // resource name (e.g., {"markets": [...], "cursor": "..."}).
+ var arrayKey string
+ var arrayItems []json.RawMessage
+ arrayCount := 0
+ for key, raw := range envelope {
+ var candidate []json.RawMessage
+ if err := json.Unmarshal(raw, &candidate); err == nil && len(candidate) > 0 {
+ arrayKey = key
+ arrayItems = candidate
+ arrayCount++
+ }
+ }
+ if arrayCount == 1 {
+ nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
+ _ = arrayKey // used for detection, items extracted above
+ return arrayItems, nextCursor, hasMore
+ }
+
+ return nil, "", false
+}
+
+// extractPaginationFromEnvelope extracts cursor and has_more from a response envelope.
+func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorParam string) (string, bool) {
+ var hasMore bool
+
+ nextCursor := nextCursorFromLinks(envelope, cursorParam)
+
+ // Try common cursor field names
+ cursorKeys := []string{
+ "next_cursor", "nextCursor", "cursor", "next_page_token",
+ "nextPageToken", "page_token", "after", "end_cursor", "endCursor",
+ }
+ if nextCursor == "" {
+ nextCursor = findCursorInMap(envelope, cursorKeys)
+ }
+
+ // If no top-level cursor was found, look one level deeper into well-known
+ // pagination wrapper objects. Slack returns {"messages":[...],
+ // "response_metadata":{"next_cursor":"..."}}; MongoDB Atlas uses
+ // "pagination"; many APIs use "meta" or "paging". Purely additive — only
+ // runs when the top-level scan returned empty — and uses the same
+ // cursorKeys set so wrapper contents go through the same name match.
+ if nextCursor == "" {
+ paginationWrapperKeys := []string{"response_metadata", "pagination", "meta", "paging"}
+ for _, wrapperKey := range paginationWrapperKeys {
+ rawWrapper, ok := envelope[wrapperKey]
+ if !ok {
+ continue
+ }
+ var inner map[string]json.RawMessage
+ if json.Unmarshal(rawWrapper, &inner) != nil {
+ continue
+ }
+ if c := findCursorInMap(inner, cursorKeys); c != "" {
+ nextCursor = c
+ break
+ }
+ }
+ }
+
+ // Try common has_more field names
+ hasMoreKeys := []string{"has_more", "hasMore", "has_next", "hasNext", "next_page"}
+ for _, key := range hasMoreKeys {
+ if raw, ok := envelope[key]; ok {
+ if err := json.Unmarshal(raw, &hasMore); err == nil {
+ break
+ }
+ }
+ }
+
+ // If we found a cursor, assume there are more pages even without explicit has_more
+ if nextCursor != "" && !hasMore {
+ hasMore = true
+ }
+
+ return nextCursor, hasMore
+}
+
+// nextCursorFromLinks extracts JSON:API-style pagination cursors from
+// {"links":{"next":"https://example.com/items?page[cursor]=..."}}.
+func nextCursorFromLinks(envelope map[string]json.RawMessage, cursorParam string) string {
+ rawLinks, ok := envelope["links"]
+ if !ok {
+ return ""
+ }
+ var links map[string]json.RawMessage
+ if json.Unmarshal(rawLinks, &links) != nil {
+ return ""
+ }
+ rawNext, ok := links["next"]
+ if !ok {
+ return ""
+ }
+ var nextURL string
+ if json.Unmarshal(rawNext, &nextURL) != nil || nextURL == "" {
+ return ""
+ }
+
+ cursorKeys := []string{cursorParam}
+ if cursorParam != "page[cursor]" {
+ cursorKeys = append(cursorKeys, "page[cursor]")
+ }
+ if cursorParam != "cursor" {
+ cursorKeys = append(cursorKeys, "cursor")
+ }
+ if cursorParam != "after" {
+ cursorKeys = append(cursorKeys, "after")
+ }
+
+ parsed, err := url.Parse(nextURL)
+ if err != nil {
+ return ""
+ }
+ values := parsed.Query()
+ for _, key := range cursorKeys {
+ if key == "" {
+ continue
+ }
+ if cursor := values.Get(key); cursor != "" {
+ return cursor
+ }
+ }
+ return ""
+}
+
+// findCursorInMap returns the first non-empty string-typed value in m
+// whose key matches one of cursorKeys. Used by extractPaginationFromEnvelope
+// to scan both the top-level envelope and well-known wrapper objects with
+// the same name-match rules — extracted so the two scans can't drift.
+func findCursorInMap(m map[string]json.RawMessage, cursorKeys []string) string {
+ for _, key := range cursorKeys {
+ raw, ok := m[key]
+ if !ok {
+ continue
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil && s != "" {
+ return s
+ }
+ }
+ return ""
+}
+
+type discriminatorDispatch struct {
+ Field string
+ Values map[string]string
+}
+
+var discriminatorDispatchers = map[string]discriminatorDispatch{
+}
+
+func upsertResourceBatch(db *store.Store, resource string, items []json.RawMessage) (int, int, error) {
+ if _, ok := discriminatorDispatchers[resource]; !ok {
+ return db.UpsertBatch(resource, items)
+ }
+
+ grouped := map[string][]json.RawMessage{}
+ order := []string{}
+ for _, item := range items {
+ target := resource
+ var obj map[string]any
+ if err := json.Unmarshal(item, &obj); err == nil {
+ target = resolveDiscriminatedResource(resource, obj)
+ }
+ if _, ok := grouped[target]; !ok {
+ order = append(order, target)
+ }
+ grouped[target] = append(grouped[target], item)
+ }
+
+ var stored, extractFailures int
+ for _, target := range order {
+ targetStored, targetExtractFailures, err := db.UpsertBatch(target, grouped[target])
+ if err != nil {
+ return stored, extractFailures + targetExtractFailures, err
+ }
+ stored += targetStored
+ extractFailures += targetExtractFailures
+ }
+ return stored, extractFailures, nil
+}
+
+func resolveDiscriminatedResource(resource string, obj map[string]any) string {
+ dispatcher, ok := discriminatorDispatchers[resource]
+ if !ok || dispatcher.Field == "" {
+ return resource
+ }
+ value := store.LookupFieldValue(obj, dispatcher.Field)
+ if value == nil {
+ return resource
+ }
+ if target, ok := dispatcher.Values[fmt.Sprintf("%v", value)]; ok && target != "" {
+ return target
+ }
+ return resource
+}
+
+// upsertSingleObject stores a non-array API response as a single record.
+func upsertSingleObject(db *store.Store, resource string, data json.RawMessage) error {
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err != nil {
+ // Not a JSON object either - store raw under resource name
+ return db.Upsert(resource, resource, data)
+ }
+
+ resource = resolveDiscriminatedResource(resource, obj)
+
+ id := extractID(resource, obj)
+ if id == "" {
+ id = resource
+ }
+
+ switch resource {
+ case "projects":
+ return db.UpsertProjects(data)
+ case "tasks":
+ return db.UpsertTasks(data)
+ case "summary":
+ return db.UpsertSummary(data)
+ default:
+ return db.Upsert(resource, id, data)
+ }
+}
+
+// parseSinceDuration converts human-friendly duration strings into a time.Time.
+// Supported formats: "7d" (days), "24h" (hours), "30m" (minutes), "1w" (weeks).
+func parseSinceDuration(s string) (time.Time, error) {
+ re := regexp.MustCompile(`^(\d+)([dhwm])$`)
+ matches := re.FindStringSubmatch(strings.TrimSpace(s))
+ if matches == nil {
+ return time.Time{}, fmt.Errorf("expected format like 7d, 24h, 1w, or 30m")
+ }
+
+ n, err := strconv.Atoi(matches[1])
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ now := time.Now()
+ switch matches[2] {
+ case "d":
+ return now.Add(-time.Duration(n) * 24 * time.Hour), nil
+ case "h":
+ return now.Add(-time.Duration(n) * time.Hour), nil
+ case "w":
+ return now.Add(-time.Duration(n) * 7 * 24 * time.Hour), nil
+ case "m":
+ return now.Add(-time.Duration(n) * time.Minute), nil
+ default:
+ return time.Time{}, fmt.Errorf("unknown unit %q", matches[2])
+ }
+}
+
+func defaultSyncResources() []string {
+ return []string{
+ "currencies",
+ "projects",
+ }
+}
+
+// syncResourcePath maps resource names to their actual API endpoint paths.
+// For REST APIs this is typically "/<resource>". For non-REST APIs (e.g., Steam)
+// this preserves the actual endpoint path like "/ISteamApps/GetAppList/v2".
+func syncResourcePath(resource string) (string, error) {
+ paths := map[string]string{
+ "currencies": "/currencies",
+ "projects": "/projects",
+ }
+ if p, ok := paths[resource]; ok {
+ return p, nil
+ }
+ return "", fmt.Errorf("unknown sync resource %q", resource)
+}
+
+// dependentResourceDef describes a child resource that requires iterating parent IDs to sync.
+type dependentResourceDef struct {
+ Name string
+ ParentTable string
+ ParentIDParam string
+ PathTemplate string
+}
+
+func dependentResourceDefs() []dependentResourceDef {
+ return []dependentResourceDef{
+ {Name: "tasks", ParentTable: "projects", ParentIDParam: "projectId", PathTemplate: "/projects/{projectId}/tasks"},
+ }
+}
+
+// syncDependentResources iterates parent tables and syncs child resources per parent ID.
+func syncDependentResources(c interface {
+ Get(string, map[string]string) (json.RawMessage, error)
+ RateLimit() float64
+}, db *store.Store, sinceTS string, full bool, maxPages int) []syncResult {
+ var results []syncResult
+ for _, dep := range dependentResourceDefs() {
+ res := syncDependentResource(c, db, dep, sinceTS, full, maxPages)
+ results = append(results, res)
+ }
+ return results
+}
+
+// syncDependentResource syncs a single child resource by iterating all parent IDs.
+func syncDependentResource(c interface {
+ Get(string, map[string]string) (json.RawMessage, error)
+ RateLimit() float64
+}, db *store.Store, dep dependentResourceDef, sinceTS string, full bool, maxPages int) syncResult {
+ started := time.Now()
+
+ // Query parent table for all IDs
+ parentIDs, err := db.ListIDs(dep.ParentTable)
+ if err != nil || len(parentIDs) == 0 {
+ if len(parentIDs) == 0 {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: skipping (parent table %s is empty, sync it first)\n", dep.Name, dep.ParentTable)
+ }
+ return syncResult{Resource: dep.Name, Duration: time.Since(started)}
+ }
+ return syncResult{Resource: dep.Name, Err: fmt.Errorf("querying parent table %s: %w", dep.ParentTable, err), Duration: time.Since(started)}
+ }
+
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: syncing for %d %s parents\n", dep.Name, len(parentIDs), dep.ParentTable)
+ }
+
+ var totalCount int
+ var deniedParents int
+ var firstDenial *accessWarning
+ pageSize := determinePaginationDefaults()
+ // Per-resource extract-failure tracking for the F4b symptom probe and
+ // per-item primary_key_unresolved warning. See syncResource for the
+ // concurrency rationale (one goroutine per resource → no race).
+ var depExtractFailureTotal int
+ var depConsumedTotal int
+ depAnomalyEmitted := false
+
+ for idx, parentID := range parentIDs {
+ // Build child endpoint path by replacing the param placeholder
+ path := strings.Replace(dep.PathTemplate, "{"+dep.ParentIDParam+"}", parentID, 1)
+
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\r %s: syncing for %s (%d/%d parents)", dep.Name, dep.ParentTable, idx+1, len(parentIDs))
+ }
+
+ cursor := ""
+ pagesFetched := 0
+ lastNextCursor := ""
+
+ for {
+ params := map[string]string{}
+ params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+ if cursor != "" {
+ params[pageSize.cursorParam] = cursor
+ }
+ if sinceTS != "" {
+ params[determineSinceParam()] = sinceTS
+ }
+
+ data, err := c.Get(path, params)
+ if err != nil {
+ // Non-fatal per parent: log and continue to next parent.
+ // Track access-denial separately so an all-denied dependent
+ // resource can surface as a Warn rather than silent success.
+ if w, ok := isSyncAccessWarning(err); ok {
+ deniedParents++
+ if firstDenial == nil {
+ firstDenial = w
+ }
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: access denied for parent %s: %s\n", dep.Name, parentID, w.Reason)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","parent":"%s","status":%d,"reason":"%s","message":"%s"}`+"\n",
+ dep.Name, parentID, w.Status, w.Reason, strings.ReplaceAll(w.Message, `"`, `\"`))
+ }
+ } else if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: error for parent %s: %v\n", dep.Name, parentID, err)
+ }
+ break
+ }
+
+ items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
+ if len(items) == 0 {
+ break
+ }
+
+ // Inject parent_id into each item before upserting
+ for i, item := range items {
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(item, &obj); err == nil {
+ parentIDJSON, _ := json.Marshal(parentID)
+ obj["parent_id"] = parentIDJSON
+ if modified, err := json.Marshal(obj); err == nil {
+ items[i] = modified
+ }
+ }
+ }
+
+ stored, extractFailures, err := upsertResourceBatch(db, dep.Name, items)
+ if err != nil {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: upsert error for parent %s: %v\n", dep.Name, parentID, err)
+ }
+ break
+ }
+
+ depConsumedTotal += len(items)
+ depExtractFailureTotal += extractFailures
+ // Order matches the flat path (syncResource): all-fail first,
+ // then partial-fail. The all-fail case dominates — if stored==0
+ // with at least one item, the resource is broken regardless of
+ // how many extractions failed individually.
+ if len(items) > 0 && stored == 0 && !depAnomalyEmitted {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s returned %d items for parent %s but stored 0 — likely scalar item shape or unrecognized primary-key field name.\n", dep.Name, len(items), parentID)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_anomaly","resource":"%s","parent":"%s","consumed":%d,"stored":0,"reason":"all_items_failed_id_extraction"}`+"\n", dep.Name, parentID, len(items))
+ }
+ depAnomalyEmitted = true
+ } else if extractFailures > 0 && stored > 0 && !depAnomalyEmitted {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s had %d item(s) with no extractable primary key — those rows were dropped silently. Annotate the spec with x-resource-id to fix.\n", dep.Name, extractFailures)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_anomaly","resource":"%s","parent":"%s","consumed":%d,"stored":%d,"count":%d,"reason":"primary_key_unresolved"}`+"\n", dep.Name, parentID, len(items), stored, extractFailures)
+ }
+ depAnomalyEmitted = true
+ }
+
+ totalCount += stored
+ pagesFetched++
+
+ if maxPages > 0 && pagesFetched >= maxPages {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: reached --max-pages limit (%d pages, %d items) for parent %s\n", dep.Name, maxPages, totalCount, parentID)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","parent":"%s","reason":"max_pages_cap_hit","message":"reached --max-pages cap of %d; data may be truncated. Re-run with --max-pages 0 (unlimited) or higher to verify."}`+"\n", dep.Name, parentID, maxPages)
+ }
+ break
+ }
+ // Sticky-cursor detector: see syncResource for rationale. Same shape
+ // here so dependent-resource page loops cannot burn the budget on a
+ // non-advancing next cursor.
+ if nextCursor != "" && nextCursor == lastNextCursor {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n %s: API returned the same next cursor across two pages for parent %s; aborting to prevent budget waste.\n", dep.Name, parentID)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","parent":"%s","reason":"stuck_pagination","message":"API returned the same next cursor across two pages for resource %s; aborting to prevent budget waste."}`+"\n", dep.Name, parentID, dep.Name)
+ }
+ break
+ }
+ lastNextCursor = nextCursor
+ if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+ break
+ }
+ cursor = nextCursor
+ }
+
+ // Brief rate-limit pause between parents to avoid hammering the API
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\n")
+ }
+
+ _ = db.SaveSyncState(dep.Name, "", totalCount)
+
+ // F4b symptom probe: items consumed and extracted but nothing landed.
+ // See syncResource for rationale.
+ if depConsumedTotal > 0 && totalCount == 0 && depExtractFailureTotal < depConsumedTotal {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, "\nwarning: %s consumed %d items, extracted %d primary keys, but stored 0 rows — extraction succeeded yet nothing landed. Investigate FTS triggers / transaction rollback / encoding.\n", dep.Name, depConsumedTotal, depConsumedTotal-depExtractFailureTotal)
+ } else {
+ fmt.Fprintf(os.Stderr, `{"event":"sync_anomaly","resource":"%s","consumed":%d,"stored":0,"extract_failures":%d,"reason":"stored_count_zero_after_extraction"}`+"\n", dep.Name, depConsumedTotal, depExtractFailureTotal)
+ }
+ }
+
+ // If every parent was access-denied and nothing was synced, surface as a
+ // warning so the run-level summary and exit code reflect insufficient access.
+ if deniedParents == len(parentIDs) && totalCount == 0 && firstDenial != nil {
+ return syncResult{
+ Resource: dep.Name,
+ Count: 0,
+ Warn: fmt.Errorf("skipped %s: %s on all %d parents", dep.Name, firstDenial.Reason, len(parentIDs)),
+ Duration: time.Since(started),
+ }
+ }
+ return syncResult{Resource: dep.Name, Count: totalCount, Duration: time.Since(started)}
+}
+
+// resourceIDFieldOverrides projects per-resource IDField (set by the profiler
+// from x-resource-id or the response-schema fallback chain) into a runtime
+// lookup map. extractID consults this first so the templated path wins over
+// the generic fallback list; the generic list applies only when the override
+// is empty or the override field is absent on a given item.
+//
+// Includes both flat resources and dependent (parent-child) resources so
+// annotations on a child path-item are honored at runtime, not just on
+// flat paths.
+var resourceIDFieldOverrides = map[string]string{
+ "projects": "id",
+ "tasks": "id",
+}
+
+// genericIDFieldFallbacks is the runtime safety net for resources that did
+// NOT receive a templated IDField. API-specific names belong in spec
+// annotations (x-resource-id), not this list.
+var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+
+// criticalResources is the template-time projection of per-resource Critical
+// (set by the profiler from the spec's path-item x-critical extension). It
+// is consulted at error-aggregation time so a non-critical failure can be
+// downgraded to a sync_warning + exit 0 unless --strict was passed.
+//
+// Includes both flat resources and dependent (parent-child) resources so a
+// failed child sync flagged x-critical: true exits non-zero just like a
+// flat-resource critical failure.
+var criticalResources = map[string]bool{
+ "projects": true,
+}
+
+// extractID resolves an item's primary-key field. It consults the
+// per-resource templated override first; on miss, it falls through to the
+// generic fallback list. resource may be empty for callers that don't have
+// a resource context (only the generic list applies in that case).
+//
+// Field lookups go through store.LookupFieldValue so snake_case overrides
+// match camelCase JSON renderings. UpsertBatch resolves fields the same
+// way — divergence between the two paths produces silent drops on
+// heterogeneous payloads.
+func extractID(resource string, obj map[string]any) string {
+ if override, ok := resourceIDFieldOverrides[resource]; ok && override != "" {
+ if v := store.LookupFieldValue(obj, override); v != nil {
+ s := fmt.Sprintf("%v", v)
+ if s != "" && s != "<nil>" {
+ return s
+ }
+ }
+ }
+ for _, key := range genericIDFieldFallbacks {
+ if v := store.LookupFieldValue(obj, key); v != nil {
+ s := fmt.Sprintf("%v", v)
+ if s != "" && s != "<nil>" {
+ return s
+ }
+ }
+ }
+ return ""
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/which.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/which.go
new file mode 100644
index 00000000..5692afb3
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/which.go
@@ -0,0 +1,219 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// whichEntry is one row of the curated capability index. The index is
+// seeded at generation time from the same NovelFeature list that drives
+// the SKILL.md feature section, so the command a `which` query returns
+// is guaranteed to exist and to match what the skill advertises.
+type whichEntry struct {
+ Command string `json:"command"`
+ Description string `json:"description"`
+ Group string `json:"group,omitempty"`
+ WhyItMatters string `json:"why_it_matters,omitempty"`
+}
+
+// whichIndex is the curated list of capabilities this CLI advertises as
+// its hero features. Endpoint-level commands are discoverable via
+// `--help`; `which` exists to resolve a natural-language capability
+// query to one of the commands the skill says matter most.
+var whichIndex = []whichEntry{
+ {Command: "currencies list", Description: "List supported currencies", Group: "currencies"},
+ {Command: "projects create", Description: "Create project", Group: "projects"},
+ {Command: "projects get", Description: "Get project", Group: "projects"},
+ {Command: "projects list", Description: "List projects", Group: "projects"},
+ {Command: "projects tasks list-project", Description: "List project tasks", Group: "projects"},
+ {Command: "projects tasks update-project", Description: "Update project task", Group: "projects"},
+ {Command: "public get-status", Description: "Get public service status", Group: "public"},
+ {Command: "reports summary get-report-year", Description: "Get a report summary for a year", Group: "reports"},
+}
+
+// whichMatch pairs an index entry with its ranking score for a query.
+// Higher score means stronger match. The ranker is naive (exact token
+// then substring then group tag) because 20-40 entries do not need
+// semantic retrieval - a ranker upgrade is a future change that would
+// not break this contract.
+type whichMatch struct {
+ Entry whichEntry `json:"entry"`
+ Score int `json:"score"`
+}
+
+// rankWhich returns up to `limit` best matches for `query` against the
+// index, sorted by descending score. Score breakdown:
+//
+// +3 exact token match on the command's leaf or full path
+// +2 substring match on the command (any part)
+// +2 substring match on the description
+// +1 group tag contains the query as a word
+//
+// Ties break on declaration order in the index. An empty query returns
+// every entry at score 0 in declaration order - this is the "list all"
+// behavior the skill documents for broad agent discovery.
+func rankWhich(index []whichEntry, query string, limit int) []whichMatch {
+ if limit <= 0 {
+ limit = 3
+ }
+ q := strings.ToLower(strings.TrimSpace(query))
+ if q == "" {
+ out := make([]whichMatch, 0, len(index))
+ for _, e := range index {
+ out = append(out, whichMatch{Entry: e, Score: 0})
+ }
+ return out
+ }
+ qTokens := strings.Fields(q)
+
+ scored := make([]whichMatch, 0, len(index))
+ for i, e := range index {
+ score := whichScoreEntry(e, q, qTokens)
+ scored = append(scored, whichMatch{Entry: e, Score: score})
+ _ = i
+ }
+
+ sort.SliceStable(scored, func(i, j int) bool {
+ return scored[i].Score > scored[j].Score
+ })
+ // Drop zero-score matches when the query was non-empty; agents
+ // branching on exit code rely on "no match" meaning no confidence.
+ filtered := scored[:0]
+ for _, m := range scored {
+ if m.Score > 0 {
+ filtered = append(filtered, m)
+ }
+ }
+ if len(filtered) > limit {
+ filtered = filtered[:limit]
+ }
+ return filtered
+}
+
+func whichScoreEntry(e whichEntry, query string, qTokens []string) int {
+ score := 0
+ cmd := strings.ToLower(e.Command)
+ cmdTokens := strings.Fields(cmd)
+ desc := strings.ToLower(e.Description)
+ group := strings.ToLower(e.Group)
+
+ // Exact token match on the command path (any token).
+ for _, qt := range qTokens {
+ for _, ct := range cmdTokens {
+ if qt == ct {
+ score += 3
+ break
+ }
+ }
+ }
+ // Substring match on the full command (covers hyphenated leaves).
+ if strings.Contains(cmd, query) {
+ score += 2
+ }
+ // Substring match on the description.
+ if strings.Contains(desc, query) {
+ score += 2
+ }
+ // Group tag match.
+ if group != "" {
+ for _, qt := range qTokens {
+ if strings.Contains(group, qt) {
+ score += 1
+ break
+ }
+ }
+ }
+ return score
+}
+
+func newWhichCmd(flags *rootFlags) *cobra.Command {
+ var limit int
+ cmd := &cobra.Command{
+ Use: "which [query]",
+ Short: "Find the command that implements a capability",
+ Annotations: map[string]string{
+ "pp:typed-exit-codes": "0,2",
+ },
+ Long: `which resolves a natural-language capability query (for example, "search messages" or "stale tickets") to the best matching command from this CLI's curated feature index.
+
+Exit codes:
+ 0 at least one match found
+ 2 no confident match - the query did not score against any indexed capability; fall back to '--help' or 'search' if this CLI has one`,
+ Example: ` printing-press-golden-pp-cli which "stale tickets"
+ printing-press-golden-pp-cli which "bottleneck"
+ printing-press-golden-pp-cli which --limit 1 "send message"
+ printing-press-golden-pp-cli which # list the full capability index`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(whichIndex) == 0 {
+ return usageErr(fmt.Errorf("this CLI has no curated capability index; run '--help' to see every command"))
+ }
+ query := strings.Join(args, " ")
+ matches := rankWhich(whichIndex, query, limit)
+
+ // Empty query returns the whole index at score 0 (listing mode).
+ if strings.TrimSpace(query) == "" {
+ return renderWhich(cmd, flags, rankWhichAll(whichIndex))
+ }
+
+ if len(matches) == 0 {
+ // Under --json, return an empty matches envelope at exit 0
+ // so agents can branch on `matches.length == 0` instead of
+ // parsing a usage error message. Non-JSON keeps the typed
+ // exit-2 path so terminal users see the help hint.
+ if flags.asJSON {
+ return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
+ "matches": []whichMatch{},
+ }, flags)
+ }
+ return usageErr(fmt.Errorf("no match for %q; try '%s --help' for the full command list", query, cmd.Root().Name()))
+ }
+ return renderWhich(cmd, flags, matches)
+ },
+ }
+ cmd.Flags().IntVar(&limit, "limit", 3, "Maximum number of matches to return")
+ return cmd
+}
+
+// rankWhichAll is a narrow helper used by the "empty query lists the
+// index" path. It returns every entry in declaration order at score 0
+// so the render path treats them uniformly.
+func rankWhichAll(index []whichEntry) []whichMatch {
+ out := make([]whichMatch, 0, len(index))
+ for _, e := range index {
+ out = append(out, whichMatch{Entry: e, Score: 0})
+ }
+ return out
+}
+
+func renderWhich(cmd *cobra.Command, flags *rootFlags, matches []whichMatch) error {
+ w := cmd.OutOrStdout()
+ // Output shape follows the same rule as every other generated
+ // command: JSON when the caller asked for it OR when stdout is not
+ // a terminal; table when a human is looking.
+ asJSON := flags.asJSON
+ if !asJSON && !isTerminal(w) {
+ asJSON = true
+ }
+ if asJSON {
+ // JSON envelope: {matches: [...]}. The wrap is critical:
+ // printJSONFiltered's --compact path uses compactListFields
+ // (allowlist) for top-level arrays, which would strip
+ // entry/score keys; routing through compactObjectFields
+ // (blocklist) via an object envelope preserves them.
+ if matches == nil {
+ matches = []whichMatch{}
+ }
+ return printJSONFiltered(w, map[string]any{"matches": matches}, flags)
+ }
+ fmt.Fprintf(w, "%-24s %-8s %s\n", "COMMAND", "SCORE", "DESCRIPTION")
+ for _, m := range matches {
+ fmt.Fprintf(w, "%-24s %-8d %s\n", m.Entry.Command, m.Score, m.Entry.Description)
+ }
+ return nil
+}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
index 0977580d..cf2052e6 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/doctor.go
@@ -68,6 +68,9 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "Check CLI health",
+ Example: ` tier-routing-golden-pp-cli doctor
+ tier-routing-golden-pp-cli doctor --json
+ tier-routing-golden-pp-cli doctor --fail-on warn`,
RunE: func(cmd *cobra.Command, args []string) error {
report := map[string]any{}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
index 08975ba3..1c283e9a 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
@@ -200,17 +200,18 @@ Exit codes & warnings:
elapsed := time.Since(started)
totalResources := successCount + warnCount + errCount
- if !humanFriendly {
+ if humanFriendly {
+ if warnCount > 0 {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+ totalSynced, totalResources, warnCount, elapsed.Seconds())
+ } else {
+ fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+ totalSynced, totalResources, elapsed.Seconds())
+ }
+ } else {
fmt.Fprintf(os.Stderr, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
}
- if warnCount > 0 {
- fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
- totalSynced, totalResources, warnCount, elapsed.Seconds())
- } else {
- fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
- totalSynced, totalResources, elapsed.Seconds())
- }
// Exit-code policy:
// 1. --strict + any error -> non-zero (legacy contract)
← 708a6159 fix(skills): speed up retro issue filing (#575)
·
back to Cli Printing Press
·
fix(cli): live-dogfood matrix accuracy (WU-2, F4+F5) (#577) 7572b1e0 →