← 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
M internal/cli/scorecard.goM internal/generator/generator.goM internal/generator/generator_test.goA internal/generator/safe_filename_test.goM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_parent.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/config.go.tmplM internal/generator/templates/helpers.go.tmplM internal/generator/templates/root.go.tmplM internal/pipeline/live_check.goM internal/pipeline/live_check_test.goM internal/spec/spec.goM internal/spec/spec_test.goM skills/printing-press/SKILL.md
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 →