← 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
M internal/generator/skill_test.goM internal/generator/templates/skill.md.tmplM internal/spec/spec.goM internal/spec/spec_test.go
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 →