← back to Cli Printing Press
fix(cli): kebab-case SKILL.md and README endpoint examples (#1445)
bd4dc64175305b61e6cb4278bba3def0e16d68b4 · 2026-05-15 11:22:22 -0700 · Trevin Chow
* fix(cli): kebab-case SKILL.md and README endpoint examples
SKILL.md and README "Command Reference" lines emitted the raw endpoint
key from the spec, so a spec with `dns.get_hosts` advertised
`<cli> dns get_hosts` while the actual Cobra subcommand was
`dns get-hosts` (the `command_endpoint.go.tmpl` `Use:` field already
routes through `{{kebab .EndpointName}}`). verify-skill rejected those
docs as phantom command paths and the polish skill batch-fixed them
post-hoc on every CLI with multi-word operation names.
Route the SKILL.md and README per-endpoint emission through the same
`kebab` template helper and kebab-case the endpoint name in
`firstCommandExample` so the first-command example block stays in sync
with the cobra command tree. Single-word endpoint keys pass through
unchanged, so existing CLIs do not drift on regen.
Closes #1270
* fix(cli): apply README Commands promotion guard
The skill.md.tmpl "Command Reference" loop checks
PromotedResourceNames and renders just the resource name when a
single-endpoint resource is promoted to a top-level cobra command;
the readme.md.tmpl "Commands" loop did not, so a spec like
{currencies: {list: ...}} produced `<cli> currencies list` in
README while the binary only exposed `<cli> currencies`.
Mirror the guard so README and SKILL.md agree on the leaf form for
promoted resources. Extend the integration test to cover this case,
fix the now-correct README expectation in
TestOutputFormatsUsesRealCommandExample, and accept the golden README
diffs for generate-golden-api and generate-golden-api-rich-auth where
single-op resources (currencies/list, public/get-status, items/list)
are promoted.
Refs #1270
Files touched
M internal/generator/first_command_example.goM internal/generator/first_command_example_test.goM internal/generator/readme_test.goA internal/generator/skill_endpoint_kebab_test.goM internal/generator/templates/readme.md.tmplM internal/generator/templates/skill.md.tmplM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.mdM testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
Diff
commit bd4dc64175305b61e6cb4278bba3def0e16d68b4
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 15 11:22:22 2026 -0700
fix(cli): kebab-case SKILL.md and README endpoint examples (#1445)
* fix(cli): kebab-case SKILL.md and README endpoint examples
SKILL.md and README "Command Reference" lines emitted the raw endpoint
key from the spec, so a spec with `dns.get_hosts` advertised
`<cli> dns get_hosts` while the actual Cobra subcommand was
`dns get-hosts` (the `command_endpoint.go.tmpl` `Use:` field already
routes through `{{kebab .EndpointName}}`). verify-skill rejected those
docs as phantom command paths and the polish skill batch-fixed them
post-hoc on every CLI with multi-word operation names.
Route the SKILL.md and README per-endpoint emission through the same
`kebab` template helper and kebab-case the endpoint name in
`firstCommandExample` so the first-command example block stays in sync
with the cobra command tree. Single-word endpoint keys pass through
unchanged, so existing CLIs do not drift on regen.
Closes #1270
* fix(cli): apply README Commands promotion guard
The skill.md.tmpl "Command Reference" loop checks
PromotedResourceNames and renders just the resource name when a
single-endpoint resource is promoted to a top-level cobra command;
the readme.md.tmpl "Commands" loop did not, so a spec like
{currencies: {list: ...}} produced `<cli> currencies list` in
README while the binary only exposed `<cli> currencies`.
Mirror the guard so README and SKILL.md agree on the leaf form for
promoted resources. Extend the integration test to cover this case,
fix the now-correct README expectation in
TestOutputFormatsUsesRealCommandExample, and accept the golden README
diffs for generate-golden-api and generate-golden-api-rich-auth where
single-op resources (currencies/list, public/get-status, items/list)
are promoted.
Refs #1270
---
internal/generator/first_command_example.go | 2 +-
internal/generator/first_command_example_test.go | 32 +++++
internal/generator/readme_test.go | 11 +-
internal/generator/skill_endpoint_kebab_test.go | 145 +++++++++++++++++++++
internal/generator/templates/readme.md.tmpl | 6 +-
internal/generator/templates/skill.md.tmpl | 2 +-
.../printing-press-rich-auth/README.md | 2 +-
.../printing-press-golden/README.md | 4 +-
8 files changed, 196 insertions(+), 8 deletions(-)
diff --git a/internal/generator/first_command_example.go b/internal/generator/first_command_example.go
index d6b758c4..3fc0e5ea 100644
--- a/internal/generator/first_command_example.go
+++ b/internal/generator/first_command_example.go
@@ -42,7 +42,7 @@ func firstCommandExample(resources map[string]spec.Resource) string {
pathFor := func(rName string, r spec.Resource, eName string, ep spec.Endpoint) string {
parts := []string{rName}
if !isPromotableSingleEndpoint(rName, r) {
- parts = append(parts, eName)
+ parts = append(parts, toKebab(eName))
}
parts = append(parts, readmeExampleArgs(ep)...)
return strings.Join(parts, " ")
diff --git a/internal/generator/first_command_example_test.go b/internal/generator/first_command_example_test.go
index f924ff61..098a91f3 100644
--- a/internal/generator/first_command_example_test.go
+++ b/internal/generator/first_command_example_test.go
@@ -280,6 +280,38 @@ func TestFirstCommandExampleHonorsPromotion(t *testing.T) {
},
want: "feed 2026-04-27",
},
+ {
+ // Issue #1270: snake_case endpoint keys must be kebab-cased so
+ // the example matches the actual cobra `Use:` string (which is
+ // already kebab) instead of advertising a phantom command path.
+ name: "multi-word snake_case endpoint key is kebab-cased",
+ resources: map[string]spec.Resource{
+ "dns": {
+ Endpoints: map[string]spec.Endpoint{
+ "get_hosts": {Method: "GET", Path: "/dns/hosts"},
+ "set_email_forwarding": {Method: "POST", Path: "/dns/forwarding"},
+ },
+ },
+ },
+ want: "dns get-hosts",
+ },
+ {
+ // Same kebab pass for camelCase endpoint keys. Mirrors the
+ // command_endpoint.go.tmpl `Use: {{kebab .EndpointName}}` rule
+ // for cobra command names. The preferred-verb scan misses
+ // `createSpeech` and `cancelJob`, so the fallback alphabetical
+ // pick is `cancelJob` (c < other letters).
+ name: "multi-word camelCase endpoint key is kebab-cased",
+ resources: map[string]spec.Resource{
+ "audio": {
+ Endpoints: map[string]spec.Endpoint{
+ "createSpeech": {Method: "POST", Path: "/audio/speech"},
+ "cancelJob": {Method: "POST", Path: "/audio/cancel"},
+ },
+ },
+ },
+ want: "audio cancel-job",
+ },
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
diff --git a/internal/generator/readme_test.go b/internal/generator/readme_test.go
index 11a40cf9..fd065be8 100644
--- a/internal/generator/readme_test.go
+++ b/internal/generator/readme_test.go
@@ -588,8 +588,15 @@ func TestOutputFormatsUsesRealCommandExample(t *testing.T) {
require.NoError(t, err)
content := string(readme)
- assert.True(t, strings.Contains(content, "realexample-pp-cli autocomplete get"),
- "Output Formats should reference a real resource+endpoint pair from the spec")
+ // A single-endpoint non-builtin resource is promoted to a top-level
+ // cobra command, so the actual command path is just the resource name
+ // (no endpoint token). The README must advertise that promoted path
+ // instead of the pre-promotion `autocomplete get` form, which would
+ // otherwise be a phantom command.
+ assert.True(t, strings.Contains(content, "realexample-pp-cli autocomplete"),
+ "Output Formats should reference the real promoted command path from the spec")
+ assert.False(t, strings.Contains(content, "realexample-pp-cli autocomplete get"),
+ "Output Formats should not advertise the pre-promotion path; cobra promotes single-op resources to a leaf")
assert.False(t, strings.Contains(content, "realexample-pp-cli autocomplete list"),
"Output Formats should not hallucinate a 'list' endpoint that doesn't exist in the spec")
}
diff --git a/internal/generator/skill_endpoint_kebab_test.go b/internal/generator/skill_endpoint_kebab_test.go
new file mode 100644
index 00000000..1dcbb172
--- /dev/null
+++ b/internal/generator/skill_endpoint_kebab_test.go
@@ -0,0 +1,145 @@
+// Copyright 2026 trevin-chow. Licensed under Apache-2.0. See LICENSE.
+
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestSkillAndReadmeKebabCaseMultiWordEndpoints covers issue #1270. The
+// generator emits the actual cobra command name via `{{kebab .EndpointName}}`,
+// so snake_case or camelCase spec keys ship as kebab-case subcommands. The
+// SKILL.md / README.md "Command Reference" sections must show the same kebab
+// form -- otherwise verify-skill rejects the example as an unknown command and
+// agents follow a phantom path.
+func TestSkillAndReadmeKebabCaseMultiWordEndpoints(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("namecheck")
+ // Multi-endpoint resource so promotion to a leaf does not fire and the
+ // per-endpoint emission path is exercised.
+ apiSpec.Resources["dns"] = spec.Resource{
+ Description: "Manage DNS records",
+ Endpoints: map[string]spec.Endpoint{
+ "get_hosts": {Method: "GET", Path: "/dns/hosts", Description: "Get DNS host records"},
+ "set_email_forwarding": {Method: "POST", Path: "/dns/forwarding", Description: "Configure email forwarding"},
+ },
+ }
+ // camelCase shape -- mirrors what an OpenAPI spec with operationId
+ // "getEmailForwarding" lands as.
+ apiSpec.Resources["audio"] = spec.Resource{
+ Description: "Audio operations",
+ Endpoints: map[string]spec.Endpoint{
+ "createSpeech": {Method: "POST", Path: "/audio/speech", Description: "Synthesize speech"},
+ "cancelJob": {Method: "POST", Path: "/audio/cancel", Description: "Cancel a job"},
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "namecheck-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ for _, file := range []string{"SKILL.md", "README.md"} {
+ t.Run(file, func(t *testing.T) {
+ body, err := os.ReadFile(filepath.Join(outputDir, file))
+ require.NoError(t, err)
+ content := string(body)
+
+ assert.Contains(t, content, "namecheck-pp-cli dns get-hosts",
+ "snake_case spec key get_hosts must render as kebab subcommand get-hosts")
+ assert.Contains(t, content, "namecheck-pp-cli dns set-email-forwarding",
+ "snake_case spec key set_email_forwarding must render as kebab set-email-forwarding")
+ assert.Contains(t, content, "namecheck-pp-cli audio create-speech",
+ "camelCase spec key createSpeech must render as kebab create-speech")
+ assert.Contains(t, content, "namecheck-pp-cli audio cancel-job",
+ "camelCase spec key cancelJob must render as kebab cancel-job")
+
+ assert.NotContains(t, content, "namecheck-pp-cli dns get_hosts",
+ "snake_case form must not leak into user-facing docs; cobra command is get-hosts")
+ assert.NotContains(t, content, "namecheck-pp-cli dns set_email_forwarding",
+ "snake_case form must not leak into user-facing docs; cobra command is set-email-forwarding")
+ assert.NotContains(t, content, "namecheck-pp-cli audio createSpeech",
+ "camelCase form must not leak into user-facing docs; cobra command is create-speech")
+ assert.NotContains(t, content, "namecheck-pp-cli audio cancelJob",
+ "camelCase form must not leak into user-facing docs; cobra command is cancel-job")
+ })
+ }
+}
+
+// TestSkillAndReadmeSingleWordEndpointsUnchanged guards the negative case in
+// issue #1270's acceptance: single-word endpoint keys (list, create, get,
+// check) must pass through the kebab helper unchanged so existing CLIs do not
+// drift on regen.
+func TestSkillAndReadmeSingleWordEndpointsUnchanged(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("singleword")
+ apiSpec.Resources["widgets"] = spec.Resource{
+ Description: "Manage widgets",
+ Endpoints: map[string]spec.Endpoint{
+ "create": {Method: "POST", Path: "/widgets", Description: "Create a widget"},
+ "list": {Method: "GET", Path: "/widgets", Description: "List widgets"},
+ "get": {Method: "GET", Path: "/widgets/{id}", Description: "Get a widget"},
+ "check": {Method: "POST", Path: "/widgets/check", Description: "Run a check"},
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "singleword-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ for _, file := range []string{"SKILL.md", "README.md"} {
+ t.Run(file, func(t *testing.T) {
+ body, err := os.ReadFile(filepath.Join(outputDir, file))
+ require.NoError(t, err)
+ content := string(body)
+
+ for _, ep := range []string{"create", "list", "get", "check"} {
+ assert.Contains(t, content, "singleword-pp-cli widgets "+ep,
+ "single-word endpoint %q should be unchanged in docs", ep)
+ }
+ })
+ }
+}
+
+// TestReadmePromotionGuardMirrorsSkill asserts that the README "Commands"
+// section honors PromotedResourceNames the same way SKILL.md's "Command
+// Reference" does. A single-endpoint resource that cobra promotes to a
+// top-level command must not advertise the pre-promotion `<cli> resource
+// endpoint` path in the README -- the binary only knows `<cli> resource`.
+func TestReadmePromotionGuardMirrorsSkill(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("promo")
+ apiSpec.Resources["qr"] = spec.Resource{
+ Description: "Generate QR codes",
+ Endpoints: map[string]spec.Endpoint{
+ "get_qrcode": {Method: "GET", Path: "/qr", Description: "Retrieve a QR code"},
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "promo-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ for _, file := range []string{"SKILL.md", "README.md"} {
+ t.Run(file, func(t *testing.T) {
+ body, err := os.ReadFile(filepath.Join(outputDir, file))
+ require.NoError(t, err)
+ content := string(body)
+
+ assert.Contains(t, content, "promo-pp-cli qr`",
+ "promoted single-op resource should advertise the leaf form")
+ assert.NotContains(t, content, "promo-pp-cli qr get_qrcode",
+ "phantom snake_case path must not appear in docs")
+ assert.NotContains(t, content, "promo-pp-cli qr get-qrcode",
+ "phantom kebab path must not appear in docs for promoted resource")
+ })
+ }
+}
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index c21c5e10..2b8b5edb 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -253,7 +253,11 @@ Run `{{.Name}}-pp-cli --help` for the full command reference and flag list.
{{$resource.Description}}
{{range $eName, $endpoint := $resource.Endpoints}}
-- **`{{$.Name}}-pp-cli {{$name}} {{$eName}}`** - {{$endpoint.Description}}
+{{- if index $.PromotedResourceNames $name}}
+- **`{{$.Name}}-pp-cli {{$name}}{{positionalArgs $endpoint}}`** - {{$endpoint.Description}}
+{{- else}}
+- **`{{$.Name}}-pp-cli {{$name}} {{kebab $eName}}`** - {{$endpoint.Description}}
+{{- end}}
{{- end}}
{{end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index 38553899..cd03910b 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -140,7 +140,7 @@ This CLI was generated with browser-observed traffic context.
{{- if index $.PromotedResourceNames $name}}
- `{{$.Name}}-pp-cli {{$name}}{{positionalArgs $endpoint}}` — {{oneline $endpoint.Description}}
{{- else}}
-- `{{$.Name}}-pp-cli {{$name}} {{$eName}}` — {{oneline $endpoint.Description}}
+- `{{$.Name}}-pp-cli {{$name}} {{kebab $eName}}` — {{oneline $endpoint.Description}}
{{- end}}
{{- end}}
{{end}}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md
index 0ddca815..97623b75 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/README.md
@@ -90,7 +90,7 @@ Run `printing-press-rich-pp-cli --help` for the full command reference and flag
Manage items
-- **`printing-press-rich-pp-cli items list`** - List items
+- **`printing-press-rich-pp-cli items`** - List items
## Output Formats
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
index 982dea47..9769d117 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
@@ -90,7 +90,7 @@ Run `printing-press-golden-pp-cli --help` for the full command reference and fla
Manage currencies
-- **`printing-press-golden-pp-cli currencies list`** - List supported currencies
+- **`printing-press-golden-pp-cli currencies`** - List supported currencies
### projects
@@ -104,7 +104,7 @@ Manage projects
Manage public
-- **`printing-press-golden-pp-cli public get-status`** - Get public service status
+- **`printing-press-golden-pp-cli public`** - Get public service status
### reports
← 5a04aca6 fix(skills): correct install-internal-skills.sh path to repo
·
back to Cli Printing Press
·
chore(ci): add CODEOWNERS for workflows and CODEOWNERS itsel 9d70aee4 →