[object Object]

← back to Cli Printing Press

feat(cli): support extra_commands: in spec.yaml for hand-written commands (#227)

84043f3a72fbf2f20dd028a6793c0e0f96db5b0f · 2026-04-19 15:05:31 -0700 · Matt Van Horn

The SKILL.md template's `## Command Reference` section iterated only
.Resources, so hand-written commands like today, streak, rivals, watch
never appeared in regenerated SKILL.md. Authors worked around this by
hand-editing SKILL.md after generation, which silently invalidated on
the next regen and caused the drift class fixed in
mvanhorn/printing-press-library#83.

This commit adds a top-level `extra_commands:` array to APISpec so
authors can declare hand-written commands once and have them rendered
into SKILL.md alongside spec-driven resources. The generator emits no
code for these entries; they are purely SKILL.md metadata.

Each ExtraCommand has Name (required, lowercase command path of one to
three space-separated segments), Description (required, one-line), and
Args (optional positional-arg signature like "<event_id>" or
"<team1> <team2>").

Validation rejects:
- empty Name or Description
- uppercase / underscore / invalid characters in Name
- duplicate Name within the same spec
- more than three space-separated segments

When ExtraCommands is absent the rendered SKILL.md is byte-identical
to today's output (backwards compatible). When populated, a new
"Hand-written commands" subsection appears at the end of
## Command Reference with one bullet per entry.

Round-trip tests cover: parse, validate (4 reject cases + valid shapes),
YAML marshal/unmarshal, and end-to-end skill template rendering both
with and without extras.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 84043f3a72fbf2f20dd028a6793c0e0f96db5b0f
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date:   Sun Apr 19 15:05:31 2026 -0700

    feat(cli): support extra_commands: in spec.yaml for hand-written commands (#227)
    
    The SKILL.md template's `## Command Reference` section iterated only
    .Resources, so hand-written commands like today, streak, rivals, watch
    never appeared in regenerated SKILL.md. Authors worked around this by
    hand-editing SKILL.md after generation, which silently invalidated on
    the next regen and caused the drift class fixed in
    mvanhorn/printing-press-library#83.
    
    This commit adds a top-level `extra_commands:` array to APISpec so
    authors can declare hand-written commands once and have them rendered
    into SKILL.md alongside spec-driven resources. The generator emits no
    code for these entries; they are purely SKILL.md metadata.
    
    Each ExtraCommand has Name (required, lowercase command path of one to
    three space-separated segments), Description (required, one-line), and
    Args (optional positional-arg signature like "<event_id>" or
    "<team1> <team2>").
    
    Validation rejects:
    - empty Name or Description
    - uppercase / underscore / invalid characters in Name
    - duplicate Name within the same spec
    - more than three space-separated segments
    
    When ExtraCommands is absent the rendered SKILL.md is byte-identical
    to today's output (backwards compatible). When populated, a new
    "Hand-written commands" subsection appears at the end of
    ## Command Reference with one bullet per entry.
    
    Round-trip tests cover: parse, validate (4 reject cases + valid shapes),
    YAML marshal/unmarshal, and end-to-end skill template rendering both
    with and without extras.
    
    Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/generator/skill_test.go           |  56 +++++++++++
 internal/generator/templates/skill.md.tmpl |   7 ++
 internal/spec/spec.go                      |  45 +++++++++
 internal/spec/spec_test.go                 | 153 +++++++++++++++++++++++++++++
 4 files changed, 261 insertions(+)

diff --git a/internal/generator/skill_test.go b/internal/generator/skill_test.go
index 9562bae6..1821522e 100644
--- a/internal/generator/skill_test.go
+++ b/internal/generator/skill_test.go
@@ -261,3 +261,59 @@ func TestSkillRendersAuthBranchPerType(t *testing.T) {
 		})
 	}
 }
+
+// TestSkillRendersExtraCommands asserts that hand-written commands declared
+// in spec.ExtraCommands appear in the generated SKILL.md Command Reference,
+// after the spec-driven resources, with binary prefix and optional args.
+// This closes the drift class where SKILL.md silently omitted hand-written
+// commands like `today`, `streak`, `rivals` because the template only iterated
+// .Resources.
+func TestSkillRendersExtraCommands(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("sports")
+	apiSpec.ExtraCommands = []spec.ExtraCommand{
+		{Name: "trending", Description: "Most-followed athletes and teams across all leagues"},
+		{Name: "boxscore", Description: "Full box score for an event", Args: "<event_id>"},
+		{Name: "h2h", Description: "Head-to-head detail between two teams", Args: "<team1> <team2>"},
+	}
+	outputDir := filepath.Join(t.TempDir(), "sports-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	content := string(skill)
+
+	assert.Contains(t, content, "**Hand-written commands**",
+		"Command Reference should include a Hand-written commands subsection when ExtraCommands present")
+	assert.Contains(t, content, "`sports-pp-cli trending`",
+		"extra command without args should render as just binary + name")
+	assert.Contains(t, content, "`sports-pp-cli boxscore <event_id>`",
+		"extra command with args should render args after the name")
+	assert.Contains(t, content, "Most-followed athletes and teams across all leagues",
+		"extra command description should appear in the rendered output")
+	assert.Contains(t, content, "`sports-pp-cli h2h <team1> <team2>`",
+		"extra command with multi-arg signature should render verbatim")
+}
+
+// TestSkillNoExtraCommandsIsBackwardCompatible asserts the template emits
+// no Hand-written commands subsection when ExtraCommands is absent. This
+// preserves the rendering of every existing CLI that has no extra_commands
+// declaration in its spec.yaml.
+func TestSkillNoExtraCommandsIsBackwardCompatible(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("plain")
+	require.Empty(t, apiSpec.ExtraCommands)
+	outputDir := filepath.Join(t.TempDir(), "plain-pp-cli")
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+	require.NoError(t, err)
+	content := string(skill)
+
+	assert.NotContains(t, content, "**Hand-written commands**",
+		"Hand-written commands subsection should not appear when ExtraCommands is absent")
+}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index 298612d4..4db2a123 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -68,6 +68,13 @@ These capabilities aren't available in any other tool for this API.
 - `{{$.Name}}-pp-cli {{$name}} {{$eName}}` — {{oneline $endpoint.Description}}
 {{- end}}
 {{end}}
+{{- if .ExtraCommands}}
+
+**Hand-written commands**
+{{range .ExtraCommands}}
+- `{{$.Name}}-pp-cli {{.Name}}{{if .Args}} {{.Args}}{{end}}` — {{oneline .Description}}
+{{- end}}
+{{end}}
 {{- if and .Narrative .Narrative.Recipes}}
 
 ## Recipes
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 78d1885c..221132da 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
+	"regexp"
 	"strings"
 
 	"gopkg.in/yaml.v3"
@@ -36,6 +37,19 @@ type APISpec struct {
 	Config          ConfigSpec          `yaml:"config" json:"config"`
 	Resources       map[string]Resource `yaml:"resources" json:"resources"`
 	Types           map[string]TypeDef  `yaml:"types" json:"types"`
+	ExtraCommands   []ExtraCommand      `yaml:"extra_commands,omitempty" json:"extra_commands,omitempty"` // hand-written cobra commands declared so SKILL.md can document them; spec-only metadata, no code generated
+}
+
+// ExtraCommand declares a hand-written cobra command so the SKILL.md
+// Command Reference can list it alongside spec-driven resources. The
+// generator does not emit code for these — authors hand-write the
+// command in internal/cli/. Without this declaration the SKILL.md
+// template only sees .Resources and silently omits hand-written
+// commands, which is the drift class that motivated this field.
+type ExtraCommand struct {
+	Name        string `yaml:"name" json:"name"`                     // command path, e.g. "boxscore" or "tv airing-today"
+	Description string `yaml:"description" json:"description"`       // one-line description rendered after a dash
+	Args        string `yaml:"args,omitempty" json:"args,omitempty"` // optional positional arg signature, e.g. "<event_id>" or "<team1> <team2>"
 }
 
 // IsSynthetic reports whether this spec declares a multi-source / combo CLI
@@ -318,6 +332,9 @@ func (s *APISpec) Validate() error {
 	if len(s.Resources) == 0 {
 		return fmt.Errorf("at least one resource is required")
 	}
+	if err := validateExtraCommands(s.ExtraCommands); err != nil {
+		return err
+	}
 	for name, r := range s.Resources {
 		if len(r.Endpoints) == 0 && len(r.SubResources) == 0 {
 			return fmt.Errorf("resource %q has no endpoints", name)
@@ -347,6 +364,34 @@ func (s *APISpec) Validate() error {
 	return nil
 }
 
+// extraCommandNameRe permits a single command leaf or a parent+leaf path
+// like "tv airing-today". Each segment must be lowercase with hyphens,
+// matching cobra's convention. Anything else (uppercase, underscores,
+// spaces in a segment) would not match an actual cobra Use: declaration
+// and would silently fail the verify-skill unknown-command check at the
+// consumer side, so we reject early here.
+var extraCommandNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*){0,2}$`)
+
+func validateExtraCommands(cmds []ExtraCommand) error {
+	seen := make(map[string]struct{}, len(cmds))
+	for i, c := range cmds {
+		if c.Name == "" {
+			return fmt.Errorf("extra_commands[%d]: name is required", i)
+		}
+		if !extraCommandNameRe.MatchString(c.Name) {
+			return fmt.Errorf("extra_commands[%d]: name %q must be lowercase command path (one to three segments separated by single spaces, lowercase letters, digits, and hyphens)", i, c.Name)
+		}
+		if c.Description == "" {
+			return fmt.Errorf("extra_commands[%d] (%s): description is required", i, c.Name)
+		}
+		if _, dup := seen[c.Name]; dup {
+			return fmt.Errorf("extra_commands[%d]: name %q appears more than once", i, c.Name)
+		}
+		seen[c.Name] = struct{}{}
+	}
+	return nil
+}
+
 // CountMCPTools counts total endpoints and public (NoAuth) endpoints across
 // all resources and sub-resources.
 func (s *APISpec) CountMCPTools() (total, public int) {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 95537e7d..ada8e574 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -611,3 +611,156 @@ resources:
 	assert.Len(t, items.Endpoints, 1)
 	assert.Contains(t, items.Endpoints, "fallback")
 }
+
+func TestExtraCommandsParse(t *testing.T) {
+	input := `
+name: demo
+base_url: http://x
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/demo/config.toml
+resources:
+  items:
+    description: "Items"
+    endpoints:
+      list:
+        method: GET
+        path: /items
+extra_commands:
+  - name: dashboard
+    description: Favorites at a glance
+  - name: boxscore
+    description: Full box score for an event
+    args: "<event_id>"
+  - name: tv airing-today
+    description: TV episodes airing today
+`
+	s, err := ParseBytes([]byte(input))
+	require.NoError(t, err)
+	require.Len(t, s.ExtraCommands, 3)
+	assert.Equal(t, "dashboard", s.ExtraCommands[0].Name)
+	assert.Equal(t, "Favorites at a glance", s.ExtraCommands[0].Description)
+	assert.Empty(t, s.ExtraCommands[0].Args)
+	assert.Equal(t, "<event_id>", s.ExtraCommands[1].Args)
+	assert.Equal(t, "tv airing-today", s.ExtraCommands[2].Name)
+}
+
+func TestExtraCommandsAbsentIsBackwardCompatible(t *testing.T) {
+	input := `
+name: demo
+base_url: http://x
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/demo/config.toml
+resources:
+  items:
+    description: "Items"
+    endpoints:
+      list:
+        method: GET
+        path: /items
+`
+	s, err := ParseBytes([]byte(input))
+	require.NoError(t, err)
+	assert.Empty(t, s.ExtraCommands)
+}
+
+func TestExtraCommandsValidation(t *testing.T) {
+	base := func(extras []ExtraCommand) APISpec {
+		return APISpec{
+			Name:    "demo",
+			BaseURL: "http://x",
+			Resources: map[string]Resource{
+				"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+			},
+			ExtraCommands: extras,
+		}
+	}
+
+	tests := []struct {
+		name    string
+		extras  []ExtraCommand
+		wantErr string
+	}{
+		{
+			name:    "missing name",
+			extras:  []ExtraCommand{{Description: "no name"}},
+			wantErr: "name is required",
+		},
+		{
+			name:    "missing description",
+			extras:  []ExtraCommand{{Name: "boxscore"}},
+			wantErr: "description is required",
+		},
+		{
+			name:    "uppercase name rejected",
+			extras:  []ExtraCommand{{Name: "Boxscore", Description: "x"}},
+			wantErr: "must be lowercase command path",
+		},
+		{
+			name:    "underscore in name rejected",
+			extras:  []ExtraCommand{{Name: "box_score", Description: "x"}},
+			wantErr: "must be lowercase command path",
+		},
+		{
+			name:    "duplicate name rejected",
+			extras:  []ExtraCommand{{Name: "boxscore", Description: "first"}, {Name: "boxscore", Description: "second"}},
+			wantErr: "appears more than once",
+		},
+		{
+			name:    "more than three segments rejected",
+			extras:  []ExtraCommand{{Name: "a b c d", Description: "too deep"}},
+			wantErr: "must be lowercase command path",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s := base(tt.extras)
+			err := s.Validate()
+			require.Error(t, err)
+			assert.Contains(t, err.Error(), tt.wantErr)
+		})
+	}
+}
+
+func TestExtraCommandsAcceptsValidShapes(t *testing.T) {
+	valid := []ExtraCommand{
+		{Name: "boxscore", Description: "single leaf"},
+		{Name: "tv airing-today", Description: "two segments with hyphen"},
+		{Name: "a b-c d", Description: "three segments"},
+		{Name: "trending", Description: "no args"},
+		{Name: "h2h", Description: "with digits and args", Args: "<team1> <team2>"},
+	}
+	s := APISpec{
+		Name:          "demo",
+		BaseURL:       "http://x",
+		Resources:     map[string]Resource{"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}}},
+		ExtraCommands: valid,
+	}
+	require.NoError(t, s.Validate())
+}
+
+func TestExtraCommandsRoundTripYAML(t *testing.T) {
+	original := APISpec{
+		Name:    "demo",
+		BaseURL: "http://x",
+		Auth:    AuthConfig{Type: "none"},
+		Resources: map[string]Resource{
+			"items": {Endpoints: map[string]Endpoint{"list": {Method: "GET", Path: "/items"}}},
+		},
+		ExtraCommands: []ExtraCommand{
+			{Name: "dashboard", Description: "Favorites"},
+			{Name: "boxscore", Description: "Box score", Args: "<event_id>"},
+		},
+	}
+	data, err := yaml.Marshal(original)
+	require.NoError(t, err)
+	var parsed APISpec
+	require.NoError(t, yaml.Unmarshal(data, &parsed))
+	assert.Equal(t, original.ExtraCommands, parsed.ExtraCommands)
+}

← 72916ac1 feat(cli): add auth doctor subcommand (#226)  ·  back to Cli Printing Press  ·  fix(cli): support nested --select paths + suppress provenanc ac4d6aa2 →