[object Object]

← 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

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 →