← back to Cli Printing Press
feat(cli): apply Cloudflare Wrangler CLI learnings (naming check, MCP token efficiency, agent-context) (#216)
dc5a8cc42943e43a1536681763c2cb913e86f531 · 2026-04-13 22:36:23 -0400 · Matt Van Horn
* feat(cli): add naming-consistency dogfood check for agent-facing CLI vocabulary
Agents trained on one printed CLI's vocabulary should recognize every other
printed CLI's vocabulary. Introduces a rule catalog (internal/pipeline/
naming_rules.go) and a new dogfood check that scans generated cobra Use:
declarations and flag registrations for banned names (info, ls, --yes,
--skip-confirmations) and reports the preferred canonical form.
Fails the dogfood verdict on any violation. Inspired by Cloudflare's
schema-layer guardrails documented in their 2026-04-13 Wrangler CLI post.
* feat(cli): add mcp_token_efficiency scorecard dimension
Measures the approximate token weight of the generated MCP tool surface
so bloated outputs are visible before publish. Scoring bands penalize
per-tool descriptions above ~80 tokens (full marks), ~160 (partial),
~320 (partial), and above (zero).
Uses chars/4 heuristic — good enough for relative comparison and
regression detection. CLIs without an MCP surface mark the dimension
as unscored via the existing UnscoredDimensions mechanic, keeping the
overall score unpenalized.
Inspired by Cloudflare's Code Mode MCP which serves 3000+ operations
in under 1000 tokens (2026-04-13 Wrangler post).
* feat(cli): add agent-context subcommand for runtime CLI introspection
Every printed CLI now exposes `<cli> agent-context` which emits structured
JSON describing its commands, flags, auth, and version. Agents can
introspect a running CLI without parsing --help or reading source.
JSON shape is versioned via schema_version starting at "1" so future
breaking changes are signaled. Output is stable-sorted (commands and
flags alphabetical) for clean diffs across regenerations. `--pretty`
indents the output for human reading.
Also refines Unit 1's naming rules: removes --yes from the banned list
since it is a long-standing Unix convention (apt, dnf) and the printed
CLI root template uses it as the canonical skip-prompt flag. Only
--skip-confirmations variants remain banned, matching Cloudflare's
explicit guidance in the 2026-04-13 Wrangler post.
Inspired by Cloudflare's /cdn-cgi/explorer/api runtime endpoint.
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Files touched
A docs/plans/2026-04-13-002-feat-cloudflare-cli-learnings-plan.mdM internal/generator/generator.goM internal/generator/generator_test.goA internal/generator/templates/agent_context.go.tmplM internal/generator/templates/root.go.tmplM internal/pipeline/dogfood.goM internal/pipeline/dogfood_test.goA internal/pipeline/mcp_size.goA internal/pipeline/mcp_size_test.goA internal/pipeline/naming_rules.goM internal/pipeline/scorecard.goM internal/pipeline/scorecard_tier2_test.go
Diff
commit dc5a8cc42943e43a1536681763c2cb913e86f531
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date: Mon Apr 13 22:36:23 2026 -0400
feat(cli): apply Cloudflare Wrangler CLI learnings (naming check, MCP token efficiency, agent-context) (#216)
* feat(cli): add naming-consistency dogfood check for agent-facing CLI vocabulary
Agents trained on one printed CLI's vocabulary should recognize every other
printed CLI's vocabulary. Introduces a rule catalog (internal/pipeline/
naming_rules.go) and a new dogfood check that scans generated cobra Use:
declarations and flag registrations for banned names (info, ls, --yes,
--skip-confirmations) and reports the preferred canonical form.
Fails the dogfood verdict on any violation. Inspired by Cloudflare's
schema-layer guardrails documented in their 2026-04-13 Wrangler CLI post.
* feat(cli): add mcp_token_efficiency scorecard dimension
Measures the approximate token weight of the generated MCP tool surface
so bloated outputs are visible before publish. Scoring bands penalize
per-tool descriptions above ~80 tokens (full marks), ~160 (partial),
~320 (partial), and above (zero).
Uses chars/4 heuristic — good enough for relative comparison and
regression detection. CLIs without an MCP surface mark the dimension
as unscored via the existing UnscoredDimensions mechanic, keeping the
overall score unpenalized.
Inspired by Cloudflare's Code Mode MCP which serves 3000+ operations
in under 1000 tokens (2026-04-13 Wrangler post).
* feat(cli): add agent-context subcommand for runtime CLI introspection
Every printed CLI now exposes `<cli> agent-context` which emits structured
JSON describing its commands, flags, auth, and version. Agents can
introspect a running CLI without parsing --help or reading source.
JSON shape is versioned via schema_version starting at "1" so future
breaking changes are signaled. Output is stable-sorted (commands and
flags alphabetical) for clean diffs across regenerations. `--pretty`
indents the output for human reading.
Also refines Unit 1's naming rules: removes --yes from the banned list
since it is a long-standing Unix convention (apt, dnf) and the printed
CLI root template uses it as the canonical skip-prompt flag. Only
--skip-confirmations variants remain banned, matching Cloudflare's
explicit guidance in the 2026-04-13 Wrangler post.
Inspired by Cloudflare's /cdn-cgi/explorer/api runtime endpoint.
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
---
...04-13-002-feat-cloudflare-cli-learnings-plan.md | 248 +++++++++++++++++++++
internal/generator/generator.go | 1 +
internal/generator/generator_test.go | 66 +++++-
internal/generator/templates/agent_context.go.tmpl | 139 ++++++++++++
internal/generator/templates/root.go.tmpl | 1 +
internal/pipeline/dogfood.go | 127 +++++++++++
internal/pipeline/dogfood_test.go | 188 ++++++++++++++++
internal/pipeline/mcp_size.go | 149 +++++++++++++
internal/pipeline/mcp_size_test.go | 120 ++++++++++
internal/pipeline/naming_rules.go | 55 +++++
internal/pipeline/scorecard.go | 46 ++--
internal/pipeline/scorecard_tier2_test.go | 4 +-
12 files changed, 1123 insertions(+), 21 deletions(-)
diff --git a/docs/plans/2026-04-13-002-feat-cloudflare-cli-learnings-plan.md b/docs/plans/2026-04-13-002-feat-cloudflare-cli-learnings-plan.md
new file mode 100644
index 00000000..bf841fd6
--- /dev/null
+++ b/docs/plans/2026-04-13-002-feat-cloudflare-cli-learnings-plan.md
@@ -0,0 +1,248 @@
+---
+title: "feat(cli): Apply Cloudflare Wrangler CLI learnings to the printing press"
+type: feat
+status: active
+date: 2026-04-13
+---
+
+# feat(cli): Apply Cloudflare Wrangler CLI learnings to the printing press
+
+## Overview
+
+Cloudflare's "Building a CLI for all of Cloudflare" (2026-04-13) describes their rebuild of Wrangler into a single CLI for 3000+ API operations. Four of their design choices map to the printing press. After checking current `main` against the article:
+
+| Cloudflare learning | PP status |
+|---|---|
+| Agent Skills auto-generated alongside CLI | ALREADY DONE (#186 ships SKILL.md; #194/#212 validate it; #212 adds agentic SKILL reviewer) |
+| Schema-layer naming consistency guardrails (`get` not `info`, `--json` not `--format`, `--force`) | NOT YET - gap this plan closes |
+| Token-cost awareness for generated MCP surface | NOT YET - gap this plan closes |
+| Self-describing agent-context endpoint for runtime introspection | NOT YET - gap this plan closes |
+| TypeScript-as-schema | NOT APPLICABLE - PP's YAML already extends OpenAPI; replacing the schema layer is a separate strategic decision, not a 2026-04 tactical change |
+| Local/remote mirror pattern (Local Explorer) | NOT APPLICABLE - most APIs PP wraps have no local equivalent |
+
+The three gaps are additive and independent. Each can ship as its own PR.
+
+## Problem Frame
+
+Printed CLIs are one SKILL.md away from being genuinely agent-ready, but three issues keep them from compounding:
+
+1. Without enforced verb and flag naming, agents guess and miss. If one printed CLI uses `info` and another uses `get`, an agent trained on one hallucinates commands on the other.
+2. MCP tool surfaces are generated without any visibility into their token weight. Cloudflare's Code Mode MCP serves 3000 operations in <1000 tokens. A printed CLI's MCP could be 10x that and nobody would know.
+3. SKILL.md is static at install time. A running CLI cannot introspect itself, so agents fall back to parsing `--help` or reading source.
+
+## Requirements Trace
+
+- R1. Every printed CLI uses a single, enforced vocabulary for command verbs and standard flags
+- R2. Scorecard reports the token weight of the generated MCP tool surface, flagging bloated outputs
+- R3. Every printed CLI exposes its own commands/flags/auth as structured JSON via a built-in subcommand for runtime agent discovery
+- R4. None of the above break existing commands, existing gates, or already-published printed CLIs
+
+## Scope Boundaries
+
+- Does NOT rewrite PP's YAML/OpenAPI schema into a TypeScript schema
+- Does NOT add local/remote mirroring (Cloudflare's Local Explorer)
+- Does NOT modify SKILL.md generation - that work is already shipped and has its own validator
+- Does NOT touch `verify` runtime testing, `emboss`, `publish`, or `research`
+- Does NOT modify already-published CLIs in the public library repo - new generations get the new behavior; old CLIs pick it up on next regeneration
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/pipeline/dogfood.go` - check functions (`checkPaths`, `checkDeadFlags`, `checkExamples`, `checkWiring`, `checkDeadFunctions`) are the pattern for a new `checkNamingConsistency` check. Note: `checkDeadFunctions` gained transitive reachability on 2026-04-12 (PR #183), confirming the regex+fixed-point pattern is the accepted style for structural checks
+- `internal/pipeline/scorecard.go` - `Scorecard` struct with dimension scoring; pattern for a new `mcp_token_efficiency` dimension. Existing `UnscoredDimensions` mechanic handles "not applicable" cases cleanly
+- `internal/generator/templates/command_endpoint.go.tmpl`, `command_promoted.go.tmpl` - command generators where naming consistency must originate
+- `internal/generator/templates/mcp_tools.go.tmpl`, `main_mcp.go.tmpl` - the MCP surface whose size gets measured
+- `internal/generator/templates/root.go.tmpl` - where the new `agent-context` subcommand registers
+- `internal/generator/templates/skill.md.tmpl` - existing SKILL.md template. Agent-context subcommand should expose a superset of the same data structure so the two stay in sync
+- `internal/generator/templates/doctor.go.tmpl` - shape for a purpose-built utility subcommand; agent-context should mirror this
+
+### Institutional Learnings
+
+- `docs/solutions/best-practices/steinberger-scorecard-scoring-architecture-2026-03-27.md` - scorecard dimension design; token-efficiency dimension should follow the same banding pattern
+- `docs/solutions/logic-errors/scorecard-accuracy-broadened-pattern-matching-2026-03-27.md` - regex word-boundary lesson applies directly to Unit 1's naming detection
+- AGENTS.md glossary - canonical names for `scorecard`, `dogfood`, `shipcheck` must be preserved
+- AGENTS.md Machine vs Printed CLI rule - all three changes are machine-layer; every future CLI benefits
+
+### External References
+
+- Cloudflare blog, "Building a CLI for all of Cloudflare" (2026-04-13) - origin of these learnings
+- Existing SKILL.md format on main (post-#186) - canonical agent-facing documentation shape for printed CLIs
+
+## Key Technical Decisions
+
+- **Naming consistency enforced in two places**: templates emit canonical names (source of truth), dogfood catches drift. Matches how PP handles auth and paths today.
+- **Token measurement uses a lightweight approximation, not a real tokenizer**: `len(payload)/4` is good enough for relative comparison across CLIs and detecting regression. Swap for a real tokenizer only if a retro finds it misleading.
+- **Agent-context subcommand emits JSON, not OpenAPI**: printed CLIs are not REST APIs - describing them as OpenAPI forces a lossy translation. Purpose-built JSON shape is clearer, smaller, and versioned via `schema_version` so future shape changes are signaled.
+- **Agent-context data shape is a superset of SKILL.md's data**: both draw from the same generator-time inputs. SKILL.md is narrative markdown for agents at install time; agent-context is structured JSON at runtime. No shared runtime code needed - the generator emits each from the same template data.
+- **All three changes are additive**: no existing behavior changes, no existing tests break.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Is SKILL.md generation already done?** Yes - #186 shipped the template, #194 added a static validator, #212 added an agentic reviewer. Unit 2 from the first draft of this plan is removed.
+- **Should naming consistency be a dogfood check or a scorecard dimension?** Dogfood. Wrong verb is a structural bug, not a quality gradient.
+- **Does MCP token cost go in scorecard or as a separate report?** Scorecard dimension, using the existing `UnscoredDimensions` mechanic for CLIs that opt out of MCP generation.
+- **Should agent-context replicate SKILL.md?** No - they serve different consumers (install-time markdown vs runtime JSON). Agent-context is additive.
+
+### Deferred to Implementation
+
+- **Exact list of banned/canonical verb pairs**: Start with the Cloudflare-cited set and let retros grow it. The rule catalog lives in one file so additions are trivial.
+- **Token count algorithm choice**: `len(payload)/4` is the starting heuristic. Swap for `tiktoken-go` if accuracy matters.
+- **Token scoring bands**: Exact thresholds are tuned from measuring the current catalog CLIs, not invented in the plan.
+
+## Implementation Units
+
+- [ ] **Unit 1: Naming consistency dogfood check**
+
+**Goal:** Add a `checkNamingConsistency` dogfood check that catches non-canonical command verbs and flag names in the generated CLI. Every printed CLI fails dogfood if it uses `info` instead of `get`, `--format` instead of `--json`, or other banned variants. Fix the generator templates at the same time so new CLIs pass by default.
+
+**Requirements:** R1, R4
+
+**Dependencies:** None
+
+**Files:**
+- Create: `internal/pipeline/naming_rules.go` - canonical banned/preferred pairs with category (verb, flag) so rules live in one place and grow over time
+- Modify: `internal/pipeline/dogfood.go` - add `checkNamingConsistency`, a new result type (similar in shape to `DeadCodeResult`), and wire it into `DogfoodReport`, `RunDogfood`, `deriveDogfoodVerdict`, and `collectDogfoodIssues`
+- Modify: `internal/pipeline/dogfood_test.go` - tests for the new check
+- Modify: `internal/generator/templates/command_endpoint.go.tmpl`, `command_promoted.go.tmpl` - audit and normalize any non-canonical names so generated output passes the new check
+- Modify: `internal/generator/generator_test.go` - if the template audit finds any violations, add a test asserting the generated output is clean against the naming rules
+
+**Approach:**
+- Rules table entries: `{Banned: "info", Preferred: "get", Category: "verb"}`, `{Banned: "--format", Preferred: "--json", Category: "flag"}`, `{Banned: "--skip-confirmations", Preferred: "--force", Category: "flag"}`, plus whatever the template audit surfaces
+- For verb checks, extract cobra `Use:` values from generated files and parse the first token
+- For flag checks, parse cobra flag registrations (`Flags().Bool`, `Flags().String`, etc.) and check flag names
+- Use word boundaries in regexes so `getInfoCached` does not trigger `info`
+- Result includes the offending file, the banned name, and the canonical suggestion so the failure message is actionable
+
+**Patterns to follow:**
+- `checkDeadFlags` for file walking, flag extraction, and result construction
+- `checkDeadFunctions` (post-#183 transitive version) for regex-with-word-boundaries discipline
+- `deriveDogfoodVerdict` for how a new check folds into the overall verdict
+
+**Test scenarios:**
+- Happy path: generated fixture with `Use: "get"` and `--json` only - check reports 0 violations, verdict PASS
+- Error path: fixture with a command declaring `Use: "info"` - violation reported with suggestion `get`
+- Error path: fixture with a flag registration `--format` - violation reported with suggestion `--json`
+- Edge case: identifier `getInfoCached` appears in body - no false positive (word boundary works)
+- Edge case: a banned name appears inside a string literal comment - no false positive (parse context matters)
+- Integration: full `RunDogfood` on a template-generated CLI - verdict is PASS after the template audit
+
+**Verification:**
+- `go test ./internal/pipeline/... -run TestCheckNamingConsistency` passes
+- Regenerating a representative catalog CLI and running `printing-press dogfood` on it shows `NamingCheck: PASS`
+- A deliberately-broken fixture produces FAIL with specific suggestions in the output
+
+- [ ] **Unit 2: MCP token-cost scorecard dimension**
+
+**Goal:** Measure and report the token weight of the generated MCP tool surface so bloated outputs are visible before publish. Cloudflare's Code Mode MCP serves 3000 operations in <1000 tokens; printed CLIs should have a visible target to compare against.
+
+**Requirements:** R2, R4
+
+**Dependencies:** None
+
+**Files:**
+- Create: `internal/pipeline/mcp_size.go` - helper to read the generated MCP tool surface and estimate token count, plus the scoring function for the new dimension
+- Create: `internal/pipeline/mcp_size_test.go` - tests for size calculation and scoring bands
+- Modify: `internal/pipeline/scorecard.go` - register `mcp_token_efficiency` as a new dimension, include in total, and support `UnscoredDimensions` for CLIs without MCP
+
+**Approach:**
+- After MCP templates render, scan `internal/mcp/` (or wherever the generated MCP tool list lives) and compute total char count of the serialized tool descriptions and parameter schemas
+- Token estimate: `chars / 4` - simple heuristic good enough for relative comparison and regression detection
+- Scoring bands (exact thresholds tuned post-measurement of catalog CLIs): small footprint = full marks, medium = partial, oversized = 0
+- Scorecard report surfaces total tokens, tokens per tool, and the top-3 heaviest tools so authors know where to trim
+- CLIs without MCP generation add `mcp_token_efficiency` to `UnscoredDimensions` so their total score is not penalized
+
+**Patterns to follow:**
+- Existing scorecard dimension scorers in `internal/pipeline/scorecard.go`
+- `docs/solutions/best-practices/steinberger-scorecard-scoring-architecture-2026-03-27.md` for banding discipline
+- Existing `UnscoredDimensions` mechanic for the opt-out case
+
+**Test scenarios:**
+- Happy path: small MCP surface (few tools, short descriptions) scores full marks
+- Happy path: scorecard report includes total token count and per-tool breakdown
+- Edge case: CLI with no MCP surface at all - dimension appears in `UnscoredDimensions`, not scored as zero
+- Boundary: MCP size at exactly a scoring band threshold - verify inclusive/exclusive behavior is documented and tested
+- Integration: real catalog CLI scorecard output shows the new dimension with a plausible token count
+
+**Verification:**
+- `go test ./internal/pipeline/... -run TestMCPTokenEfficiency` passes
+- Running `printing-press scorecard` on a known catalog CLI shows the new dimension with a reasonable count
+- A deliberately bloated MCP (inflated descriptions) drops the score and lists the bloated tools in the report
+
+- [ ] **Unit 3: Agent-context JSON subcommand**
+
+**Goal:** Every printed CLI exposes `<cli> agent-context` that emits a structured JSON description of its commands, flags, auth, and doctor capabilities. Agents can introspect a running CLI without parsing `--help` or reading source. Mirrors Cloudflare's `/cdn-cgi/explorer/api` pattern.
+
+**Requirements:** R3, R4
+
+**Dependencies:** None (the SKILL.md generation path supplies a reference for what data to expose, but no code dependency)
+
+**Files:**
+- Create: `internal/generator/templates/agent_context.go.tmpl` - the subcommand template
+- Modify: `internal/generator/templates/root.go.tmpl` - register the new subcommand
+- Modify: `internal/generator/generator.go` - wire the new template into generation
+- Modify: `internal/generator/generator_test.go` - assert the file is generated and the subcommand is wired; update expected file counts if relevant
+- Modify: `internal/pipeline/dogfood.go` (`checkWiring` or sibling) - assert `agent-context` is registered
+
+**Approach:**
+- Generated subcommand outputs JSON by default; `--pretty` for human reading
+- JSON shape (version-gated): `{ schema_version: "1", cli: {name, version, description}, auth: {mode, env_vars}, commands: [{use, short, flags: [{name, type, description, required}], examples: []}], doctor: {checks: []} }`
+- Data source: generator has all of this at build time; emit as an embedded constant inside the generated file (no runtime spec parsing, no file IO)
+- Stable ordering: sort commands and flags alphabetically so output diffs cleanly across regenerations
+- `schema_version` exists from day one so future shape changes are signaled
+
+**Patterns to follow:**
+- `doctor.go.tmpl` for purpose-built utility subcommand shape
+- `command_endpoint.go.tmpl` for cobra command registration idioms
+- Existing SKILL.md generation for how the same generator data feeds multiple outputs
+
+**Test scenarios:**
+- Happy path: generated CLI runs `<cli> agent-context` and emits valid JSON that parses
+- Happy path: JSON includes every registered top-level command
+- Happy path: `--pretty` produces indented output; default is compact
+- Edge case: CLI with no auth (rare) produces `auth: {mode: "none"}`, not an omitted field
+- Edge case: CLI with zero custom commands (only scaffolding) produces `commands: []`, not a missing field
+- Integration: generating two different catalog CLIs produces structurally identical JSON shapes with API-specific contents
+
+**Verification:**
+- `go test ./internal/generator/...` passes
+- Running a generated CLI's `agent-context` produces valid JSON matching the documented shape
+- Running the same against two catalog CLIs shows structural parity with different payloads
+- `dogfood` wiring check confirms `agent-context` is registered
+
+## System-Wide Impact
+
+- **Interaction graph:** Only pipeline (dogfood, scorecard) and generator (templates, pipeline) are touched. No changes to `verify`, `emboss`, `publish`, `research`, `crowdsniff`, `megamcp`, or SKILL.md generation.
+- **Error propagation:** New dogfood check surfaces through existing `DogfoodReport` and `deriveDogfoodVerdict`. New scorecard dimension uses the existing `UnscoredDimensions` pattern for opt-outs.
+- **State lifecycle risks:** None - all changes are generation-time and static-analysis-time.
+- **API surface parity:** Agent-context subcommand is additive. SKILL.md (already generated) remains unchanged. Existing commands are untouched.
+- **Unchanged invariants:** Quality gates (7 static checks) unchanged. Existing scorecard dimensions and weights unchanged. Existing dogfood checks unchanged except for additive wiring. SKILL.md generation, validator, and agentic reviewer (#186/#194/#212) unchanged. Published CLIs in the public library repo are not auto-modified.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Unit 1 naming rules cause mass failures on existing catalog CLIs when regenerated | Audit and fix templates in the same PR. Run Unit 1 locally against all catalog CLIs before merging; if any fail, the fix goes into the same PR as the check |
+| Token heuristic (`chars/4`) diverges from real tokenizer on non-English or code-heavy content | Accepted - goal is relative comparison and regression detection. Swap for `tiktoken-go` if a retro finds it misleading |
+| Agent-context JSON shape becomes a de-facto public contract that constrains future changes | `schema_version: "1"` from day one; breaking changes increment the version and are signaled |
+| Scoring band thresholds for MCP token efficiency are invented without data | Measure current catalog CLIs first; set bands based on observed distribution, not guess |
+| Three units landing as separate PRs could each trigger a minor version bump | Acceptable - each is an additive feature and deserves its own changelog entry. If batching is preferred, land them as one PR with three commits |
+
+## Documentation / Operational Notes
+
+- Update `AGENTS.md` glossary to define `agent-context` subcommand and the `schema_version` convention
+- Update `AGENTS.md` Commit Style section only if a new scope is needed (likely not - `cli` covers all three)
+- Consider a short section in the retro template or `/printing-press-retro` skill that calls out the new checks so retros can identify which CLIs would regress under them
+- Release plan: each unit can ship as `feat(cli)` - release-please accumulates and the next release PR picks them up
+
+## Sources & References
+
+- Cloudflare blog, "Building a CLI for all of Cloudflare" (2026-04-13)
+- PR #186 - SKILL.md generation (reason Unit 2 from first draft was dropped)
+- PR #194 - static SKILL.md validator
+- PR #212 - agentic SKILL reviewer
+- PR #183 - transitive dogfood dead function scanner (recent example of additive dogfood check)
+- `docs/solutions/best-practices/steinberger-scorecard-scoring-architecture-2026-03-27.md`
+- `AGENTS.md` for naming conventions, versioning rules, and Machine vs Printed CLI discipline
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index fcbf6f8e..47a6c7a3 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -650,6 +650,7 @@ func (g *Generator) Generate() error {
"main.go.tmpl": filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
"helpers.go.tmpl": filepath.Join("internal", "cli", "helpers.go"),
"doctor.go.tmpl": filepath.Join("internal", "cli", "doctor.go"),
+ "agent_context.go.tmpl": filepath.Join("internal", "cli", "agent_context.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"),
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 2a8b2d6f..4e98b587 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1,6 +1,7 @@
package generator
import (
+ "encoding/json"
"os"
"os/exec"
"path/filepath"
@@ -24,9 +25,10 @@ func TestGenerateProjectsCompile(t *testing.T) {
expectedFiles int
}{
// +3 for cliutil package: fanout.go, text.go, cliutil_test.go
- {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 35},
- {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 39},
- {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 39},
+ // +1 for internal/cli/agent_context.go (Cloudflare-style runtime introspection)
+ {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 36},
+ {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 40},
+ {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 40},
}
for _, tt := range tests {
@@ -100,6 +102,64 @@ func TestGenerateCliutilPackage(t *testing.T) {
runGoCommand(t, outputDir, "test", "./internal/cliutil/...")
}
+// TestGenerateAgentContextCommand verifies that every generated CLI ships
+// with the agent-context subcommand and that it emits valid JSON matching
+// the documented schema. Inspired by Cloudflare's /cdn-cgi/explorer/api
+// endpoint (2026-04-13 Wrangler post) — agents introspect at runtime.
+func TestGenerateAgentContextCommand(t *testing.T) {
+ t.Parallel()
+
+ apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
+ require.NoError(t, err)
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ // The agent_context.go file must exist in internal/cli/.
+ agentContextPath := filepath.Join(outputDir, "internal", "cli", "agent_context.go")
+ data, err := os.ReadFile(agentContextPath)
+ require.NoError(t, err, "agent_context.go must be generated")
+
+ src := string(data)
+ // Key symbols callers (root.go, tests, agents) rely on.
+ for _, snippet := range []string{
+ "func newAgentContextCmd",
+ "agentContextSchemaVersion",
+ `"schema_version"`,
+ `Use: "agent-context"`,
+ "collectAgentCommands",
+ `"pretty"`,
+ } {
+ assert.Contains(t, src, snippet, "agent_context.go missing %q", snippet)
+ }
+
+ // The subcommand must be registered in root.go so the CLI picks it up.
+ rootSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(rootSrc), "newAgentContextCmd(rootCmd)",
+ "agent-context command must be registered in root.go")
+
+ // The CLI must build with the new subcommand.
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+
+ // Build the binary and run agent-context; output must be valid JSON
+ // carrying the schema_version field at the top level.
+ binaryPath := filepath.Join(outputDir, naming.CLI(apiSpec.Name))
+ runGoCommand(t, outputDir, "build", "-o", binaryPath, "./cmd/"+naming.CLI(apiSpec.Name))
+
+ out, err := exec.Command(binaryPath, "agent-context").Output()
+ require.NoError(t, err, "running agent-context must succeed")
+
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(out, &payload), "agent-context must emit valid JSON")
+ assert.Equal(t, "1", payload["schema_version"], "schema_version must be present and start at 1")
+ assert.Contains(t, payload, "cli")
+ assert.Contains(t, payload, "auth")
+ assert.Contains(t, payload, "commands")
+}
+
func TestGenerateOAuth2AuthTemplateConditionally(t *testing.T) {
t.Parallel()
diff --git a/internal/generator/templates/agent_context.go.tmpl b/internal/generator/templates/agent_context.go.tmpl
new file mode 100644
index 00000000..4fb2b14b
--- /dev/null
+++ b/internal/generator/templates/agent_context.go.tmpl
@@ -0,0 +1,139 @@
+// 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"
+ "os"
+ "sort"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+// agentContextSchemaVersion is bumped on any breaking change to the JSON
+// shape emitted by `agent-context`. Agents should check this before
+// parsing. Shape at v1 is stable: {schema_version, cli, auth, commands}.
+const agentContextSchemaVersion = "1"
+
+// agentContext is the structured description of this CLI consumed by AI
+// agents. Inspired by Cloudflare's /cdn-cgi/explorer/api runtime endpoint
+// (2026-04-13 Wrangler post): agents can introspect the live CLI without
+// parsing --help or reading source.
+type agentContext struct {
+ SchemaVersion string `json:"schema_version"`
+ CLI agentContextCLI `json:"cli"`
+ Auth agentContextAuth `json:"auth"`
+ Commands []agentContextCommand `json:"commands"`
+}
+
+type agentContextCLI struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Version string `json:"version"`
+}
+
+type agentContextAuth struct {
+ Mode string `json:"mode"`
+ EnvVars []string `json:"env_vars"`
+}
+
+type agentContextCommand struct {
+ Name string `json:"name"`
+ Use string `json:"use,omitempty"`
+ Short string `json:"short,omitempty"`
+ Flags []agentContextFlag `json:"flags,omitempty"`
+ Subcommands []agentContextCommand `json:"subcommands,omitempty"`
+}
+
+type agentContextFlag struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Usage string `json:"usage,omitempty"`
+ Default string `json:"default,omitempty"`
+}
+
+func newAgentContextCmd(rootCmd *cobra.Command) *cobra.Command {
+ var pretty bool
+ cmd := &cobra.Command{
+ Use: "agent-context",
+ Short: "Emit structured JSON describing this CLI for agents",
+ Long: `Outputs a machine-readable description of commands, flags, and auth so
+agents can introspect this CLI at runtime without parsing --help or
+reading source. Schema is versioned via schema_version.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := buildAgentContext(rootCmd)
+ enc := json.NewEncoder(os.Stdout)
+ if pretty {
+ enc.SetIndent("", " ")
+ }
+ return enc.Encode(ctx)
+ },
+ }
+ cmd.Flags().BoolVar(&pretty, "pretty", false, "indent JSON output for human reading")
+ return cmd
+}
+
+func buildAgentContext(rootCmd *cobra.Command) agentContext {
+ envVars := []string{
+{{- range .Auth.EnvVars}}
+ {{printf "%q" .}},
+{{- end}}
+ }
+ authMode := {{printf "%q" .Auth.Type}}
+ if authMode == "" {
+ authMode = "none"
+ }
+ return agentContext{
+ SchemaVersion: agentContextSchemaVersion,
+ CLI: agentContextCLI{
+ Name: {{printf "%q" (printf "%s-pp-cli" .Name)}},
+ Description: {{printf "%q" (oneline .Description)}},
+ Version: rootCmd.Version,
+ },
+ Auth: agentContextAuth{
+ Mode: authMode,
+ EnvVars: envVars,
+ },
+ Commands: collectAgentCommands(rootCmd),
+ }
+}
+
+// collectAgentCommands walks the cobra tree from the given command and
+// returns its direct children (skipping hidden commands and the
+// agent-context command itself to avoid self-reference). Each child is
+// recursed into if it has subcommands. Flags are captured via VisitAll.
+// Output is sorted by command name for stable diffs across regenerations.
+func collectAgentCommands(c *cobra.Command) []agentContextCommand {
+ children := c.Commands()
+ sort.Slice(children, func(i, j int) bool { return children[i].Name() < children[j].Name() })
+
+ out := make([]agentContextCommand, 0, len(children))
+ for _, sub := range children {
+ if sub.Hidden || sub.Name() == "agent-context" {
+ continue
+ }
+ entry := agentContextCommand{
+ Name: sub.Name(),
+ Use: sub.Use,
+ Short: sub.Short,
+ }
+ sub.Flags().VisitAll(func(f *pflag.Flag) {
+ entry.Flags = append(entry.Flags, agentContextFlag{
+ Name: f.Name,
+ Type: f.Value.Type(),
+ Usage: f.Usage,
+ Default: f.DefValue,
+ })
+ })
+ sort.Slice(entry.Flags, func(i, j int) bool {
+ return entry.Flags[i].Name < entry.Flags[j].Name
+ })
+ if len(sub.Commands()) > 0 {
+ entry.Subcommands = collectAgentCommands(sub)
+ }
+ out = append(out, entry)
+ }
+ return out
+}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 26806698..ad5f87e9 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -140,6 +140,7 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
{{- end}}
rootCmd.AddCommand(newDoctorCmd(&flags))
rootCmd.AddCommand(newAuthCmd(&flags))
+ rootCmd.AddCommand(newAgentContextCmd(rootCmd))
{{- if .VisionSet.Export}}
rootCmd.AddCommand(newExportCmd(&flags))
{{- end}}
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index c6259614..a448d8f3 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -31,9 +31,27 @@ type DogfoodReport struct {
WiringCheck WiringCheckResult `json:"wiring_check"`
NovelFeaturesCheck NovelFeaturesCheckResult `json:"novel_features_check"`
TestPresence TestPresenceResult `json:"test_presence"`
+ NamingCheck NamingCheckResult `json:"naming_check"`
Issues []string `json:"issues"`
}
+// NamingCheckResult reports non-canonical command verbs and flag names
+// found in generated CLI source. The rules live in naming_rules.go. Agents
+// trained on one printed CLI's vocabulary should recognize every other
+// printed CLI's vocabulary — drift here is a structural bug, not a style
+// preference.
+type NamingCheckResult struct {
+ Checked int `json:"checked"`
+ Violations []NamingViolation `json:"violations,omitempty"`
+}
+
+type NamingViolation struct {
+ File string `json:"file"`
+ Banned string `json:"banned"`
+ Preferred string `json:"preferred"`
+ Category string `json:"category"`
+}
+
// TestPresenceResult reports coverage gaps in agent-authored pure-logic
// packages under internal/. The walker only inspects packages outside the
// known generator-emitted set, so this check targets agent-authored novel
@@ -185,6 +203,7 @@ func RunDogfood(dir, specPath string, opts ...DogfoodOption) (*DogfoodReport, er
report.WiringCheck = checkWiring(dir)
report.NovelFeaturesCheck = checkNovelFeatures(dir, cfg.researchDir)
report.TestPresence = checkTestPresence(dir)
+ report.NamingCheck = checkNamingConsistency(dir)
report.Issues = collectDogfoodIssues(report, spec != nil)
report.Verdict = deriveDogfoodVerdict(report, spec != nil)
@@ -885,6 +904,103 @@ func checkDeadFunctions(dir string) DeadCodeResult {
return result
}
+// checkNamingConsistency walks the generated CLI source and reports any
+// non-canonical command verbs or flag names against the rules in
+// naming_rules.go. The check is structural: agents need consistent
+// vocabulary across every printed CLI, so drift is a bug not a style nit.
+//
+// Verb detection: extract the first token of every cobra `Use:` declaration.
+// Flag detection: extract long-form flag names from the various
+// `Flags().StringVar` / `Flags().BoolP` / etc. registration patterns.
+//
+// The check never reports false positives from identifiers that happen to
+// contain a banned substring (e.g. `getInfoCached`) because it matches only
+// in the contexts where verbs or flags are declared.
+func checkNamingConsistency(dir string) NamingCheckResult {
+ files := listGoFiles(filepath.Join(dir, "internal", "cli"))
+ if len(files) == 0 {
+ return NamingCheckResult{}
+ }
+
+ result := NamingCheckResult{Checked: len(files)}
+
+ // Extract the first token of a cobra Use: declaration:
+ // Use: "get", -> "get"
+ // Use: "list [id]", -> "list"
+ useRe := regexp.MustCompile(`(?m)Use:\s*"([A-Za-z][A-Za-z0-9_-]*)`)
+
+ // Extract long-form flag names from the common cobra registration
+ // patterns: StringVar, BoolVar, IntVar, Int64Var, StringVarP, BoolVarP,
+ // etc. The flag name is the second string argument in the non-P forms
+ // and the second string argument followed by a shorthand in the P forms.
+ // Matching `"--name"` directly covers both cases because the name in
+ // code does not include the leading dashes; we instead look for the
+ // quoted name positioned right after a Flags() call.
+ //
+ // Pattern catches: Flags().StringVar(&x, "name", ...), Flags().Bool("name", ...),
+ // PersistentFlags().StringVarP(&x, "name", "n", ...), etc.
+ flagRe := regexp.MustCompile(`(?:Persistent)?Flags\(\)\.(?:String|Bool|Int|Int64|Float64|Duration|StringSlice|StringArray)(?:Var)?(?:P)?\(\s*(?:&[A-Za-z_]\w*\s*,\s*)?"([A-Za-z][A-Za-z0-9_-]*)"`)
+
+ for _, path := range files {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ src := string(content)
+ rel, relErr := filepath.Rel(dir, path)
+ if relErr != nil {
+ rel = path
+ }
+
+ verbs := useRe.FindAllStringSubmatch(src, -1)
+ flags := flagRe.FindAllStringSubmatch(src, -1)
+
+ for _, match := range verbs {
+ verb := match[1]
+ if rule, ok := lookupNamingRule(verb, "verb"); ok {
+ result.Violations = append(result.Violations, NamingViolation{
+ File: rel,
+ Banned: verb,
+ Preferred: rule.Preferred,
+ Category: "verb",
+ })
+ }
+ }
+ for _, match := range flags {
+ flag := match[1]
+ // Flag rules are declared with the `--` prefix; normalize.
+ bannedName := "--" + flag
+ if rule, ok := lookupNamingRule(bannedName, "flag"); ok {
+ result.Violations = append(result.Violations, NamingViolation{
+ File: rel,
+ Banned: bannedName,
+ Preferred: rule.Preferred,
+ Category: "flag",
+ })
+ }
+ }
+ }
+
+ sort.Slice(result.Violations, func(i, j int) bool {
+ if result.Violations[i].File != result.Violations[j].File {
+ return result.Violations[i].File < result.Violations[j].File
+ }
+ return result.Violations[i].Banned < result.Violations[j].Banned
+ })
+ return result
+}
+
+// lookupNamingRule returns the first rule matching the given name and
+// category, or false if none match.
+func lookupNamingRule(name, category string) (NamingRule, bool) {
+ for _, r := range namingRules {
+ if r.Category == category && r.Banned == name {
+ return r, true
+ }
+ }
+ return NamingRule{}, false
+}
+
// extractFunctionBodies returns a map from function name to its body text
// (everything between its func line and the next top-level func line).
func extractFunctionBodies(source string) map[string]string {
@@ -1115,6 +1231,9 @@ func deriveDogfoodVerdict(report *DogfoodReport, hasSpec bool) string {
// (agents rationalize skipping tests). See docs/plans/2026-04-13-001.
return "FAIL"
}
+ if len(report.NamingCheck.Violations) > 0 {
+ return "FAIL"
+ }
if len(report.WiringCheck.WorkflowComplete.UnmappedSteps) > 0 {
return "WARN"
}
@@ -1177,6 +1296,14 @@ func collectDogfoodIssues(report *DogfoodReport, hasSpec bool) []string {
issues = append(issues, fmt.Sprintf("pure-logic packages with no tests: %s",
strings.Join(report.TestPresence.MissingTests, ", ")))
}
+ if len(report.NamingCheck.Violations) > 0 {
+ parts := make([]string, 0, len(report.NamingCheck.Violations))
+ for _, v := range report.NamingCheck.Violations {
+ parts = append(parts, fmt.Sprintf("%s %s→%s in %s", v.Category, v.Banned, v.Preferred, v.File))
+ }
+ issues = append(issues, fmt.Sprintf("%d naming violations: %s",
+ len(report.NamingCheck.Violations), strings.Join(parts, "; ")))
+ }
// ThinTests is intentionally NOT added as a hard issue — it's a warning
// surfaced to Wave B's Phase 4.85 agentic reviewer for deeper judgment.
// Hard-gating on test-function count would reward trivial placeholder
diff --git a/internal/pipeline/dogfood_test.go b/internal/pipeline/dogfood_test.go
index 65cabb6d..39640b55 100644
--- a/internal/pipeline/dogfood_test.go
+++ b/internal/pipeline/dogfood_test.go
@@ -871,6 +871,194 @@ func runExport(flags *rootFlags) {
assert.Equal(t, []string{"deadOnly"}, result.Items)
}
+func TestCheckNamingConsistency_CleanCLI(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "cmd.go"), `package cli
+
+import "github.com/spf13/cobra"
+
+var forceFlag bool
+
+func newGetCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "get",
+ Short: "Get a resource",
+ }
+ cmd.Flags().BoolVar(&forceFlag, "force", false, "skip confirmation")
+ return cmd
+}
+
+func newListCmd() *cobra.Command {
+ return &cobra.Command{Use: "list"}
+}
+`)
+
+ result := checkNamingConsistency(dir)
+ assert.Equal(t, 1, result.Checked)
+ assert.Empty(t, result.Violations)
+}
+
+func TestCheckNamingConsistency_BannedVerbInfo(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "cmd.go"), `package cli
+
+import "github.com/spf13/cobra"
+
+func newInfoCmd() *cobra.Command {
+ return &cobra.Command{Use: "info"}
+}
+`)
+
+ result := checkNamingConsistency(dir)
+ require.Len(t, result.Violations, 1)
+ assert.Equal(t, "info", result.Violations[0].Banned)
+ assert.Equal(t, "get", result.Violations[0].Preferred)
+ assert.Equal(t, "verb", result.Violations[0].Category)
+}
+
+func TestCheckNamingConsistency_BannedVerbLs(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "cmd.go"), `package cli
+
+import "github.com/spf13/cobra"
+
+func newLsCmd() *cobra.Command {
+ return &cobra.Command{Use: "ls"}
+}
+`)
+
+ result := checkNamingConsistency(dir)
+ require.Len(t, result.Violations, 1)
+ assert.Equal(t, "ls", result.Violations[0].Banned)
+ assert.Equal(t, "list", result.Violations[0].Preferred)
+}
+
+func TestCheckNamingConsistency_BannedFlagSkipConfirmations(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "cmd.go"), `package cli
+
+import "github.com/spf13/cobra"
+
+var skip bool
+
+func newDeleteCmd() *cobra.Command {
+ cmd := &cobra.Command{Use: "delete"}
+ cmd.Flags().BoolVar(&skip, "skip-confirmations", false, "bypass prompts")
+ return cmd
+}
+`)
+
+ result := checkNamingConsistency(dir)
+ require.Len(t, result.Violations, 1)
+ assert.Equal(t, "--skip-confirmations", result.Violations[0].Banned)
+ assert.Equal(t, "--force", result.Violations[0].Preferred)
+ assert.Equal(t, "flag", result.Violations[0].Category)
+}
+
+func TestCheckNamingConsistency_YesFlagIsNotBanned(t *testing.T) {
+ // --yes is a long-standing Unix convention (apt, dnf, etc.) and the
+ // printed CLI root template uses it today. Document that only the
+ // explicitly-banned skip-confirmations variants trigger the check.
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "cmd.go"), `package cli
+
+import "github.com/spf13/cobra"
+
+var yesFlag bool
+
+func newCmd() *cobra.Command {
+ cmd := &cobra.Command{Use: "purge"}
+ cmd.PersistentFlags().BoolVarP(&yesFlag, "yes", "y", false, "skip prompt")
+ return cmd
+}
+`)
+
+ result := checkNamingConsistency(dir)
+ assert.Empty(t, result.Violations, "--yes is allowed; only --skip-confirmations variants are banned")
+}
+
+func TestCheckNamingConsistency_NoFalsePositiveOnIdentifierWithBannedSubstring(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+
+ // `getInfoCached` contains `info` but is a Go identifier, not a Use: verb.
+ // A body containing --format or --skip as a comment or string literal must
+ // not trigger a flag violation.
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "cmd.go"), `package cli
+
+import "github.com/spf13/cobra"
+
+func getInfoCached() string { return "cached" }
+
+func newGetCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "get",
+ Short: "mentions --skip-confirmations in docstring but does not register it",
+ }
+}
+`)
+
+ result := checkNamingConsistency(dir)
+ assert.Empty(t, result.Violations)
+}
+
+func TestCheckNamingConsistency_MultipleViolationsSortedByFile(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "b_cmd.go"), `package cli
+import "github.com/spf13/cobra"
+func newLs() *cobra.Command { return &cobra.Command{Use: "ls"} }
+`)
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "a_cmd.go"), `package cli
+import "github.com/spf13/cobra"
+func newInfo() *cobra.Command { return &cobra.Command{Use: "info"} }
+`)
+
+ result := checkNamingConsistency(dir)
+ require.Len(t, result.Violations, 2)
+ // Sorted by file path — a_cmd.go comes before b_cmd.go
+ assert.Contains(t, result.Violations[0].File, "a_cmd.go")
+ assert.Equal(t, "info", result.Violations[0].Banned)
+ assert.Contains(t, result.Violations[1].File, "b_cmd.go")
+ assert.Equal(t, "ls", result.Violations[1].Banned)
+}
+
+func TestCheckNamingConsistency_EmptyCLIDir(t *testing.T) {
+ dir := t.TempDir()
+ // No internal/cli directory at all — check returns empty, not panics.
+ result := checkNamingConsistency(dir)
+ assert.Equal(t, 0, result.Checked)
+ assert.Empty(t, result.Violations)
+}
+
+func TestDeriveDogfoodVerdict_NamingViolationFails(t *testing.T) {
+ report := &DogfoodReport{
+ PathCheck: PathCheckResult{Tested: 10, Pct: 100},
+ AuthCheck: AuthCheckResult{Match: true},
+ ExampleCheck: ExampleCheckResult{Tested: 5, WithExamples: 5},
+ PipelineCheck: PipelineResult{
+ SyncCallsDomain: true, SearchCallsDomain: true, DomainTables: 1,
+ },
+ NamingCheck: NamingCheckResult{
+ Violations: []NamingViolation{
+ {File: "internal/cli/cmd.go", Banned: "info", Preferred: "get", Category: "verb"},
+ },
+ },
+ }
+ assert.Equal(t, "FAIL", deriveDogfoodVerdict(report, true))
+}
+
func writeTestFile(t *testing.T, path string, content string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
diff --git a/internal/pipeline/mcp_size.go b/internal/pipeline/mcp_size.go
new file mode 100644
index 00000000..7aa6806c
--- /dev/null
+++ b/internal/pipeline/mcp_size.go
@@ -0,0 +1,149 @@
+package pipeline
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// MCPTokenEstimate reports the approximate token weight of a generated MCP
+// tool surface. Inspired by Cloudflare's Code Mode MCP which serves 3000+
+// operations in under 1000 tokens (see 2026-04-13 Wrangler post).
+//
+// Token estimate uses the simple chars/4 heuristic. Good enough for
+// relative comparison across printed CLIs and for catching regressions.
+// Swap for a real tokenizer if a retro finds the heuristic misleading.
+type MCPTokenEstimate struct {
+ TotalChars int `json:"total_chars"`
+ TotalTokens int `json:"total_tokens"`
+ ToolCount int `json:"tool_count"`
+ PerTool []MCPToolSize `json:"per_tool,omitempty"`
+ TopHeaviest []MCPToolSize `json:"top_heaviest,omitempty"`
+}
+
+type MCPToolSize struct {
+ Name string `json:"name"`
+ Chars int `json:"chars"`
+ Tokens int `json:"tokens"`
+}
+
+// mcpToolsPath is the conventional location of the generated MCP tool
+// registrations in a printed CLI.
+func mcpToolsPath(dir string) string {
+ return filepath.Join(dir, "internal", "mcp", "tools.go")
+}
+
+// estimateMCPTokens reads the generated MCP tool surface and returns an
+// estimate of its total token weight, plus per-tool breakdown when
+// individual tool definitions can be isolated. Returns a zero-valued
+// estimate (TotalTokens == 0) if no MCP surface exists.
+func estimateMCPTokens(dir string) MCPTokenEstimate {
+ content, err := os.ReadFile(mcpToolsPath(dir))
+ if err != nil {
+ return MCPTokenEstimate{}
+ }
+ src := string(content)
+ if !strings.Contains(src, "RegisterTools") {
+ // File exists but is a stub — treat as no MCP surface.
+ return MCPTokenEstimate{}
+ }
+
+ // The agent-facing weight of an MCP tool is the name plus description
+ // plus every parameter name and description. Rather than parsing
+ // mcp-go's builder API perfectly, we approximate by extracting all
+ // string literals in the file — the vast majority of bytes an agent
+ // sees come from those literals.
+ literalRe := regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)
+ toolRe := regexp.MustCompile(`mcplib\.NewTool\(\s*"([^"]+)"`)
+
+ literals := literalRe.FindAllString(src, -1)
+ totalChars := 0
+ for _, lit := range literals {
+ totalChars += len(lit) - 2 // strip surrounding quotes
+ }
+
+ // Per-tool sizes: slice the source between consecutive NewTool() calls
+ // and count literal chars within each slice.
+ toolStarts := toolRe.FindAllStringSubmatchIndex(src, -1)
+ toolNames := toolRe.FindAllStringSubmatch(src, -1)
+ perTool := make([]MCPToolSize, 0, len(toolNames))
+ for i, match := range toolNames {
+ name := match[1]
+ start := toolStarts[i][0]
+ var end int
+ if i+1 < len(toolStarts) {
+ end = toolStarts[i+1][0]
+ } else {
+ end = len(src)
+ }
+ chunk := src[start:end]
+ chunkChars := 0
+ for _, lit := range literalRe.FindAllString(chunk, -1) {
+ chunkChars += len(lit) - 2
+ }
+ perTool = append(perTool, MCPToolSize{
+ Name: name,
+ Chars: chunkChars,
+ Tokens: chunkChars / 4,
+ })
+ }
+
+ est := MCPTokenEstimate{
+ TotalChars: totalChars,
+ TotalTokens: totalChars / 4,
+ ToolCount: len(perTool),
+ PerTool: perTool,
+ }
+
+ // Top-3 heaviest tools so authors know where to trim descriptions.
+ if len(perTool) > 0 {
+ heaviest := make([]MCPToolSize, len(perTool))
+ copy(heaviest, perTool)
+ sort.Slice(heaviest, func(i, j int) bool {
+ return heaviest[i].Chars > heaviest[j].Chars
+ })
+ top := 3
+ if len(heaviest) < top {
+ top = len(heaviest)
+ }
+ est.TopHeaviest = heaviest[:top]
+ }
+
+ return est
+}
+
+// scoreMCPTokenEfficiency scores 0-10 based on the token weight of the
+// generated MCP surface. Returns (score, scored) where scored is false
+// for CLIs without an MCP surface so the dimension can be excluded from
+// the scorecard denominator.
+//
+// Scoring bands are calibrated against Cloudflare's <1000 tokens for
+// 3000 operations. Printed CLIs typically have far fewer operations, so
+// the per-tool target is more meaningful than the absolute total. Bands:
+//
+// - per-tool <= 80 tokens: full marks (10)
+// - per-tool <= 160 tokens: partial (7)
+// - per-tool <= 320 tokens: partial (4)
+// - per-tool > 320 tokens: 0
+//
+// Empty or missing MCP surface returns (0, false) so the dimension is
+// added to UnscoredDimensions.
+func scoreMCPTokenEfficiency(dir string) (int, bool) {
+ est := estimateMCPTokens(dir)
+ if est.ToolCount == 0 {
+ return 0, false
+ }
+ avgTokensPerTool := est.TotalTokens / est.ToolCount
+ switch {
+ case avgTokensPerTool <= 80:
+ return 10, true
+ case avgTokensPerTool <= 160:
+ return 7, true
+ case avgTokensPerTool <= 320:
+ return 4, true
+ default:
+ return 0, true
+ }
+}
diff --git a/internal/pipeline/mcp_size_test.go b/internal/pipeline/mcp_size_test.go
new file mode 100644
index 00000000..a62b2e8f
--- /dev/null
+++ b/internal/pipeline/mcp_size_test.go
@@ -0,0 +1,120 @@
+package pipeline
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// writeMCPTools writes a generated tools.go with the provided RegisterTools
+// body and returns the CLI dir. The body is inserted into a minimal file
+// stub that matches what the MCP template produces.
+func writeMCPTools(t *testing.T, body string) string {
+ t.Helper()
+ dir := t.TempDir()
+ mcpDir := filepath.Join(dir, "internal", "mcp")
+ require.NoError(t, os.MkdirAll(mcpDir, 0o755))
+ content := `package mcp
+
+import mcplib "github.com/mark3labs/mcp-go/mcp"
+
+func RegisterTools(s *server.MCPServer) {
+` + body + `
+}
+`
+ require.NoError(t, os.WriteFile(filepath.Join(mcpDir, "tools.go"), []byte(content), 0o644))
+ return dir
+}
+
+func TestEstimateMCPTokens_NoMCPSurface(t *testing.T) {
+ dir := t.TempDir()
+ est := estimateMCPTokens(dir)
+ assert.Equal(t, 0, est.TotalChars)
+ assert.Equal(t, 0, est.ToolCount)
+}
+
+func TestEstimateMCPTokens_StubWithoutRegisterTools(t *testing.T) {
+ dir := t.TempDir()
+ mcpDir := filepath.Join(dir, "internal", "mcp")
+ require.NoError(t, os.MkdirAll(mcpDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(mcpDir, "tools.go"),
+ []byte(`package mcp`), 0o644))
+
+ est := estimateMCPTokens(dir)
+ assert.Equal(t, 0, est.ToolCount, "stub without RegisterTools treated as no MCP surface")
+}
+
+func TestEstimateMCPTokens_SingleSmallTool(t *testing.T) {
+ dir := writeMCPTools(t, `
+ s.AddTool(
+ mcplib.NewTool("get_user",
+ mcplib.WithDescription("Retrieve a user by ID."),
+ mcplib.WithString("id", mcplib.Description("User ID"))),
+ handleGetUser)
+`)
+ est := estimateMCPTokens(dir)
+ require.Equal(t, 1, est.ToolCount)
+ assert.Equal(t, "get_user", est.PerTool[0].Name)
+ assert.Greater(t, est.TotalChars, 0)
+ assert.Greater(t, est.TotalTokens, 0)
+ // small tool should be well under 80 tokens
+ assert.Less(t, est.PerTool[0].Tokens, 80)
+}
+
+func TestEstimateMCPTokens_MultipleToolsTopHeaviest(t *testing.T) {
+ // Three tools with increasing description length so TopHeaviest
+ // ordering is deterministic.
+ dir := writeMCPTools(t, `
+ s.AddTool(mcplib.NewTool("small", mcplib.WithDescription("tiny")), nil)
+ s.AddTool(mcplib.NewTool("medium", mcplib.WithDescription("a medium description here")), nil)
+ s.AddTool(mcplib.NewTool("huge", mcplib.WithDescription("`+strings.Repeat("x", 500)+`")), nil)
+`)
+ est := estimateMCPTokens(dir)
+ require.Equal(t, 3, est.ToolCount)
+ require.Len(t, est.TopHeaviest, 3)
+ assert.Equal(t, "huge", est.TopHeaviest[0].Name, "largest tool listed first in TopHeaviest")
+ assert.Greater(t, est.TopHeaviest[0].Chars, est.TopHeaviest[1].Chars)
+ assert.Greater(t, est.TopHeaviest[1].Chars, est.TopHeaviest[2].Chars)
+}
+
+func TestScoreMCPTokenEfficiency_FullMarksForLeanSurface(t *testing.T) {
+ dir := writeMCPTools(t, `
+ s.AddTool(mcplib.NewTool("get", mcplib.WithDescription("get item")), nil)
+ s.AddTool(mcplib.NewTool("list", mcplib.WithDescription("list items")), nil)
+`)
+ score, scored := scoreMCPTokenEfficiency(dir)
+ assert.True(t, scored)
+ assert.Equal(t, 10, score, "small per-tool size earns full marks")
+}
+
+func TestScoreMCPTokenEfficiency_NotScoredWhenNoMCP(t *testing.T) {
+ dir := t.TempDir()
+ score, scored := scoreMCPTokenEfficiency(dir)
+ assert.False(t, scored, "missing MCP surface must be unscored, not zero-scored")
+ assert.Equal(t, 0, score)
+}
+
+func TestScoreMCPTokenEfficiency_PenalizesBloatedSurface(t *testing.T) {
+ // One tool with a ~1600-char description → ~400 tokens, above the
+ // 320-token ceiling → 0 score.
+ dir := writeMCPTools(t, `
+ s.AddTool(mcplib.NewTool("bloated", mcplib.WithDescription("`+strings.Repeat("x", 1600)+`")), nil)
+`)
+ score, scored := scoreMCPTokenEfficiency(dir)
+ assert.True(t, scored)
+ assert.Equal(t, 0, score, "oversized per-tool description scores zero")
+}
+
+func TestScoreMCPTokenEfficiency_PartialCreditMedium(t *testing.T) {
+ // One tool at ~500 chars → ~125 tokens → partial credit (7)
+ dir := writeMCPTools(t, `
+ s.AddTool(mcplib.NewTool("medium", mcplib.WithDescription("`+strings.Repeat("x", 500)+`")), nil)
+`)
+ score, scored := scoreMCPTokenEfficiency(dir)
+ assert.True(t, scored)
+ assert.Equal(t, 7, score)
+}
diff --git a/internal/pipeline/naming_rules.go b/internal/pipeline/naming_rules.go
new file mode 100644
index 00000000..c39cf781
--- /dev/null
+++ b/internal/pipeline/naming_rules.go
@@ -0,0 +1,55 @@
+package pipeline
+
+// NamingRule captures a banned name and the canonical alternative for
+// agent-facing CLI surfaces (command verbs and flag names). Agents expect
+// consistency: if one printed CLI uses `info` and another uses `get`,
+// trained behavior on one hallucinates commands on the other.
+//
+// The rule catalog is intentionally small at the start. Retros add entries
+// as real drift patterns surface.
+//
+// Categories:
+// - "verb": applies to the first token of a cobra `Use:` declaration
+// - "flag": applies to long-form flag names as registered (e.g. `--force`)
+type NamingRule struct {
+ Banned string
+ Preferred string
+ Category string
+ Reason string
+}
+
+// namingRules is the canonical set of banned → preferred naming pairs
+// enforced by the dogfood naming-consistency check. Inspired by Cloudflare's
+// schema-layer guardrails documented in their 2026-04-13 Wrangler CLI post:
+// "It's always get, never info. Always --force, never --skip-confirmations."
+var namingRules = []NamingRule{
+ {
+ Banned: "info",
+ Preferred: "get",
+ Category: "verb",
+ Reason: "agents expect `get` to retrieve a single resource; `info` collides with help-style surfaces",
+ },
+ {
+ Banned: "ls",
+ Preferred: "list",
+ Category: "verb",
+ Reason: "unix shorthand `ls` is inconsistent with the rest of the command vocabulary; agents trained on `list` miss it",
+ },
+ {
+ Banned: "--skip-confirmations",
+ Preferred: "--force",
+ Category: "flag",
+ Reason: "`--force` is the cross-CLI convention for bypassing confirmation prompts",
+ },
+ {
+ Banned: "--skip-confirmation",
+ Preferred: "--force",
+ Category: "flag",
+ Reason: "`--force` is the cross-CLI convention for bypassing confirmation prompts",
+ },
+ // NOTE: `--yes` is NOT banned. It is a long-standing Unix convention
+ // (apt, dnf, etc.) and printed CLIs use it as the canonical skip-prompt
+ // flag today. Cloudflare's article bans only `--skip-confirmations`,
+ // not `--yes`. If a retro shows agents getting confused between `--yes`
+ // and `--force`, consolidate then — not speculatively here.
+}
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 22b4c81f..968601ee 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -40,19 +40,20 @@ type Scorecard struct {
// SteinerScore breaks down the Steinberger bar into 11 dimensions, each 0-10.
type SteinerScore struct {
- OutputModes int `json:"output_modes"` // 0-10
- Auth int `json:"auth"` // 0-10
- ErrorHandling int `json:"error_handling"` // 0-10
- TerminalUX int `json:"terminal_ux"` // 0-10
- README int `json:"readme"` // 0-10
- Doctor int `json:"doctor"` // 0-10
- AgentNative int `json:"agent_native"` // 0-10
- MCPQuality int `json:"mcp_quality"` // 0-10
- LocalCache int `json:"local_cache"` // 0-10
- Breadth int `json:"breadth"` // 0-10: how many commands (penalizes empty CLIs)
- Vision int `json:"vision"` // 0-10
- Workflows int `json:"workflows"` // 0-10
- Insight int `json:"insight"` // 0-10
+ OutputModes int `json:"output_modes"` // 0-10
+ Auth int `json:"auth"` // 0-10
+ ErrorHandling int `json:"error_handling"` // 0-10
+ TerminalUX int `json:"terminal_ux"` // 0-10
+ README int `json:"readme"` // 0-10
+ Doctor int `json:"doctor"` // 0-10
+ AgentNative int `json:"agent_native"` // 0-10
+ MCPQuality int `json:"mcp_quality"` // 0-10
+ MCPTokenEff int `json:"mcp_token_efficiency"` // 0-10; unscored when no MCP surface
+ LocalCache int `json:"local_cache"` // 0-10
+ Breadth int `json:"breadth"` // 0-10: how many commands (penalizes empty CLIs)
+ Vision int `json:"vision"` // 0-10
+ Workflows int `json:"workflows"` // 0-10
+ Insight int `json:"insight"` // 0-10
// Tier 2: Domain Correctness (semantic checks)
PathValidity int `json:"path_validity"` // 0-10
AuthProtocol int `json:"auth_protocol"` // 0-10
@@ -90,6 +91,11 @@ func RunScorecard(outputDir, pipelineDir, specPath string, verifyReport *VerifyR
sc.Steinberger.Doctor = scoreDoctor(outputDir)
sc.Steinberger.AgentNative = scoreAgentNative(outputDir)
sc.Steinberger.MCPQuality = scoreMCPQuality(outputDir)
+ if mcpTokenScore, scored := scoreMCPTokenEfficiency(outputDir); scored {
+ sc.Steinberger.MCPTokenEff = mcpTokenScore
+ } else {
+ sc.UnscoredDimensions = append(sc.UnscoredDimensions, "mcp_token_efficiency")
+ }
sc.Steinberger.LocalCache = scoreLocalCache(outputDir)
sc.Steinberger.Breadth = scoreBreadth(outputDir)
sc.Steinberger.Vision = scoreVision(outputDir)
@@ -130,7 +136,7 @@ func RunScorecard(outputDir, pipelineDir, specPath string, verifyReport *VerifyR
sc.Steinberger.TypeFidelity = scoreTypeFidelity(outputDir)
sc.Steinberger.DeadCode = scoreDeadCode(outputDir)
- // Tier 1: Infrastructure (string-matching, 130 max)
+ // Tier 1: Infrastructure (string-matching, 140 max; 130 when no MCP surface)
tier1Raw := sc.Steinberger.OutputModes +
sc.Steinberger.Auth +
sc.Steinberger.ErrorHandling +
@@ -139,6 +145,7 @@ func RunScorecard(outputDir, pipelineDir, specPath string, verifyReport *VerifyR
sc.Steinberger.Doctor +
sc.Steinberger.AgentNative +
sc.Steinberger.MCPQuality +
+ sc.Steinberger.MCPTokenEff +
sc.Steinberger.LocalCache +
sc.Steinberger.Breadth +
sc.Steinberger.Vision +
@@ -160,8 +167,13 @@ func RunScorecard(outputDir, pipelineDir, specPath string, verifyReport *VerifyR
sc.Steinberger.TypeFidelity +
sc.Steinberger.DeadCode
- // Weighted composite: Tier 1 = 50%, Tier 2 = 50% of final 100-point scale
- tier1Normalized := (tier1Raw * 50) / 130 // scale 0-130 to 0-50
+ // Weighted composite: Tier 1 = 50%, Tier 2 = 50% of final 100-point scale.
+ // Tier 1 max is 140 with MCP, 130 without (mcp_token_efficiency unscored).
+ tier1Max := 140
+ if sc.IsDimensionUnscored("mcp_token_efficiency") {
+ tier1Max = 130
+ }
+ tier1Normalized := (tier1Raw * 50) / tier1Max // scale 0-tier1Max to 0-50
tier2Max := 50
if sc.IsDimensionUnscored("path_validity") {
tier2Max -= 10
@@ -1985,6 +1997,7 @@ func buildGapReport(s SteinerScore, unscored []string) []string {
{"doctor", s.Doctor},
{"agent_native", s.AgentNative},
{"mcp_quality", s.MCPQuality},
+ {"mcp_token_efficiency", s.MCPTokenEff},
{"local_cache", s.LocalCache},
{"breadth", s.Breadth},
{"vision", s.Vision},
@@ -2114,6 +2127,7 @@ func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
{"Doctor", "doctor", s.Doctor},
{"Agent Native", "agent_native", s.AgentNative},
{"MCP Quality", "mcp_quality", s.MCPQuality},
+ {"MCP Token Efficiency", "mcp_token_efficiency", s.MCPTokenEff},
{"Local Cache", "local_cache", s.LocalCache},
{"Breadth", "breadth", s.Breadth},
{"Vision", "vision", s.Vision},
diff --git a/internal/pipeline/scorecard_tier2_test.go b/internal/pipeline/scorecard_tier2_test.go
index 69aba6bd..8eeb91bb 100644
--- a/internal/pipeline/scorecard_tier2_test.go
+++ b/internal/pipeline/scorecard_tier2_test.go
@@ -514,7 +514,7 @@ func runLinks() string {
pipelineDir := t.TempDir()
sc, err := RunScorecard(dir, pipelineDir, "", nil)
assert.NoError(t, err)
- assert.ElementsMatch(t, []string{"path_validity", "auth_protocol"}, sc.UnscoredDimensions)
+ assert.ElementsMatch(t, []string{"mcp_token_efficiency", "path_validity", "auth_protocol"}, sc.UnscoredDimensions)
assert.NotContains(t, sc.GapReport, "path_validity scored 0/10 - needs improvement")
assert.NotContains(t, sc.GapReport, "auth_protocol scored 0/10 - needs improvement")
})
@@ -843,7 +843,7 @@ func runLinks() string {
body := string(data)
assert.True(t, strings.Contains(body, `"path_validity":0`))
assert.True(t, strings.Contains(body, `"auth_protocol":0`))
- assert.True(t, strings.Contains(body, `"unscored_dimensions":["path_validity","auth_protocol"]`))
+ assert.True(t, strings.Contains(body, `"unscored_dimensions":["mcp_token_efficiency","path_validity","auth_protocol"]`))
})
}
← 6f8b0c7d fix(skills): Phase 4.85 prompt refinements from calibration
·
back to Cli Printing Press
·
docs(cli): plan HeyGen CLI learnings application d4297f63 →