← back to Cli Printing Press
feat(cli): generate <cli> which <capability> resolver in every printed CLI (#240)
9dd5632a1a5cdc742d997cc8b764d9169a7c4f81 · 2026-04-22 10:25:13 -0700 · Matt Van Horn
Every printed CLI now ships a `which` subcommand that resolves a
natural-language capability query to the best matching command from
the same curated feature index that drives SKILL.md. Agents no longer
need to parse `--help` trees to find the right command.
Ranking is naive and deterministic: exact token match (+3), substring
match on command or description (+2), group-tag match (+1). Zero-score
matches are dropped when the query is non-empty; an empty query lists
the full index for broad discovery.
Output follows the existing duality rule: JSON when --json or piped,
table when TTY. Exit codes follow the typed convention:
0 at least one confident match
2 no match (usageErr) - caller branches without parsing text
Index is seeded at generation time from the NovelFeature list already
used by skill.md.tmpl, so every advertised capability is queryable and
no runtime help-text parsing is involved. CLIs with zero novel features
still emit a valid which command that exits 2 with a clear hint.
skill.md.tmpl documents the command in a new "Finding the right
command" section so agents learn the shape on first read.
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/generator.goM internal/generator/generator_test.goM internal/generator/templates/root.go.tmplM internal/generator/templates/skill.md.tmplA internal/generator/templates/which.go.tmplA internal/generator/templates/which_test.go.tmpl
Diff
commit 9dd5632a1a5cdc742d997cc8b764d9169a7c4f81
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date: Wed Apr 22 10:25:13 2026 -0700
feat(cli): generate <cli> which <capability> resolver in every printed CLI (#240)
Every printed CLI now ships a `which` subcommand that resolves a
natural-language capability query to the best matching command from
the same curated feature index that drives SKILL.md. Agents no longer
need to parse `--help` trees to find the right command.
Ranking is naive and deterministic: exact token match (+3), substring
match on command or description (+2), group-tag match (+1). Zero-score
matches are dropped when the query is non-empty; an empty query lists
the full index for broad discovery.
Output follows the existing duality rule: JSON when --json or piped,
table when TTY. Exit codes follow the typed convention:
0 at least one confident match
2 no match (usageErr) - caller branches without parsing text
Index is seeded at generation time from the NovelFeature list already
used by skill.md.tmpl, so every advertised capability is queryable and
no runtime help-text parsing is involved. CLIs with zero novel features
still emit a valid which command that exits 2 with a clear hint.
skill.md.tmpl documents the command in a new "Finding the right
command" section so agents learn the shape on first read.
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/generator.go | 4 +-
internal/generator/generator_test.go | 7 +-
internal/generator/templates/root.go.tmpl | 1 +
internal/generator/templates/skill.md.tmpl | 10 ++
internal/generator/templates/which.go.tmpl | 197 ++++++++++++++++++++++++
internal/generator/templates/which_test.go.tmpl | 98 ++++++++++++
6 files changed, 313 insertions(+), 4 deletions(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 87e58e61..ab21c921 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -680,6 +680,8 @@ func (g *Generator) Generate() error {
"profile.go.tmpl": filepath.Join("internal", "cli", "profile.go"),
"deliver.go.tmpl": filepath.Join("internal", "cli", "deliver.go"),
"feedback.go.tmpl": filepath.Join("internal", "cli", "feedback.go"),
+ "which.go.tmpl": filepath.Join("internal", "cli", "which.go"),
+ "which_test.go.tmpl": filepath.Join("internal", "cli", "which_test.go"),
"config.go.tmpl": filepath.Join("internal", "config", "config.go"),
"cache.go.tmpl": filepath.Join("internal", "cache", "cache.go"),
"client.go.tmpl": filepath.Join("internal", "client", "client.go"),
@@ -697,7 +699,7 @@ func (g *Generator) Generate() error {
for tmplName, outPath := range singleFiles {
var data any
switch tmplName {
- case "readme.md.tmpl", "skill.md.tmpl":
+ case "readme.md.tmpl", "skill.md.tmpl", "which.go.tmpl", "which_test.go.tmpl":
data = g.readmeData()
case "helpers.go.tmpl":
hFlags := computeHelperFlags(g.Spec)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 0790493d..e75feade 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -30,9 +30,10 @@ func TestGenerateProjectsCompile(t *testing.T) {
// +1 for internal/cli/deliver.go (HeyGen-style --deliver output routing)
// +1 for internal/cli/feedback.go (HeyGen-style in-band agent feedback channel)
// +1 for internal/store/schema_version_test.go (PRAGMA user_version gate, discrawl-inspired)
- {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 40},
- {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 44},
- {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 42},
+ // +2 for internal/cli/which.go + which_test.go (capability-to-command resolver)
+ {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 42},
+ {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 46},
+ {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 44},
}
for _, tt := range tests {
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 67c0871a..dcc2dd98 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -193,6 +193,7 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
rootCmd.AddCommand(newAgentContextCmd(rootCmd))
rootCmd.AddCommand(newProfileCmd(&flags))
rootCmd.AddCommand(newFeedbackCmd(&flags))
+ rootCmd.AddCommand(newWhichCmd(&flags))
{{- if .HasAsyncJobs}}
rootCmd.AddCommand(newJobsCmd(&flags))
{{- end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index fc2fb3ba..c0c0ff4c 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -75,6 +75,16 @@ These capabilities aren't available in any other tool for this API.
- `{{$.Name}}-pp-cli {{.Name}}{{if .Args}} {{.Args}}{{end}}` — {{oneline .Description}}
{{- end}}
{{end}}
+
+### Finding the right command
+
+When you know what you want to do but not which command does it, ask the CLI directly:
+
+```bash
+{{.Name}}-pp-cli which "<capability in your own words>"
+```
+
+`which` resolves a natural-language capability query to the best matching command from this CLI's curated feature index. Exit code `0` means at least one match; exit code `2` means no confident match — fall back to `--help` or use a narrower query.
{{- if and .Narrative .Narrative.Recipes}}
## Recipes
diff --git a/internal/generator/templates/which.go.tmpl b/internal/generator/templates/which.go.tmpl
new file mode 100644
index 00000000..b33fc694
--- /dev/null
+++ b/internal/generator/templates/which.go.tmpl
@@ -0,0 +1,197 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
+
+// whichEntry is one row of the curated capability index. The index is
+// seeded at generation time from the same NovelFeature list that drives
+// the SKILL.md feature section, so the command a `which` query returns
+// is guaranteed to exist and to match what the skill advertises.
+type whichEntry struct {
+ Command string `json:"command"`
+ Description string `json:"description"`
+ Group string `json:"group,omitempty"`
+ WhyItMatters string `json:"why_it_matters,omitempty"`
+}
+
+// whichIndex is the curated list of capabilities this CLI advertises as
+// its hero features. Endpoint-level commands are discoverable via
+// `--help`; `which` exists to resolve a natural-language capability
+// query to one of the commands the skill says matter most.
+var whichIndex = []whichEntry{
+{{- range .NovelFeatures}}
+ {Command: {{printf "%q" .Command}}, Description: {{printf "%q" .Description}}, Group: {{printf "%q" .Group}}, WhyItMatters: {{printf "%q" .WhyItMatters}}},
+{{- end}}
+}
+
+// whichMatch pairs an index entry with its ranking score for a query.
+// Higher score means stronger match. The ranker is naive (exact token
+// then substring then group tag) because 20-40 entries do not need
+// semantic retrieval - a ranker upgrade is a future change that would
+// not break this contract.
+type whichMatch struct {
+ Entry whichEntry `json:"entry"`
+ Score int `json:"score"`
+}
+
+// rankWhich returns up to `limit` best matches for `query` against the
+// index, sorted by descending score. Score breakdown:
+//
+// +3 exact token match on the command's leaf or full path
+// +2 substring match on the command (any part)
+// +2 substring match on the description
+// +1 group tag contains the query as a word
+//
+// Ties break on declaration order in the index. An empty query returns
+// every entry at score 0 in declaration order - this is the "list all"
+// behavior the skill documents for broad agent discovery.
+func rankWhich(index []whichEntry, query string, limit int) []whichMatch {
+ if limit <= 0 {
+ limit = 3
+ }
+ q := strings.ToLower(strings.TrimSpace(query))
+ if q == "" {
+ out := make([]whichMatch, 0, len(index))
+ for _, e := range index {
+ out = append(out, whichMatch{Entry: e, Score: 0})
+ }
+ return out
+ }
+ qTokens := strings.Fields(q)
+
+ scored := make([]whichMatch, 0, len(index))
+ for i, e := range index {
+ score := whichScoreEntry(e, q, qTokens)
+ scored = append(scored, whichMatch{Entry: e, Score: score})
+ _ = i
+ }
+
+ sort.SliceStable(scored, func(i, j int) bool {
+ return scored[i].Score > scored[j].Score
+ })
+ // Drop zero-score matches when the query was non-empty; agents
+ // branching on exit code rely on "no match" meaning no confidence.
+ filtered := scored[:0]
+ for _, m := range scored {
+ if m.Score > 0 {
+ filtered = append(filtered, m)
+ }
+ }
+ if len(filtered) > limit {
+ filtered = filtered[:limit]
+ }
+ return filtered
+}
+
+func whichScoreEntry(e whichEntry, query string, qTokens []string) int {
+ score := 0
+ cmd := strings.ToLower(e.Command)
+ cmdTokens := strings.Fields(cmd)
+ desc := strings.ToLower(e.Description)
+ group := strings.ToLower(e.Group)
+
+ // Exact token match on the command path (any token).
+ for _, qt := range qTokens {
+ for _, ct := range cmdTokens {
+ if qt == ct {
+ score += 3
+ break
+ }
+ }
+ }
+ // Substring match on the full command (covers hyphenated leaves).
+ if strings.Contains(cmd, query) {
+ score += 2
+ }
+ // Substring match on the description.
+ if strings.Contains(desc, query) {
+ score += 2
+ }
+ // Group tag match.
+ if group != "" {
+ for _, qt := range qTokens {
+ if strings.Contains(group, qt) {
+ score += 1
+ break
+ }
+ }
+ }
+ return score
+}
+
+func newWhichCmd(flags *rootFlags) *cobra.Command {
+ var limit int
+ cmd := &cobra.Command{
+ Use: "which [query]",
+ Short: "Find the command that implements a capability",
+ Long: `which resolves a natural-language capability query (for example, "search messages" or "stale tickets") to the best matching command from this CLI's curated feature index.
+
+Exit codes:
+ 0 at least one match found
+ 2 no confident match - the query did not score against any indexed capability; fall back to '--help' or 'search' if this CLI has one`,
+ Example: ` {{.Name}}-pp-cli which "stale tickets"
+ {{.Name}}-pp-cli which "bottleneck"
+ {{.Name}}-pp-cli which --limit 1 "send message"
+ {{.Name}}-pp-cli which # list the full capability index`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(whichIndex) == 0 {
+ return usageErr(fmt.Errorf("this CLI has no curated capability index; run '--help' to see every command"))
+ }
+ query := strings.Join(args, " ")
+ matches := rankWhich(whichIndex, query, limit)
+
+ // Empty query returns the whole index at score 0 (listing mode).
+ if strings.TrimSpace(query) == "" {
+ return renderWhich(cmd, flags, rankWhichAll(whichIndex))
+ }
+
+ if len(matches) == 0 {
+ return usageErr(fmt.Errorf("no match for %q; try '%s --help' for the full command list", query, cmd.Root().Name()))
+ }
+ return renderWhich(cmd, flags, matches)
+ },
+ }
+ cmd.Flags().IntVar(&limit, "limit", 3, "Maximum number of matches to return")
+ return cmd
+}
+
+// rankWhichAll is a narrow helper used by the "empty query lists the
+// index" path. It returns every entry in declaration order at score 0
+// so the render path treats them uniformly.
+func rankWhichAll(index []whichEntry) []whichMatch {
+ out := make([]whichMatch, 0, len(index))
+ for _, e := range index {
+ out = append(out, whichMatch{Entry: e, Score: 0})
+ }
+ return out
+}
+
+func renderWhich(cmd *cobra.Command, flags *rootFlags, matches []whichMatch) error {
+ w := cmd.OutOrStdout()
+ // Output shape follows the same rule as every other generated
+ // command: JSON when the caller asked for it OR when stdout is not
+ // a terminal; table when a human is looking.
+ asJSON := flags.asJSON
+ if !asJSON && !isTerminal(w) {
+ asJSON = true
+ }
+ if asJSON {
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ return enc.Encode(matches)
+ }
+ fmt.Fprintf(w, "%-24s %-8s %s\n", "COMMAND", "SCORE", "DESCRIPTION")
+ for _, m := range matches {
+ fmt.Fprintf(w, "%-24s %-8d %s\n", m.Entry.Command, m.Score, m.Entry.Description)
+ }
+ return nil
+}
diff --git a/internal/generator/templates/which_test.go.tmpl b/internal/generator/templates/which_test.go.tmpl
new file mode 100644
index 00000000..6811c9b8
--- /dev/null
+++ b/internal/generator/templates/which_test.go.tmpl
@@ -0,0 +1,98 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "strings"
+ "testing"
+)
+
+// Fixture index used across which-ranking tests. Covers a typical mix
+// of single-word commands, multi-word commands, and grouped entries so
+// the ranker is exercised against shapes a generated CLI actually
+// produces.
+var whichTestIndex = []whichEntry{
+ {Command: "search", Description: "Full-text search across synced resources", Group: "Local state"},
+ {Command: "stale", Description: "Find tickets that have not moved in a while", Group: "Local state"},
+ {Command: "bottleneck", Description: "Identify pipeline bottlenecks", Group: "Local state"},
+ {Command: "send", Description: "Send a message", Group: "Write operations"},
+ {Command: "sync", Description: "Sync resources to local SQLite", Group: "Local state"},
+}
+
+// Happy path: a query that matches a command by keyword returns that
+// command first. This is the load-bearing promise of `which`.
+func TestRankWhich_ExactTokenMatchWins(t *testing.T) {
+ got := rankWhich(whichTestIndex, "search", 3)
+ if len(got) == 0 {
+ t.Fatalf("expected at least one match, got zero")
+ }
+ if got[0].Entry.Command != "search" {
+ t.Errorf("top match: want search, got %s", got[0].Entry.Command)
+ }
+}
+
+// Happy path: a query matching the description wins when the command
+// itself does not contain the query tokens.
+func TestRankWhich_DescriptionMatch(t *testing.T) {
+ got := rankWhich(whichTestIndex, "bottlenecks", 3)
+ if len(got) == 0 || got[0].Entry.Command != "bottleneck" {
+ t.Errorf("expected bottleneck command as top match for bottlenecks query, got %+v", got)
+ }
+}
+
+// Happy path: a multi-word query resolves to the best single match by
+// summing per-token scores.
+func TestRankWhich_MultiTokenQuery(t *testing.T) {
+ got := rankWhich(whichTestIndex, "send a message", 3)
+ if len(got) == 0 || got[0].Entry.Command != "send" {
+ t.Errorf("expected send as top match for 'send a message', got %+v", got)
+ }
+}
+
+// Edge case: empty query should surface the full index (listing mode)
+// rather than treating as no-match. Agents use this for broad discovery.
+func TestRankWhich_EmptyQueryListsIndex(t *testing.T) {
+ got := rankWhich(whichTestIndex, "", 3)
+ if len(got) != len(whichTestIndex) {
+ t.Errorf("empty query should return all %d entries, got %d", len(whichTestIndex), len(got))
+ }
+ for i, m := range got {
+ if m.Score != 0 {
+ t.Errorf("empty query entry %d: score should be 0, got %d", i, m.Score)
+ }
+ }
+}
+
+// Edge case: the limit flag caps the result set so agents can ask for
+// a single top answer when they want a deterministic branch.
+func TestRankWhich_LimitCapsResults(t *testing.T) {
+ got := rankWhich(whichTestIndex, "local", 1)
+ if len(got) > 1 {
+ t.Errorf("limit=1 should return at most 1 match, got %d", len(got))
+ }
+}
+
+// No-match path: a query that hits nothing in the index returns an
+// empty slice so the caller can exit with the no-match code (2) rather
+// than printing a misleading best-effort result.
+func TestRankWhich_NoMatchReturnsEmpty(t *testing.T) {
+ got := rankWhich(whichTestIndex, "nonexistentxyz", 3)
+ if len(got) != 0 {
+ t.Errorf("nonsense query should return zero matches, got %d (%+v)", len(got), got)
+ }
+}
+
+// Sanity: whichIndex compiles and is well-formed. Generated CLIs with
+// zero NovelFeatures ship an empty index, and that is still a valid
+// state (which returns the "no curated index" error at runtime).
+func TestWhichIndex_ExistsAndIsWellFormed(t *testing.T) {
+ for i, e := range whichIndex {
+ if e.Command == "" {
+ t.Errorf("whichIndex[%d] has empty Command - template rendered bad data", i)
+ }
+ if strings.TrimSpace(e.Description) == "" {
+ t.Errorf("whichIndex[%d] (%s) has empty Description - template rendered bad data", i, e.Command)
+ }
+ }
+}
← 440f654d feat(cli): add live_api_verification scorecard dimension (#2
·
back to Cli Printing Press
·
feat(cli): add http streamable transport to generated MCP se bce586bc →