[object Object]

← 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

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 →