[object Object]

← back to Cli Printing Press

fix(cli): retro #302 — nine fixes from pagliacci-pizza regenerate (#305)

6181ee36d01366731d3262014877d42c7e9ad302 · 2026-04-26 09:52:28 -0700 · Trevin Chow

* fix(cli): retro #302 F3 — wire path-param placeholders for explicit endpoints

Internal-YAML specs that use the `endpoints:` shorthand (rather than the
`operations:` shorthand) decode straight from YAML into Endpoint{} structs.
The `operations:` branch was already populating Endpoint.Params with
{Positional: true} for path-templated paths so the generator emitted cobra
ExactArgs + replacePathParam calls. Explicit endpoints did not — every
`{paramName}` placeholder shipped as a literal in the URL and every
path-templated request returned 404 at runtime.

Surfaced during the Pagliacci regenerate where 29 generated commands
silently produced 404s until live dogfood. The fallback (Claude
hand-patches each broken file) is unreliable; the bug is silent at
build time and only fails against the real API.

Add an enrichPathParams pass in ParseBytes that walks every resource
and sub-resource endpoint, regex-extracts `{name}` placeholders from
the path, and appends Param{Positional, Required, type:"string"}
entries for any name not already declared in Params or Body. Author-
declared params are preserved as-is, and repeated placeholders in the
same path are added once.

Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): retro #302 F4 — JSON branch for composed-auth Config save/load

The composed-auth template emitted a Config.save() body that returned
`fmt.Errorf("config format %q does not support writing", "")` because
the `{{if eq .Config.Format "toml"}}...{{else}}` branch hit the else
when Format was empty. Internal-YAML specs that don't declare a
`config:` block (typical for browser-sniffed CLIs that use composed
cookie auth) thus shipped with broken auth persistence — `auth login
--chrome` validated the session and then died trying to save.

The Config struct tags already defaulted to JSON via the configTag
helper, and the file path used `config.{{or .Config.Format "json"}}`,
so the only missing piece was an actual JSON read/write path.

Add `encoding/json` to the imports when Format != "toml", emit a
`json.Unmarshal` branch in Load(), and emit a `json.MarshalIndent`
+ os.WriteFile branch in save(). The empty-format case (the one
that produced the always-error stub) now naturally maps to JSON,
matching the configTag default and the file-extension default.

Existing TOML-based CLIs (OpenAPI, crowd-sniff, docs paths all set
Format: "toml" explicitly) are untouched.

Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): retro #302 F6 — --select wins over --compact in agent mode

`--agent` enables `--compact`, which strips JSON to a fixed allow-list
(`id, name, status, timestamps`). The filter pipeline ran --compact
BEFORE --select, so when the user wrote `--agent --select x,y,z` the
allow-list dropped fields x/y/z before --select could pick them and
the command returned `[{},{},{}]`. Documented agent recipes pairing
the two flags shipped broken.

Reorder the filter pipeline at all four sites (command_endpoint.go.tmpl
twice, command_promoted.go.tmpl once, helpers.go.tmpl once) so an
explicit --select bypasses --compact entirely. --compact still applies
when --agent is on without --select (preserves the agent default).

Express it as `if selectFields != "" { filterFields(...) } else if
compact { compactFields(...) }` instead of two independent ifs, so
the precedence is a structural property of the code rather than a
comment a future template change might forget.

Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): retro #302 F7+F9 — live-check verb-token + research.json

F7: outputMentionsQuery flagged correct output as failed for verb-shaped
commands. `slices today --json` returns the right data, but the rendered
fields (store_name, slice_name, price) had no reason to echo the literal
word "today" — the trailing positional argument is the command verb, not
a search query. Without recognition, the relevance check failed every
time-shaped command (today, tonight, now, current, latest, recent,
upcoming, pending, active, expired, stale, all, live, open, closed) and
produced a stream of polish-worker false-positives.

Extend commonCommandVerb to include those time/state words alongside the
existing CRUD verbs so extractQueryToken returns "" for verb-shaped
trailing positionals. The relevance check then skips, and structurally
correct output passes.

F9: live-check resolved research.json from CLIDir only, so calling
scorecard --live-check on a working directory whose research.json lives
under the run state required manually copying the file. Add a
ResearchDir option (and matching --research-dir flag on `scorecard`)
that takes precedence over CLIDir when set; CLIDir remains the default.

Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 findings F7 and F9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): retro #302 F5 — kebab-case for cobra Use: from snake_case spec keys

Spec resource and endpoint names like `customer_feedback`, `slot_list`,
`window_days` flowed verbatim into cobra `Use:` strings, producing
underscored user-facing commands (`pagliacci-pp-cli customer_feedback`,
`scheduling window_days`). Cobra-based CLIs in the rest of the library
all use kebab-case or single-word commands; the snake form was a UX
artifact of the YAML key not getting normalized.

Extend the existing toKebab helper to convert `_` → `-` (it already
handles camelCase → kebab) and apply `{{kebab .ResourceName}}` /
`{{kebab .EndpointName}}` in command_parent.go.tmpl and
command_endpoint.go.tmpl. Go function identifiers (`newCustomerFeedbackCmd`)
keep the camelCase derived via the `camel` helper — only the user-facing
command name changes. End-to-end verified: a spec with
`customer_feedback` resource produces `customer-feedback get-by-id` etc.
in --help.

Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): retro #302 F1+F2 — reject reserved-name resources, rename GOOS-named files

F1: Spec resources named after a single-file template (`feedback`,
`doctor`, `auth`, `helpers`, `agent_context`, etc.) silently overwrote
the reserved file. Two collisions surfaced: the resource template's
<name>.go clobbered the reserved file (losing helpers like
FeedbackEndpointConfigured()), AND the resource's `new<Name>Cmd`
shadowed the reserved template's same-named cobra builder, breaking the
build with a redeclaration. Renaming the file alone wouldn't fix the
function-name clash, so reject at parse time with a clear hint:
"resource name 'feedback' collides with a reserved Printing Press
template ... Rename the resource — common alternatives: 'customer_feedback',
'feedback_resource'".

ReservedCLIResourceNames lives in internal/spec so the parser can
consult it without an import cycle on the generator.

F2: Endpoint names ending in a GOOS or GOARCH token (`windows`, `linux`,
`darwin`, `arm64`, …) produced filenames like `scheduling_windows.go`
that Go silently treats as platform-specific build constraints. The
function defined in such a file is undefined when building on any other
platform; the build fails with `undefined: newSchedulingWindowsCmd`.

Add safeResourceFileStem in the generator that detects the
*_<GOOS>.go, *_<GOARCH>.go, *_<GOOS>_<GOARCH>.go pattern (full
go-tool-dist token list as of Go 1.26) and suffixes "_cmd" so the file
is not subject to the build constraint. Apply at the four resource-emit
sites: parent file, endpoint file, sub-parent file, sub-endpoint file.
The "_cmd" suffix is itself neither a GOOS nor GOARCH token, so a
single application is sufficient.

End-to-end: a spec with `feedback` resource is rejected at parse, and a
spec with a `windows` endpoint generates `<resource>_windows_cmd.go` on
top of the existing `<resource>.go`.

Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 findings F1 and F2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): retro #302 F8 — cli_description spec field eliminates manual rewrite

The generator copied the spec's `description` field (which describes the
*API*) as the printed CLI's root cobra `Short:` text. Spec descriptions
read like "REST API for ordering pizza"; CLI help should read like
"Order Seattle pizza from the terminal". The skill's Phase 2 instructed
Claude to rewrite root.go's Short: after every generation — a recurring
manual edit on every CLI.

Add a `cli_description` field to APISpec. The root.go.tmpl now picks
the Short: text in this priority order:

  1. spec.cli_description           (explicit author choice)
  2. research.json narrative.headline (research-derived headline)
  3. "Manage <api> resources via the <api> API"  (generic fallback)

Long: takes the same three-tier priority so cobra `--help` (which
prefers Long: when present) renders the correct text. Existing CLIs
unchanged — both new branches are additive `{{- if}}` blocks above the
existing fallback.

Update SKILL.md to replace the "REQUIRED: Rewrite the CLI description"
imperative with a verification step that prefers adding `cli_description:`
to the spec over hand-editing the generated file. Author-time spec
edits compound; post-generation hand-edits get clobbered on regenerate.

Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): retro #302 F1 review polish — correct PascalCase in collision message

Code review on PR #305 caught two cosmetic-but-misleading issues in the
reserved-name rejection error:

1. The "common alternatives: customer_<name>" suggestion was generic and
   silly for many reserved names (customer_auth, customer_doctor). Drop
   the prefix and suggest only `<name>_resource`.

2. capitalizeFirst("agent_context") returned "Agent_context", so the
   error message claimed the function was `newAgent_contextCmd` — but
   the actual generated function is `newAgentContextCmd` (camel-cased).
   Replace capitalizeFirst with a snakeToPascal helper that converts
   each underscore-separated segment, matching what the generator's
   toCamel helper produces.

Tightened TestValidateReservedNames to assert the correct function name
appears (and the wrong one does not). Added TestSnakeToPascal covering
empty input, leading/trailing underscores, multi-segment, single-token,
and already-PascalCase input.

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

Diff

commit 6181ee36d01366731d3262014877d42c7e9ad302
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun Apr 26 09:52:28 2026 -0700

    fix(cli): retro #302 — nine fixes from pagliacci-pizza regenerate (#305)
    
    * fix(cli): retro #302 F3 — wire path-param placeholders for explicit endpoints
    
    Internal-YAML specs that use the `endpoints:` shorthand (rather than the
    `operations:` shorthand) decode straight from YAML into Endpoint{} structs.
    The `operations:` branch was already populating Endpoint.Params with
    {Positional: true} for path-templated paths so the generator emitted cobra
    ExactArgs + replacePathParam calls. Explicit endpoints did not — every
    `{paramName}` placeholder shipped as a literal in the URL and every
    path-templated request returned 404 at runtime.
    
    Surfaced during the Pagliacci regenerate where 29 generated commands
    silently produced 404s until live dogfood. The fallback (Claude
    hand-patches each broken file) is unreliable; the bug is silent at
    build time and only fails against the real API.
    
    Add an enrichPathParams pass in ParseBytes that walks every resource
    and sub-resource endpoint, regex-extracts `{name}` placeholders from
    the path, and appends Param{Positional, Required, type:"string"}
    entries for any name not already declared in Params or Body. Author-
    declared params are preserved as-is, and repeated placeholders in the
    same path are added once.
    
    Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F3.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): retro #302 F4 — JSON branch for composed-auth Config save/load
    
    The composed-auth template emitted a Config.save() body that returned
    `fmt.Errorf("config format %q does not support writing", "")` because
    the `{{if eq .Config.Format "toml"}}...{{else}}` branch hit the else
    when Format was empty. Internal-YAML specs that don't declare a
    `config:` block (typical for browser-sniffed CLIs that use composed
    cookie auth) thus shipped with broken auth persistence — `auth login
    --chrome` validated the session and then died trying to save.
    
    The Config struct tags already defaulted to JSON via the configTag
    helper, and the file path used `config.{{or .Config.Format "json"}}`,
    so the only missing piece was an actual JSON read/write path.
    
    Add `encoding/json` to the imports when Format != "toml", emit a
    `json.Unmarshal` branch in Load(), and emit a `json.MarshalIndent`
    + os.WriteFile branch in save(). The empty-format case (the one
    that produced the always-error stub) now naturally maps to JSON,
    matching the configTag default and the file-extension default.
    
    Existing TOML-based CLIs (OpenAPI, crowd-sniff, docs paths all set
    Format: "toml" explicitly) are untouched.
    
    Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F4.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): retro #302 F6 — --select wins over --compact in agent mode
    
    `--agent` enables `--compact`, which strips JSON to a fixed allow-list
    (`id, name, status, timestamps`). The filter pipeline ran --compact
    BEFORE --select, so when the user wrote `--agent --select x,y,z` the
    allow-list dropped fields x/y/z before --select could pick them and
    the command returned `[{},{},{}]`. Documented agent recipes pairing
    the two flags shipped broken.
    
    Reorder the filter pipeline at all four sites (command_endpoint.go.tmpl
    twice, command_promoted.go.tmpl once, helpers.go.tmpl once) so an
    explicit --select bypasses --compact entirely. --compact still applies
    when --agent is on without --select (preserves the agent default).
    
    Express it as `if selectFields != "" { filterFields(...) } else if
    compact { compactFields(...) }` instead of two independent ifs, so
    the precedence is a structural property of the code rather than a
    comment a future template change might forget.
    
    Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F6.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): retro #302 F7+F9 — live-check verb-token + research.json
    
    F7: outputMentionsQuery flagged correct output as failed for verb-shaped
    commands. `slices today --json` returns the right data, but the rendered
    fields (store_name, slice_name, price) had no reason to echo the literal
    word "today" — the trailing positional argument is the command verb, not
    a search query. Without recognition, the relevance check failed every
    time-shaped command (today, tonight, now, current, latest, recent,
    upcoming, pending, active, expired, stale, all, live, open, closed) and
    produced a stream of polish-worker false-positives.
    
    Extend commonCommandVerb to include those time/state words alongside the
    existing CRUD verbs so extractQueryToken returns "" for verb-shaped
    trailing positionals. The relevance check then skips, and structurally
    correct output passes.
    
    F9: live-check resolved research.json from CLIDir only, so calling
    scorecard --live-check on a working directory whose research.json lives
    under the run state required manually copying the file. Add a
    ResearchDir option (and matching --research-dir flag on `scorecard`)
    that takes precedence over CLIDir when set; CLIDir remains the default.
    
    Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 findings F7 and F9.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): retro #302 F5 — kebab-case for cobra Use: from snake_case spec keys
    
    Spec resource and endpoint names like `customer_feedback`, `slot_list`,
    `window_days` flowed verbatim into cobra `Use:` strings, producing
    underscored user-facing commands (`pagliacci-pp-cli customer_feedback`,
    `scheduling window_days`). Cobra-based CLIs in the rest of the library
    all use kebab-case or single-word commands; the snake form was a UX
    artifact of the YAML key not getting normalized.
    
    Extend the existing toKebab helper to convert `_` → `-` (it already
    handles camelCase → kebab) and apply `{{kebab .ResourceName}}` /
    `{{kebab .EndpointName}}` in command_parent.go.tmpl and
    command_endpoint.go.tmpl. Go function identifiers (`newCustomerFeedbackCmd`)
    keep the camelCase derived via the `camel` helper — only the user-facing
    command name changes. End-to-end verified: a spec with
    `customer_feedback` resource produces `customer-feedback get-by-id` etc.
    in --help.
    
    Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F5.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): retro #302 F1+F2 — reject reserved-name resources, rename GOOS-named files
    
    F1: Spec resources named after a single-file template (`feedback`,
    `doctor`, `auth`, `helpers`, `agent_context`, etc.) silently overwrote
    the reserved file. Two collisions surfaced: the resource template's
    <name>.go clobbered the reserved file (losing helpers like
    FeedbackEndpointConfigured()), AND the resource's `new<Name>Cmd`
    shadowed the reserved template's same-named cobra builder, breaking the
    build with a redeclaration. Renaming the file alone wouldn't fix the
    function-name clash, so reject at parse time with a clear hint:
    "resource name 'feedback' collides with a reserved Printing Press
    template ... Rename the resource — common alternatives: 'customer_feedback',
    'feedback_resource'".
    
    ReservedCLIResourceNames lives in internal/spec so the parser can
    consult it without an import cycle on the generator.
    
    F2: Endpoint names ending in a GOOS or GOARCH token (`windows`, `linux`,
    `darwin`, `arm64`, …) produced filenames like `scheduling_windows.go`
    that Go silently treats as platform-specific build constraints. The
    function defined in such a file is undefined when building on any other
    platform; the build fails with `undefined: newSchedulingWindowsCmd`.
    
    Add safeResourceFileStem in the generator that detects the
    *_<GOOS>.go, *_<GOARCH>.go, *_<GOOS>_<GOARCH>.go pattern (full
    go-tool-dist token list as of Go 1.26) and suffixes "_cmd" so the file
    is not subject to the build constraint. Apply at the four resource-emit
    sites: parent file, endpoint file, sub-parent file, sub-endpoint file.
    The "_cmd" suffix is itself neither a GOOS nor GOARCH token, so a
    single application is sufficient.
    
    End-to-end: a spec with `feedback` resource is rejected at parse, and a
    spec with a `windows` endpoint generates `<resource>_windows_cmd.go` on
    top of the existing `<resource>.go`.
    
    Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 findings F1 and F2.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): retro #302 F8 — cli_description spec field eliminates manual rewrite
    
    The generator copied the spec's `description` field (which describes the
    *API*) as the printed CLI's root cobra `Short:` text. Spec descriptions
    read like "REST API for ordering pizza"; CLI help should read like
    "Order Seattle pizza from the terminal". The skill's Phase 2 instructed
    Claude to rewrite root.go's Short: after every generation — a recurring
    manual edit on every CLI.
    
    Add a `cli_description` field to APISpec. The root.go.tmpl now picks
    the Short: text in this priority order:
    
      1. spec.cli_description           (explicit author choice)
      2. research.json narrative.headline (research-derived headline)
      3. "Manage <api> resources via the <api> API"  (generic fallback)
    
    Long: takes the same three-tier priority so cobra `--help` (which
    prefers Long: when present) renders the correct text. Existing CLIs
    unchanged — both new branches are additive `{{- if}}` blocks above the
    existing fallback.
    
    Update SKILL.md to replace the "REQUIRED: Rewrite the CLI description"
    imperative with a verification step that prefers adding `cli_description:`
    to the spec over hand-editing the generated file. Author-time spec
    edits compound; post-generation hand-edits get clobbered on regenerate.
    
    Surfaced during pagliacci-pizza regenerate (PR mvanhorn/printing-press-library#128); part of retro #302 finding F8.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): retro #302 F1 review polish — correct PascalCase in collision message
    
    Code review on PR #305 caught two cosmetic-but-misleading issues in the
    reserved-name rejection error:
    
    1. The "common alternatives: customer_<name>" suggestion was generic and
       silly for many reserved names (customer_auth, customer_doctor). Drop
       the prefix and suggest only `<name>_resource`.
    
    2. capitalizeFirst("agent_context") returned "Agent_context", so the
       error message claimed the function was `newAgent_contextCmd` — but
       the actual generated function is `newAgentContextCmd` (camel-cased).
       Replace capitalizeFirst with a snakeToPascal helper that converts
       each underscore-separated segment, matching what the generator's
       toCamel helper produces.
    
    Tightened TestValidateReservedNames to assert the correct function name
    appears (and the wrong one does not). Added TestSnakeToPascal covering
    empty input, leading/trailing underscores, multi-segment, single-token,
    and already-PascalCase input.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/cli/scorecard.go                          |  10 +-
 internal/generator/generator.go                    | 107 +++++-
 internal/generator/generator_test.go               |  23 ++
 internal/generator/safe_filename_test.go           | 119 +++++++
 .../generator/templates/command_endpoint.go.tmpl   |  21 +-
 .../generator/templates/command_parent.go.tmpl     |   2 +-
 .../generator/templates/command_promoted.go.tmpl   |   9 +-
 internal/generator/templates/config.go.tmpl        |  16 +-
 internal/generator/templates/helpers.go.tmpl       |  12 +-
 internal/generator/templates/root.go.tmpl          |  11 +-
 internal/pipeline/live_check.go                    |  27 +-
 internal/pipeline/live_check_test.go               |  41 +++
 internal/spec/spec.go                              | 174 ++++++++-
 internal/spec/spec_test.go                         | 391 +++++++++++++++++++++
 skills/printing-press/SKILL.md                     |  15 +-
 15 files changed, 939 insertions(+), 39 deletions(-)

diff --git a/internal/cli/scorecard.go b/internal/cli/scorecard.go
index f8d03776..d971780c 100644
--- a/internal/cli/scorecard.go
+++ b/internal/cli/scorecard.go
@@ -13,6 +13,7 @@ import (
 
 func newScorecardCmd() *cobra.Command {
 	var dir string
+	var researchDir string
 	var specPath string
 	var asJSON bool
 	var liveCheck bool
@@ -27,6 +28,9 @@ func newScorecardCmd() *cobra.Command {
   # Include a live behavioral sample (runs novel-feature examples against real targets)
   printing-press scorecard --dir ./generated/stripe-pp-cli --live-check
 
+  # Live-check a CLI whose research.json lives in the run state, not the CLI dir
+  printing-press scorecard --dir ./working/foo-pp-cli --research-dir ./runs/<id> --live-check
+
   # Output as JSON
   printing-press scorecard --dir ./generated/stripe-pp-cli --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
@@ -49,8 +53,9 @@ func newScorecardCmd() *cobra.Command {
 			var live *pipeline.LiveCheckResult
 			if liveCheck {
 				live = pipeline.RunLiveCheck(pipeline.LiveCheckOptions{
-					CLIDir:  dir,
-					Timeout: liveCheckTimeout,
+					CLIDir:      dir,
+					ResearchDir: researchDir,
+					Timeout:     liveCheckTimeout,
 				})
 				if insightCap := pipeline.InsightCapFromLiveCheck(live); insightCap != nil && sc.Steinberger.Insight > *insightCap {
 					sc.Steinberger.Insight = *insightCap
@@ -147,6 +152,7 @@ func newScorecardCmd() *cobra.Command {
 	}
 
 	cmd.Flags().StringVar(&dir, "dir", "", "Path to generated CLI directory")
+	cmd.Flags().StringVar(&researchDir, "research-dir", "", "Directory containing research.json (defaults to --dir; useful when the CLI working dir and the run's research.json live in different directories)")
 	cmd.Flags().StringVar(&specPath, "spec", "", "Path to OpenAPI spec JSON for semantic validation")
 	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
 	cmd.Flags().BoolVar(&liveCheck, "live-check", false, "Sample novel-feature examples against real targets and cap Insight when flagships return broken output")
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 32e4693d..f0ee5d94 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1087,7 +1087,7 @@ func (g *Generator) Generate() error {
 				Hidden:       false,
 				APISpec:      g.Spec,
 			}
-			parentPath := filepath.Join("internal", "cli", name+".go")
+			parentPath := filepath.Join("internal", "cli", safeResourceFileStem(name)+".go")
 			if err := g.renderTemplate("command_parent.go.tmpl", parentPath, parentData); err != nil {
 				return fmt.Errorf("rendering parent command %s: %w", name, err)
 			}
@@ -1121,7 +1121,7 @@ func (g *Generator) Generate() error {
 				Async:        asyncInfo,
 				APISpec:      g.Spec,
 			}
-			epPath := filepath.Join("internal", "cli", name+"_"+eName+".go")
+			epPath := filepath.Join("internal", "cli", safeResourceFileStem(name+"_"+eName)+".go")
 			if err := g.renderTemplate("command_endpoint.go.tmpl", epPath, epData); err != nil {
 				return fmt.Errorf("rendering endpoint %s/%s: %w", name, eName, err)
 			}
@@ -1149,7 +1149,7 @@ func (g *Generator) Generate() error {
 					Hidden:       false,
 					APISpec:      g.Spec,
 				}
-				subParentPath := filepath.Join("internal", "cli", name+"_"+subName+".go")
+				subParentPath := filepath.Join("internal", "cli", safeResourceFileStem(name+"_"+subName)+".go")
 				if err := g.renderTemplate("command_parent.go.tmpl", subParentPath, subParentData); err != nil {
 					return fmt.Errorf("rendering sub-parent %s/%s: %w", name, subName, err)
 				}
@@ -1179,7 +1179,7 @@ func (g *Generator) Generate() error {
 					Async:        asyncInfo,
 					APISpec:      g.Spec,
 				}
-				epPath := filepath.Join("internal", "cli", name+"_"+subName+"_"+eName+".go")
+				epPath := filepath.Join("internal", "cli", safeResourceFileStem(name+"_"+subName+"_"+eName)+".go")
 				if err := g.renderTemplate("command_endpoint.go.tmpl", epPath, epData); err != nil {
 					return fmt.Errorf("rendering sub-endpoint %s/%s/%s: %w", name, subName, eName, err)
 				}
@@ -2037,6 +2037,97 @@ func envVarField(envVar string) string {
 	return result.String()
 }
 
+// ReservedCLIResourceNames lives in internal/spec/spec.go (spec.ReservedCLIResourceNames)
+// so the parser can consult it without importing this package and creating a cycle.
+
+// goosTokens are the GOOS values Go's filename-based build constraints recognize.
+// A file named *_<token>.go gets an implicit build tag and is silently excluded
+// when the host OS doesn't match. Source of truth: `go tool dist list`.
+var goosTokens = map[string]struct{}{
+	"aix":       {},
+	"android":   {},
+	"darwin":    {},
+	"dragonfly": {},
+	"freebsd":   {},
+	"hurd":      {},
+	"illumos":   {},
+	"ios":       {},
+	"js":        {},
+	"linux":     {},
+	"nacl":      {},
+	"netbsd":    {},
+	"openbsd":   {},
+	"plan9":     {},
+	"solaris":   {},
+	"wasip1":    {},
+	"windows":   {},
+	"zos":       {},
+}
+
+// goarchTokens are the GOARCH values Go's filename-based build constraints recognize.
+var goarchTokens = map[string]struct{}{
+	"386":      {},
+	"amd64":    {},
+	"arm":      {},
+	"arm64":    {},
+	"loong64":  {},
+	"mips":     {},
+	"mips64":   {},
+	"mips64le": {},
+	"mipsle":   {},
+	"ppc64":    {},
+	"ppc64le":  {},
+	"riscv":    {},
+	"riscv64":  {},
+	"s390x":    {},
+	"sparc64":  {},
+	"wasm":     {},
+}
+
+// safeResourceFileStem returns a basename (without .go) safe to write under
+// internal/cli/, suffixing "_cmd" if the bare stem matches Go's filename-based
+// build-constraint pattern (*_<GOOS>.go, *_<GOARCH>.go, *_<GOOS>_<GOARCH>.go).
+// Without this rename, a file like scheduling_windows.go would get an implicit
+// Windows-only build tag and be silently excluded on macOS/Linux builds.
+//
+// The reserved-name collision is handled separately at spec-parse time
+// (see ReservedCLIResourceNames) because the function-name collision needs a
+// hard error rather than a silent rename — `new<Name>Cmd` would clash with
+// the reserved template's identically-named cobra builder.
+//
+// The suffix "_cmd" is never itself a GOOS or GOARCH token, so a single
+// application is sufficient.
+//
+// Examples:
+//
+//	safeResourceFileStem("scheduling_windows")     -> "scheduling_windows_cmd"
+//	safeResourceFileStem("foo_linux_amd64")        -> "foo_linux_amd64_cmd"
+//	safeResourceFileStem("scheduling_window_days") -> "scheduling_window_days" (no change)
+//	safeResourceFileStem("feedback")               -> "feedback" (no change; rejected at parse)
+func safeResourceFileStem(stem string) string {
+	parts := strings.Split(stem, "_")
+	if len(parts) >= 2 {
+		last := parts[len(parts)-1]
+		if _, isOS := goosTokens[last]; isOS {
+			return stem + "_cmd"
+		}
+		if _, isArch := goarchTokens[last]; isArch {
+			return stem + "_cmd"
+		}
+	}
+	if len(parts) >= 3 {
+		// Match the *_GOOS_GOARCH.go pattern (e.g., foo_linux_amd64.go).
+		penultimate := parts[len(parts)-2]
+		last := parts[len(parts)-1]
+		_, osOK := goosTokens[penultimate]
+		_, archOK := goarchTokens[last]
+		if osOK && archOK {
+			return stem + "_cmd"
+		}
+	}
+	return stem
+}
+
 // builtinConfigTags lists the JSON/TOML tags of hardcoded Config struct fields
 // in config.go.tmpl. When an env var's placeholder matches one of these, the
 // env var should populate the existing field instead of creating a duplicate.
@@ -2326,6 +2417,14 @@ func toKebab(s string) string {
 	}
 	var result strings.Builder
 	for i, r := range s {
+		// Snake-case underscores convert to dashes. Lets spec keys like
+		// `customer_feedback` and `slot_list_for_date` flow through to
+		// user-facing cobra `Use:` strings as `customer-feedback` and
+		// `slot-list-for-date` instead of preserving the snake form.
+		if r == '_' {
+			result.WriteByte('-')
+			continue
+		}
 		if unicode.IsUpper(r) && i > 0 {
 			prev := rune(s[i-1])
 			// Insert hyphen before uppercase letter if preceded by lowercase,
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index a9bd23ec..d4f257eb 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -3572,3 +3572,26 @@ func TestGenerateMCPMainRemoteCompiles(t *testing.T) {
 	require.False(t, info.IsDir())
 	require.NotZero(t, info.Size())
 }
+
+func TestToKebab_SnakeCaseInput(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		input    string
+		expected string
+		why      string
+	}{
+		{"customer_feedback", "customer-feedback", "snake_case spec key flows to user-facing kebab-case"},
+		{"slot_list_for_date", "slot-list-for-date", "multi-segment snake → kebab"},
+		{"window_days", "window-days", "two-word snake → kebab"},
+		{"already-kebab", "already-kebab", "kebab passes through unchanged"},
+		{"mixed_caseInput", "mixed-case-input", "mixed snake + camel both convert"},
+		{"single", "single", "single-word lowercase unchanged"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			t.Parallel()
+			got := toKebab(tt.input)
+			require.Equal(t, tt.expected, got, tt.why)
+		})
+	}
+}
diff --git a/internal/generator/safe_filename_test.go b/internal/generator/safe_filename_test.go
new file mode 100644
index 00000000..7122cd24
--- /dev/null
+++ b/internal/generator/safe_filename_test.go
@@ -0,0 +1,119 @@
+package generator
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSafeResourceFileStem(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		name     string
+		stem     string
+		expected string
+		why      string
+	}{
+		{
+			name:     "ordinary single-word stem unchanged",
+			stem:     "store",
+			expected: "store",
+			why:      "no collision; baseline pass-through",
+		},
+		{
+			name:     "ordinary multi-word stem unchanged",
+			stem:     "scheduling_window_days",
+			expected: "scheduling_window_days",
+			why:      "last token is not a GOOS/GOARCH",
+		},
+		{
+			name:     "reserved name not handled here (parse-time validation rejects it)",
+			stem:     "feedback",
+			expected: "feedback",
+			why:      "reserved-name collision is rejected by spec.validateReservedNames; safeResourceFileStem only handles GOOS/GOARCH renames",
+		},
+		{
+			name:     "GOOS suffix triggers rename",
+			stem:     "scheduling_windows",
+			expected: "scheduling_windows_cmd",
+			why:      "Go treats *_windows.go as Windows-only build constraint",
+		},
+		{
+			name:     "GOOS suffix linux",
+			stem:     "metrics_linux",
+			expected: "metrics_linux_cmd",
+			why:      "Linux-only build tag would silently exclude the file",
+		},
+		{
+			name:     "GOARCH suffix triggers rename",
+			stem:     "cpu_amd64",
+			expected: "cpu_amd64_cmd",
+			why:      "Go treats *_amd64.go as amd64-only build constraint",
+		},
+		{
+			name:     "GOOS_GOARCH suffix triggers rename",
+			stem:     "build_linux_amd64",
+			expected: "build_linux_amd64_cmd",
+			why:      "Combined GOOS+GOARCH is also a build-constraint pattern",
+		},
+		{
+			name:     "stem ending in non-token even if matches partial token unchanged",
+			stem:     "scheduling_window_days",
+			expected: "scheduling_window_days",
+			why:      "'days' is not a GOOS/GOARCH; only exact-suffix tokens trigger",
+		},
+		{
+			name:     "embedded GOOS in middle position is fine",
+			stem:     "windows_special",
+			expected: "windows_special",
+			why:      "'windows' is only a build constraint when it's the trailing token",
+		},
+		{
+			name:     "single token GOOS by itself unchanged",
+			stem:     "windows",
+			expected: "windows",
+			why:      "bare 'windows.go' has no underscore prefix, so build-tag rule does not match",
+		},
+		{
+			name:     "GOOS as resource alone produces *_<endpoint>.go pattern handled by caller",
+			stem:     "linux_list",
+			expected: "linux_list",
+			why:      "trailing 'list' is not a GOOS/GOARCH; the caller passes the full <resource>_<endpoint> stem so we evaluate the trailing token",
+		},
+		{
+			name:     "GOOS_GOARCH where penultimate is not actually GOOS unchanged",
+			stem:     "store_arm64_endpoint",
+			expected: "store_arm64_endpoint",
+			why:      "'endpoint' is not a GOARCH/GOOS; only triggers on the last token",
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := safeResourceFileStem(tc.stem)
+			assert.Equal(t, tc.expected, got, tc.why)
+		})
+	}
+}
+
+func TestSafeResourceFileStem_AllGOOSTokensCovered(t *testing.T) {
+	t.Parallel()
+	// Every GOOS token must trigger the rename when present as the trailing
+	// segment. This keeps the goosTokens map honest if Go adds new platforms.
+	for token := range goosTokens {
+		got := safeResourceFileStem("res_" + token)
+		assert.Equal(t, "res_"+token+"_cmd", got, "GOOS token %q should trigger rename", token)
+	}
+}
+
+func TestSafeResourceFileStem_AllGOARCHTokensCovered(t *testing.T) {
+	t.Parallel()
+	for token := range goarchTokens {
+		got := safeResourceFileStem("res_" + token)
+		assert.Equal(t, "res_"+token+"_cmd", got, "GOARCH token %q should trigger rename", token)
+	}
+}
+
+// (The reserved-name set lives in internal/spec.ReservedCLIResourceNames, where
+// the parser rejects colliding resource names. Tests for it live in
+// internal/spec/spec_test.go alongside the rest of validation.)
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 3af167a4..8c7806cc 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -42,7 +42,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- end}}
 
 	cmd := &cobra.Command{
-		Use:   "{{.EndpointName}}{{positionalArgs .Endpoint}}",
+		Use:   "{{kebab .EndpointName}}{{positionalArgs .Endpoint}}",
 {{- if .Endpoint.Alias}}
 		Aliases: []string{"{{.Endpoint.Alias}}"},
 {{- end}}
@@ -435,13 +435,15 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				if flags.quiet {
 					return nil
 				}
-				// Apply --compact and --select to the API response before wrapping
+				// Apply --compact and --select to the API response before wrapping.
+				// --select wins when both are set: explicit field choice trumps the
+				// generic high-gravity allow-list. Otherwise --compact still applies
+				// when --agent is on but the user did not name fields.
 				filtered := data
-				if flags.compact {
-					filtered = compactFields(filtered)
-				}
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
+				} else if flags.compact {
+					filtered = compactFields(filtered)
 				}
 				envelope := map[string]any{
 					"action":   "{{lower .Endpoint.Method}}",
@@ -476,14 +478,15 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				_ = json.Unmarshal(data, &countItems)
 				printProvenance(cmd, len(countItems), prov)
 			}
-			// For JSON output, wrap with provenance envelope before passing through flags
+			// For JSON output, wrap with provenance envelope before passing through flags.
+			// --select wins over --compact when both are set; --compact only runs when
+			// no explicit fields were requested.
 			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
 				filtered := data
-				if flags.compact {
-					filtered = compactFields(filtered)
-				}
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
+				} else if flags.compact {
+					filtered = compactFields(filtered)
 				}
 				wrapped, wrapErr := wrapWithProvenance(filtered, prov)
 				if wrapErr != nil {
diff --git a/internal/generator/templates/command_parent.go.tmpl b/internal/generator/templates/command_parent.go.tmpl
index 6f613c35..198b884f 100644
--- a/internal/generator/templates/command_parent.go.tmpl
+++ b/internal/generator/templates/command_parent.go.tmpl
@@ -9,7 +9,7 @@ import (
 
 func new{{camel .FuncPrefix}}Cmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
-		Use:   "{{.ResourceName}}",
+		Use:   "{{kebab .ResourceName}}",
 		Short: "{{oneline .Resource.Description}}",
 {{- if .Hidden}}
 		Hidden: true,
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 60c56691..39df0ee9 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -184,14 +184,15 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 			if flags.csv {
 				return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
 			}
-			// For JSON output, wrap with provenance envelope
+			// For JSON output, wrap with provenance envelope. --select wins over
+			// --compact when both are set; --compact only runs when no explicit
+			// fields were requested.
 			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
 				filtered := data
-				if flags.compact {
-					filtered = compactFields(filtered)
-				}
 				if flags.selectFields != "" {
 					filtered = filterFields(filtered, flags.selectFields)
+				} else if flags.compact {
+					filtered = compactFields(filtered)
 				}
 				wrapped, wrapErr := wrapWithProvenance(filtered, prov)
 				if wrapErr != nil {
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index fec6b833..f59fc2b4 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -4,6 +4,9 @@
 package config
 
 import (
+{{- if ne .Config.Format "toml"}}
+	"encoding/json"
+{{- end}}
 	"fmt"
 	"os"
 	"path/filepath"
@@ -58,6 +61,13 @@ func Load(configPath string) (*Config, error) {
 			return nil, fmt.Errorf("parsing config %s: %w", path, err)
 		}
 	}
+{{- else}}
+	if data, err := os.ReadFile(path); err == nil {
+		if err := json.Unmarshal(data, cfg); err != nil {
+			return nil, fmt.Errorf("parsing config %s: %w", path, err)
+		}
+		cfg.Path = path
+	}
 {{- end}}
 
 	// Env var overrides
@@ -198,7 +208,11 @@ func (c *Config) save() error {
 	}
 	return os.WriteFile(c.Path, data, 0o600)
 {{- else}}
-	return fmt.Errorf("config format %q does not support writing", "{{.Config.Format}}")
+	data, err := json.MarshalIndent(c, "", "  ")
+	if err != nil {
+		return fmt.Errorf("marshaling config: %w", err)
+	}
+	return os.WriteFile(c.Path, data, 0o600)
 {{- end}}
 }
 
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 13b38a8d..1addb2e1 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -553,13 +553,15 @@ func camelToKebab(s string) string {
 
 // printOutputWithFlags routes output through the right format based on flags.
 func printOutputWithFlags(w io.Writer, data json.RawMessage, flags *rootFlags) error {
-	// Apply --compact: filter to high-gravity fields only
-	if flags.compact {
-		data = compactFields(data)
-	}
-	// Apply --select field filtering
+	// --select wins over --compact when both are set: an explicit field list
+	// is the user's authoritative request, so the high-gravity allow-list
+	// must not strip those fields out before --select can pick them. When
+	// only --compact is set (e.g., --agent without --select), the allow-list
+	// still runs.
 	if flags.selectFields != "" {
 		data = filterFields(data, flags.selectFields)
+	} else if flags.compact {
+		data = compactFields(data)
 	}
 	// --quiet: suppress all output, exit code communicates result
 	if flags.quiet {
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 286b1914..2478c6d6 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -65,13 +65,15 @@ func Execute() error {
            from producing a wall of text in --help
        Full novel-feature details with examples live in README/SKILL; Long
        names the capabilities so an agent can pick the right subcommand. */}}
-{{- if and .Narrative .Narrative.Headline}}
+{{- if .CLIDescription}}
+		Short: {{backtick}}{{goRawSafe (truncate 200 .CLIDescription)}}{{backtick}},
+{{- else if and .Narrative .Narrative.Headline}}
 		Short: {{backtick}}{{humanName .Name}} CLI — {{goRawSafe (truncate 120 .Narrative.Headline)}}{{backtick}},
 {{- else}}
 		Short: "Manage {{.Name}} resources via the {{.Name}} API",
 {{- end}}
 {{- if .TopNovelFeatures}}
-		Long: {{backtick}}{{humanName .Name}} CLI{{if and .Narrative .Narrative.Headline}} — {{goRawSafe (truncate 120 .Narrative.Headline)}}{{end}}
+		Long: {{backtick}}{{if .CLIDescription}}{{goRawSafe (truncate 200 .CLIDescription)}}{{else}}{{humanName .Name}} CLI{{if and .Narrative .Narrative.Headline}} — {{goRawSafe (truncate 120 .Narrative.Headline)}}{{end}}{{end}}
 
 Highlights (not in the official API docs):
 {{- range .TopNovelFeatures}}
@@ -84,6 +86,11 @@ Highlights (not in the official API docs):
 Agent mode: add --agent to any command for JSON output + non-interactive mode.
 Health check: run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.
 See README.md or the bundled SKILL.md for recipes.{{backtick}},
+{{- else if .CLIDescription}}
+		Long: {{backtick}}{{goRawSafe (truncate 200 .CLIDescription)}}
+
+Add --agent to any command for JSON output + non-interactive mode.
+Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 {{- else}}
 		Long: {{backtick}}Manage {{.Name}} resources via the {{.Name}} API.
 
diff --git a/internal/pipeline/live_check.go b/internal/pipeline/live_check.go
index cabcbdfb..4c044ad2 100644
--- a/internal/pipeline/live_check.go
+++ b/internal/pipeline/live_check.go
@@ -100,9 +100,15 @@ const outputSampleMaxBytes = 4096
 // LiveCheckOptions bundles the optional knobs for RunLiveCheck. CLIDir is
 // required; every other field has a sensible zero-value default.
 type LiveCheckOptions struct {
-	// CLIDir is the printed CLI's root (containing research.json and the
-	// built binary).
+	// CLIDir is the printed CLI's root (which holds the built binary).
 	CLIDir string
+	// ResearchDir, when non-empty, names a directory that holds research.json
+	// instead of CLIDir. Use this when invoking live-check from a working
+	// directory the run state owns (where research.json lives next to the
+	// run's manuscripts) and the printed CLI hasn't been promoted to its
+	// final library location. Leave blank to keep the historical behavior
+	// of looking for research.json under CLIDir.
+	ResearchDir string
 	// BinaryName, when non-empty, names the executable to run. Leave blank
 	// to let RunLiveCheck derive it from CLIDir (tries `<base>-pp-cli`,
 	// falls back to `<base>`).
@@ -129,7 +135,11 @@ func RunLiveCheck(opts LiveCheckOptions) *LiveCheckResult {
 		return out
 	}
 
-	research, err := LoadResearch(opts.CLIDir)
+	researchDir := opts.ResearchDir
+	if researchDir == "" {
+		researchDir = opts.CLIDir
+	}
+	research, err := LoadResearch(researchDir)
 	if err != nil {
 		out.Unable = true
 		out.Reason = "no research.json: " + err.Error()
@@ -530,8 +540,19 @@ func extractQueryToken(args []string) string {
 
 func commonCommandVerb(s string) bool {
 	switch strings.ToLower(strings.TrimSpace(s)) {
+	// CRUD / action verbs.
 	case "list", "get", "show", "fetch", "query", "search", "create", "update", "delete", "remove", "set", "run":
 		return true
+	// Time / state words used as command names rather than user-supplied
+	// queries. Commands like `slices today`, `store tonight`, `events now`
+	// are verb-shaped: the trailing word names the view, not a search term.
+	// Without this branch, the rendered output (which has no reason to
+	// echo "today" as text — it's structured data) would fail the
+	// outputMentionsQuery heuristic and produce a spurious live-check
+	// failure on otherwise-correct output.
+	case "today", "tonight", "now", "current", "latest", "recent", "upcoming",
+		"pending", "active", "expired", "stale", "all", "live", "open", "closed":
+		return true
 	default:
 		return false
 	}
diff --git a/internal/pipeline/live_check_test.go b/internal/pipeline/live_check_test.go
index d0724496..50c00907 100644
--- a/internal/pipeline/live_check_test.go
+++ b/internal/pipeline/live_check_test.go
@@ -49,6 +49,38 @@ func TestLiveCheck_UnableWhenNoResearch(t *testing.T) {
 	require.Zero(t, result.Checked())
 }
 
+// TestLiveCheck_ResearchDirOverride verifies that ResearchDir, when set,
+// is used to locate research.json instead of CLIDir. This matters when a
+// CLI's working dir and the run-state's research.json live in different
+// directories — without this option, callers had to copy research.json
+// into the CLI dir as a workaround.
+func TestLiveCheck_ResearchDirOverride(t *testing.T) {
+	cliDir := t.TempDir()      // no research.json here
+	researchDir := t.TempDir() // research.json lives here
+	writeStubBinary(t, cliDir, "bin", `exit 0`)
+	writeTestResearchJSON(t, researchDir, []NovelFeature{
+		{Name: "Feature A", Command: "foo", Description: "no example"},
+	})
+
+	// Default behavior: looks under CLIDir, finds nothing, reports Unable.
+	r1 := RunLiveCheck(LiveCheckOptions{CLIDir: cliDir, BinaryName: "bin", Timeout: time.Second})
+	require.True(t, r1.Unable)
+	require.Contains(t, r1.Reason, "research.json")
+
+	// With ResearchDir: looks at the override, finds research.json. Now the
+	// reason is "no Example commands" — different failure mode, proves we
+	// did locate research.json successfully.
+	r2 := RunLiveCheck(LiveCheckOptions{
+		CLIDir:      cliDir,
+		ResearchDir: researchDir,
+		BinaryName:  "bin",
+		Timeout:     time.Second,
+	})
+	require.True(t, r2.Unable)
+	require.Contains(t, r2.Reason, "Example", "after locating research.json, the next gate is the Example-command check")
+	require.NotContains(t, r2.Reason, "no research.json", "should have read research.json from ResearchDir")
+}
+
 // TestLiveCheck_UnableWhenNoExamples verifies the check skips when research
 // exists but no novel feature has an Example command.
 func TestLiveCheck_UnableWhenNoExamples(t *testing.T) {
@@ -253,6 +285,15 @@ func TestExtractQueryToken(t *testing.T) {
 		{[]string{"tonight", "--max-time", "30m"}, ""},
 		{[]string{"cookbook", "list", "--json"}, ""},
 		{[]string{"cookbook"}, ""},
+		// Verb-shaped command paths: trailing word names the view, not a
+		// search query. Without this, the relevance check would fail
+		// commands like `slices today --json` whose structured output has
+		// no reason to echo the verb.
+		{[]string{"slices", "today", "--json"}, ""},
+		{[]string{"store", "tonight", "--json"}, ""},
+		{[]string{"events", "now"}, ""},
+		{[]string{"orders", "pending", "--json"}, ""},
+		{[]string{"deals", "current"}, ""},
 	}
 	for _, tc := range cases {
 		got := extractQueryToken(tc.args)
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 3abf68a8..79e697dc 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -36,8 +36,19 @@ const (
 )
 
 type APISpec struct {
-	Name            string              `yaml:"name" json:"name"`
-	Description     string              `yaml:"description" json:"description"`
+	Name string `yaml:"name" json:"name"`
+	// Description describes the API itself ("REST API for ordering pizza").
+	// It flows into generated docs and SKILL.md but is intentionally NOT used
+	// as the printed CLI's --help text; that's CLIDescription's job.
+	Description string `yaml:"description" json:"description"`
+	// CLIDescription, when set, becomes the printed CLI's root cobra command
+	// `Short:` text. Spec authors should phrase it as what the CLI does
+	// ("Order Seattle pizza from the terminal"), not what the API is. When
+	// blank the generator falls back to the research narrative's headline,
+	// then to a generic "Manage <api> resources via the <api> API". Adding
+	// this field eliminates a recurring manual rewrite step that the main
+	// skill used to instruct Claude to perform after every generation.
+	CLIDescription  string              `yaml:"cli_description,omitempty" json:"cli_description,omitempty"`
 	Version         string              `yaml:"version" json:"version"`
 	BaseURL         string              `yaml:"base_url" json:"base_url"`
 	BasePath        string              `yaml:"base_path,omitempty" json:"base_path,omitempty"`
@@ -459,12 +470,171 @@ func ParseBytes(data []byte) (*APISpec, error) {
 		return nil, fmt.Errorf("parsing yaml: %w", yamlErr)
 	}
 	s.expandOperations()
+	s.enrichPathParams()
+	if err := s.validateReservedNames(); err != nil {
+		return nil, err
+	}
 	if err := s.Validate(); err != nil {
 		return nil, fmt.Errorf("validation: %w", err)
 	}
 	return &s, nil
 }
 
+// ReservedCLIResourceNames is the set of resource names that would collide with
+// reserved single-file templates emitted into the printed CLI's internal/cli/
+// directory. Two collisions occur if a spec uses one of these as a resource
+// name: the resource template's <name>.go overwrites the reserved file (losing
+// helpers like FeedbackEndpointConfigured() from feedback.go), AND the
+// resource's `new<Name>Cmd` cobra-builder shadows the reserved template's
+// same-named function, breaking the build with a redeclaration error.
+//
+// Renaming the file alone is not enough; the function-name collision still
+// breaks the build. Reject at parse time and ask the author to rename the
+// resource (e.g., `feedback` → `customer_feedback`, `auth` → `accounts`).
+//
+// The contract is intentionally stable: removing an entry is allowed only when
+// the corresponding reserved template is also removed from the generator.
+var ReservedCLIResourceNames = map[string]struct{}{
+	"agent_context":    {},
+	"api_discovery":    {},
+	"auth":             {},
+	"auto_refresh":     {},
+	"cache":            {},
+	"channel_workflow": {},
+	"client":           {},
+	"data_source":      {},
+	"deliver":          {},
+	"doctor":           {},
+	"export":           {},
+	"feedback":         {},
+	"helpers":          {},
+	"html_extract":     {},
+	"import":           {},
+	"profile":          {},
+	"root":             {},
+	"search":           {},
+	"share_commands":   {},
+	"sync":             {},
+	"tail":             {},
+	"types":            {},
+	"which":            {},
+	"workflow":         {},
+}
+
+// validateReservedNames rejects specs whose top-level resource names would
+// collide with reserved Printing Press templates. Sub-resource names are not
+// checked because they emit under a parent prefix (`<parent>_<sub>.go`,
+// `new<Parent><Sub>Cmd`) that does not collide with single-file templates.
+func (s *APISpec) validateReservedNames() error {
+	for name := range s.Resources {
+		if _, reserved := ReservedCLIResourceNames[name]; reserved {
+			return fmt.Errorf("resource name %q collides with a reserved Printing Press template (would overwrite internal/cli/%s.go and produce a duplicate `new%sCmd` function). Rename the resource — e.g. %q",
+				name, name, snakeToPascal(name), name+"_resource")
+		}
+	}
+	return nil
+}
+
+// snakeToPascal converts a snake_case identifier to PascalCase so error
+// messages name the same Go function the generator would emit. Mirrors
+// generator.toCamel for snake_case input — kept here so the spec package
+// has no import-cycle dependency on the generator. Empty input → empty
+// output.
+func snakeToPascal(s string) string {
+	if s == "" {
+		return s
+	}
+	parts := strings.Split(s, "_")
+	for i, p := range parts {
+		if p == "" {
+			continue
+		}
+		parts[i] = strings.ToUpper(p[:1]) + p[1:]
+	}
+	return strings.Join(parts, "")
+}
+
+// pathParamRe matches `{name}` placeholders in a path template. Names are
+// alphanumeric/underscore — the conservative subset every parser observed in
+// the wild uses. Anchoring on `{` and `}` keeps it from over-matching JSON
+// fragments accidentally embedded in path strings.
+var pathParamRe = regexp.MustCompile(`\{([A-Za-z_][A-Za-z0-9_]*)\}`)
+
+// enrichPathParams walks every resource and sub-resource endpoint and ensures
+// each `{paramName}` placeholder in the endpoint path is represented in
+// Endpoint.Params with Positional: true, Required: true. The expandOperations
+// path already populates these for shorthand-generated endpoints; explicit
+// `endpoints:` blocks in the YAML do not, so without this step the generator
+// emits a literal-placeholder URL with no positional-arg parsing — every
+// path-templated request returns 404 at runtime.
+//
+// Existing Params are never modified. If a placeholder name already appears
+// in Endpoint.Params or Endpoint.Body, the placeholder is left alone — the
+// author is presumed to have declared it intentionally (with their own type,
+// description, or default).
+//
+// Order is preserved: placeholders are appended in the order they appear in
+// the path so generated cobra `Args: cobra.ExactArgs(N)` sites and the
+// matching `replacePathParam(...args[i])` calls line up.
+func (s *APISpec) enrichPathParams() {
+	for resourceName, r := range s.Resources {
+		s.enrichResourcePathParams(&r)
+		s.Resources[resourceName] = r
+	}
+}
+
+func (s *APISpec) enrichResourcePathParams(r *Resource) {
+	if r.Endpoints != nil {
+		for endpointName, e := range r.Endpoints {
+			enrichEndpointPathParams(&e)
+			r.Endpoints[endpointName] = e
+		}
+	}
+	for subName, sub := range r.SubResources {
+		s.enrichResourcePathParams(&sub)
+		r.SubResources[subName] = sub
+	}
+}
+
+func enrichEndpointPathParams(e *Endpoint) {
+	if e.Path == "" {
+		return
+	}
+	matches := pathParamRe.FindAllStringSubmatch(e.Path, -1)
+	if len(matches) == 0 {
+		return
+	}
+	// Build a set of names already declared so we never duplicate or overwrite
+	// an author-provided Param/Body entry.
+	declared := make(map[string]struct{}, len(e.Params)+len(e.Body))
+	for _, p := range e.Params {
+		declared[p.Name] = struct{}{}
+	}
+	for _, p := range e.Body {
+		declared[p.Name] = struct{}{}
+	}
+	// Track which placeholders we've already appended in this pass so a
+	// repeated placeholder (rare but valid) doesn't add the param twice.
+	seen := make(map[string]struct{}, len(matches))
+	for _, m := range matches {
+		name := m[1]
+		if _, dup := seen[name]; dup {
+			continue
+		}
+		seen[name] = struct{}{}
+		if _, exists := declared[name]; exists {
+			continue
+		}
+		e.Params = append(e.Params, Param{
+			Name:        name,
+			Type:        "string",
+			Required:    true,
+			Positional:  true,
+			Description: name,
+		})
+	}
+}
+
 // expandOperations converts operations shorthand (e.g., [list, get, create])
 // into explicit Endpoint entries for each resource that has Operations set.
 // Explicit endpoints take precedence over generated ones.
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 7edef950..66d53484 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -1441,3 +1441,394 @@ func TestHTMLResponseExtractionValidation(t *testing.T) {
 	badMethod.Resources["posts"].Endpoints["list"] = ep
 	require.ErrorContains(t, badMethod.Validate(), "html response_format is only supported")
 }
+
+func TestEnrichPathParams(t *testing.T) {
+	t.Parallel()
+
+	t.Run("explicit endpoint with placeholders gets positional Params auto-added", func(t *testing.T) {
+		t.Parallel()
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  customer:
+    description: Customer endpoints
+    endpoints:
+      get:
+        method: GET
+        path: /Customer/{customerId}
+        description: Get customer by ID
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		ep := s.Resources["customer"].Endpoints["get"]
+		require.Len(t, ep.Params, 1)
+		assert.Equal(t, "customerId", ep.Params[0].Name)
+		assert.True(t, ep.Params[0].Positional)
+		assert.True(t, ep.Params[0].Required)
+		assert.Equal(t, "string", ep.Params[0].Type)
+	})
+
+	t.Run("multiple placeholders preserve path order", func(t *testing.T) {
+		t.Parallel()
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  scheduling:
+    description: Time windows
+    endpoints:
+      slots_for_date:
+        method: GET
+        path: /TimeWindows/{storeId}/{serviceType}/{date}
+        description: Available slots for a date
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		ep := s.Resources["scheduling"].Endpoints["slots_for_date"]
+		require.Len(t, ep.Params, 3)
+		assert.Equal(t, "storeId", ep.Params[0].Name)
+		assert.Equal(t, "serviceType", ep.Params[1].Name)
+		assert.Equal(t, "date", ep.Params[2].Name)
+		for _, p := range ep.Params {
+			assert.True(t, p.Positional, "param %q should be Positional", p.Name)
+			assert.True(t, p.Required, "param %q should be Required", p.Name)
+		}
+	})
+
+	t.Run("author-declared params are not duplicated or overwritten", func(t *testing.T) {
+		t.Parallel()
+		// `customerId` is declared with a custom description and integer type;
+		// enrichment must leave it alone.
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  customer:
+    description: Customer endpoints
+    endpoints:
+      get:
+        method: GET
+        path: /Customer/{customerId}
+        description: Get customer
+        params:
+          - name: customerId
+            type: integer
+            required: true
+            positional: true
+            description: Numeric customer ID
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		ep := s.Resources["customer"].Endpoints["get"]
+		require.Len(t, ep.Params, 1, "should not duplicate the declared param")
+		assert.Equal(t, "customerId", ep.Params[0].Name)
+		assert.Equal(t, "integer", ep.Params[0].Type, "author's type must be preserved")
+		assert.Equal(t, "Numeric customer ID", ep.Params[0].Description, "author's description must be preserved")
+	})
+
+	t.Run("endpoint with no placeholders is unchanged", func(t *testing.T) {
+		t.Parallel()
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  store:
+    description: Stores
+    endpoints:
+      list:
+        method: GET
+        path: /Store
+        description: List stores
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		ep := s.Resources["store"].Endpoints["list"]
+		assert.Empty(t, ep.Params, "no placeholders should mean no Params added")
+	})
+
+	t.Run("operations shorthand still works (regression)", func(t *testing.T) {
+		t.Parallel()
+		// The shorthand path already populated Params correctly before this
+		// change; confirm enrichment doesn't break it.
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  items:
+    description: Items
+    path: /api/items
+    operations: [list, get, create, update, delete]
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		getEp := s.Resources["items"].Endpoints["get"]
+		require.Len(t, getEp.Params, 1)
+		assert.Equal(t, "itemId", getEp.Params[0].Name)
+		assert.True(t, getEp.Params[0].Positional)
+	})
+
+	t.Run("repeated placeholder in same path is added once", func(t *testing.T) {
+		t.Parallel()
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  pair:
+    description: Pair
+    endpoints:
+      twin:
+        method: GET
+        path: /Pair/{id}/twin/{id}
+        description: Twin by ID
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		ep := s.Resources["pair"].Endpoints["twin"]
+		require.Len(t, ep.Params, 1, "repeated placeholder should produce one Param")
+		assert.Equal(t, "id", ep.Params[0].Name)
+	})
+
+	t.Run("sub-resource endpoints are also enriched", func(t *testing.T) {
+		t.Parallel()
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  store:
+    description: Stores
+    sub_resources:
+      menu:
+        description: Per-store menus
+        endpoints:
+          get:
+            method: GET
+            path: /Store/{storeId}/Menu/{menuId}
+            description: Get menu by store and ID
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		ep := s.Resources["store"].SubResources["menu"].Endpoints["get"]
+		require.Len(t, ep.Params, 2)
+		assert.Equal(t, "storeId", ep.Params[0].Name)
+		assert.Equal(t, "menuId", ep.Params[1].Name)
+	})
+}
+
+func TestValidateReservedNames(t *testing.T) {
+	t.Parallel()
+
+	t.Run("reserved resource name is rejected with a clear rename hint", func(t *testing.T) {
+		t.Parallel()
+		// `feedback` collides with the reserved feedback.go template that
+		// declares the in-band agent feedback channel. Two collisions: file
+		// overwrite and `newFeedbackCmd` redeclaration.
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  feedback:
+    description: Customer feedback
+    endpoints:
+      submit:
+        method: POST
+        path: /feedback
+        description: Submit feedback
+`
+		_, err := ParseBytes([]byte(input))
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), `"feedback"`)
+		assert.Contains(t, err.Error(), "reserved Printing Press template")
+		assert.Contains(t, err.Error(), "Rename")
+		assert.Contains(t, err.Error(), "newFeedbackCmd", "error names the actual generated function")
+		assert.Contains(t, err.Error(), `"feedback_resource"`, "error suggests a concrete rename")
+	})
+
+	t.Run("multi-word reserved name produces correct PascalCase function name", func(t *testing.T) {
+		t.Parallel()
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  agent_context:
+    description: Should be rejected
+    endpoints:
+      list:
+        method: GET
+        path: /agent_context
+        description: list
+`
+		_, err := ParseBytes([]byte(input))
+		require.Error(t, err)
+		// The error must name the actual generated function — newAgentContextCmd —
+		// not newAgent_contextCmd. The previous capitalize-first variant lied
+		// about the function name, which would confuse users debugging the
+		// collision.
+		assert.Contains(t, err.Error(), "newAgentContextCmd")
+		assert.NotContains(t, err.Error(), "newAgent_contextCmd")
+	})
+
+	t.Run("auth resource name rejected", func(t *testing.T) {
+		t.Parallel()
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  auth:
+    description: Auth surface
+    endpoints:
+      list:
+        method: GET
+        path: /auth
+        description: list
+`
+		_, err := ParseBytes([]byte(input))
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), `"auth"`)
+	})
+
+	t.Run("non-reserved name with reserved-substring is allowed", func(t *testing.T) {
+		t.Parallel()
+		// "customer_feedback" contains "feedback" but is not itself reserved;
+		// we only reject exact matches because file emit is by exact name.
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  customer_feedback:
+    description: Customer feedback (renamed)
+    endpoints:
+      submit:
+        method: POST
+        path: /feedback
+        description: Submit feedback
+`
+		_, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+	})
+
+	t.Run("sub-resources are NOT subject to the reserved-name check", func(t *testing.T) {
+		t.Parallel()
+		// Sub-resources emit under <parent>_<sub>.go and produce
+		// new<Parent><Sub>Cmd identifiers, so they cannot collide with the
+		// single-file templates regardless of name.
+		input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  customer:
+    description: Customer
+    sub_resources:
+      feedback:
+        description: Customer-feedback sub-resource
+        endpoints:
+          submit:
+            method: POST
+            path: /customer/feedback
+            description: Submit
+`
+		_, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+	})
+
+	t.Run("known clobbers are all in the set", func(t *testing.T) {
+		t.Parallel()
+		// Pin a baseline. Removing any of these from ReservedCLIResourceNames
+		// without first removing the corresponding generator template is a
+		// regression that will reintroduce silent overwrites.
+		mustReserve := []string{"feedback", "doctor", "auth", "helpers", "agent_context", "profile", "deliver", "which", "sync", "tail", "search", "client", "cache", "export", "import"}
+		for _, name := range mustReserve {
+			_, ok := ReservedCLIResourceNames[name]
+			assert.True(t, ok, "%q must remain in ReservedCLIResourceNames — losing it would reintroduce silent template overwrites", name)
+		}
+	})
+}
+func TestCLIDescriptionParses(t *testing.T) {
+	t.Parallel()
+	input := `name: testapi
+base_url: https://api.example.com
+cli_description: "Manage testapi resources from the terminal"
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  store:
+    description: Stores
+    endpoints:
+      list:
+        method: GET
+        path: /stores
+        description: List stores
+`
+	s, err := ParseBytes([]byte(input))
+	require.NoError(t, err)
+	assert.Equal(t, "Manage testapi resources from the terminal", s.CLIDescription)
+}
+
+func TestCLIDescriptionAbsent(t *testing.T) {
+	t.Parallel()
+	input := `name: testapi
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars: [TESTAPI_TOKEN]
+resources:
+  store:
+    description: Stores
+    endpoints:
+      list:
+        method: GET
+        path: /stores
+        description: List stores
+`
+	s, err := ParseBytes([]byte(input))
+	require.NoError(t, err)
+	assert.Empty(t, s.CLIDescription, "field should be empty when not declared")
+}
+
+func TestSnakeToPascal(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		input, expected string
+	}{
+		{"feedback", "Feedback"},
+		{"agent_context", "AgentContext"},
+		{"customer_feedback", "CustomerFeedback"},
+		{"a_b_c", "ABC"},
+		{"already_PascalCase", "AlreadyPascalCase"},
+		{"", ""},
+		{"_leading", "Leading"},
+		{"trailing_", "Trailing"},
+		{"single", "Single"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			t.Parallel()
+			assert.Equal(t, tt.expected, snakeToPascal(tt.input))
+		})
+	}
+}
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 232aa6a1..eceedc32 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1473,12 +1473,15 @@ GraphQL-only APIs:
 
 After generation:
 
-**REQUIRED: Rewrite the CLI description.** The generator copies the spec's `description` field
-as the CLI's `Short` help text. Spec descriptions describe the *API* ("Payment processing API")
-but CLI help should describe what the *CLI does* ("Manage payments, subscriptions, and invoices
-via the Stripe API"). Open `$CLI_WORK_DIR/internal/cli/root.go`, find the
-`Short:` field on the root cobra command, and rewrite it as a concise, user-facing description
-of the CLI's purpose. Use the product thesis from the Phase 1 brief to inform the rewrite.
+**Verify the CLI description.** The root cobra command's `Short:` text comes from
+the spec's `cli_description` field when set, then falls back to `narrative.headline`
+in `research.json`, then to a generic `"Manage <api> resources via the <api> API"`.
+The first two phrasings describe what the *CLI does* ("Manage payments, subscriptions,
+and invoices via the Stripe API"); the generic fallback often does too but reads as
+template filler. Open `$CLI_WORK_DIR/internal/cli/root.go`, find the `Short:` field,
+and confirm it reads as user-facing CLI purpose rather than API description. If it
+needs a rewrite, prefer adding a `cli_description:` line to the spec and regenerating
+over hand-editing the generated file.
 
 **REQUIRED: Preserve README sections.** The generated README contains 5 standard sections
 that the scorecard checks for: Quick Start, Agent Usage, Health Check, Troubleshooting, and

← 0d4d2cba docs(cli): refresh README for launch (Apr 28) (#306)  ·  back to Cli Printing Press  ·  chore(main): release 2.3.8 (#304) 42721ebc →