← back to Cli Printing Press
fix(cli): address Cal.com retro findings — scoring, templates, parser, dogfood (#133)
95c96c4de334f76e6aae8513d9aa48e1c86f7405 · 2026-04-05 14:45:23 -0700 · Trevin Chow
* fix(cli): address Cal.com retro findings — token masking, dead code, wiring check, operationId normalization, endpoint limit
- Add maskToken() to client.go.tmpl for safe token display in dry-run and error output (+2 auth scorecard points)
- Add HasDataLayer conditional to helpers.go.tmpl so provenance helpers are only emitted for CLIs with a data layer
- Replace help-text scraping in dogfood wiring check with static source analysis (eliminates false positives on deeply nested commands)
- Strip controller class names and embedded version dates (YYYY_MM_DD) from operationId-derived command names
- Make endpoint-per-resource limit configurable via --max-endpoints-per-resource flag (default 50)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): skip dead parent/endpoint files for promoted resources
When a resource has a promoted command, the generator was still emitting
the resource parent file and promoted endpoint file, creating dead
constructors (e.g., newBookingsCmd, newBookingsGetCmd never called).
Now skips: (1) resource parent files for promoted resources, (2) the
promoted endpoint file (its logic is inlined in the promoted RunE),
(3) single-endpoint sub-resource parents under promoted resources
(the promoted command wires the endpoint directly).
Cal.com dogfood: 14 unregistered → 2 (genuine spec-specific auth endpoints).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): add --spec-url provenance flag and compute spec checksum on input
The manifest previously only recorded spec_url when --spec was a URL,
losing provenance when the skill downloaded to /tmp/ first. Now:
- --spec-url flag explicitly records the original URL for provenance
- Spec checksum is computed from the actual input file (not just cached copies)
- Both spec_url and spec_path are populated when both are known
Also fixes promoted resource dead code: skip generating parent and
promoted endpoint files that would never be wired.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): archive input spec in generated CLI directory for reproducibility
Copies the raw input spec to spec.json alongside .printing-press.json.
The spec URL may change or disappear (as happened with Cal.com's moved
docs endpoint), so the local copy is the only guaranteed way to
regenerate from the exact same input. The manifest's spec_checksum
lets you verify whether the remote has drifted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
M internal/cli/root.goM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/client.go.tmplM internal/generator/templates/helpers.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/climanifest.goM internal/pipeline/dogfood.goM internal/pipeline/dogfood_test.go
Diff
commit 95c96c4de334f76e6aae8513d9aa48e1c86f7405
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun Apr 5 14:45:23 2026 -0700
fix(cli): address Cal.com retro findings — scoring, templates, parser, dogfood (#133)
* fix(cli): address Cal.com retro findings — token masking, dead code, wiring check, operationId normalization, endpoint limit
- Add maskToken() to client.go.tmpl for safe token display in dry-run and error output (+2 auth scorecard points)
- Add HasDataLayer conditional to helpers.go.tmpl so provenance helpers are only emitted for CLIs with a data layer
- Replace help-text scraping in dogfood wiring check with static source analysis (eliminates false positives on deeply nested commands)
- Strip controller class names and embedded version dates (YYYY_MM_DD) from operationId-derived command names
- Make endpoint-per-resource limit configurable via --max-endpoints-per-resource flag (default 50)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): skip dead parent/endpoint files for promoted resources
When a resource has a promoted command, the generator was still emitting
the resource parent file and promoted endpoint file, creating dead
constructors (e.g., newBookingsCmd, newBookingsGetCmd never called).
Now skips: (1) resource parent files for promoted resources, (2) the
promoted endpoint file (its logic is inlined in the promoted RunE),
(3) single-endpoint sub-resource parents under promoted resources
(the promoted command wires the endpoint directly).
Cal.com dogfood: 14 unregistered → 2 (genuine spec-specific auth endpoints).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): add --spec-url provenance flag and compute spec checksum on input
The manifest previously only recorded spec_url when --spec was a URL,
losing provenance when the skill downloaded to /tmp/ first. Now:
- --spec-url flag explicitly records the original URL for provenance
- Spec checksum is computed from the actual input file (not just cached copies)
- Both spec_url and spec_path are populated when both are known
Also fixes promoted resource dead code: skip generating parent and
promoted endpoint files that would never be wired.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): archive input spec in generated CLI directory for reproducibility
Copies the raw input spec to spec.json alongside .printing-press.json.
The spec URL may change or disappear (as happened with Cal.com's moved
docs endpoint), so the local copy is the only guaranteed way to
regenerate from the exact same input. The manifest's spec_checksum
lets you verify whether the remote has drifted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
internal/cli/root.go | 24 +++++
internal/generator/generator.go | 115 +++++++++++++---------
internal/generator/generator_test.go | 57 +++++++++--
internal/generator/templates/client.go.tmpl | 18 ++--
internal/generator/templates/helpers.go.tmpl | 2 +
internal/openapi/parser.go | 36 ++++++-
internal/openapi/parser_test.go | 8 ++
internal/pipeline/climanifest.go | 45 ++++++---
internal/pipeline/dogfood.go | 139 +++++++++++----------------
internal/pipeline/dogfood_test.go | 98 +++++++++++++++----
10 files changed, 369 insertions(+), 173 deletions(-)
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 95673cf6..25fc23d9 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -74,6 +74,8 @@ func newGenerateCmd() *cobra.Command {
var specSource string
var clientPattern string
var researchDir string
+ var maxEndpointsPerResource int
+ var specURL string
cmd := &cobra.Command{
Use: "generate",
@@ -213,12 +215,18 @@ func newGenerateCmd() *cobra.Command {
return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--spec is required")}
}
+ if maxEndpointsPerResource > 0 {
+ openapi.SetMaxEndpointsPerResource(maxEndpointsPerResource)
+ }
+
var specs []*spec.APISpec
+ var specRawBytes [][]byte // raw spec data for archiving
for _, specFile := range specFiles {
data, err := readSpec(specFile, refresh, dryRun)
if err != nil {
return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("reading spec %s: %w", specFile, err)}
}
+ specRawBytes = append(specRawBytes, data)
var apiSpec *spec.APISpec
if openapi.IsOpenAPI(data) {
@@ -331,11 +339,25 @@ func newGenerateCmd() *cobra.Command {
if err := pipeline.WriteManifestForGenerate(pipeline.GenerateManifestParams{
APIName: apiSpec.Name,
SpecSrcs: specFiles,
+ SpecURL: specURL,
OutputDir: absOut,
}); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write manifest: %v\n", err)
}
+ // Archive the input spec alongside the CLI for reproducibility.
+ // The spec_url may change or disappear; this local copy is the
+ // only guaranteed way to regenerate from the exact same input.
+ if len(specRawBytes) > 0 {
+ archiveName := "spec.json"
+ if len(specFiles) > 0 && !openapi.IsOpenAPI(specRawBytes[0]) {
+ archiveName = "spec.yaml"
+ }
+ if err := os.WriteFile(filepath.Join(absOut, archiveName), specRawBytes[0], 0o644); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: could not archive spec: %v\n", err)
+ }
+ }
+
fmt.Fprintf(os.Stderr, "Generated %s at %s\n", naming.CLI(apiSpec.Name), absOut)
if asJSON {
if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
@@ -366,6 +388,8 @@ func newGenerateCmd() *cobra.Command {
cmd.Flags().StringVar(&specSource, "spec-source", "", "Spec provenance: official, community, sniffed, docs (affects generated client defaults like rate limiting)")
cmd.Flags().StringVar(&clientPattern, "client-pattern", "", "HTTP client pattern: rest (default), proxy-envelope (wraps requests in POST envelope)")
cmd.Flags().StringVar(&researchDir, "research-dir", "", "Pipeline directory containing research.json and discovery/ for README source credits")
+ cmd.Flags().IntVar(&maxEndpointsPerResource, "max-endpoints-per-resource", 0, "Maximum endpoints per resource (default 50, raise for large APIs)")
+ cmd.Flags().StringVar(&specURL, "spec-url", "", "Original spec URL for provenance (use when --spec is a local file downloaded from a URL)")
return cmd
}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 88725df5..b6de8a32 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -185,6 +185,7 @@ type HelperFlags struct {
HasDelete bool // spec has DELETE endpoints → emit classifyDeleteError
HasPathParams bool // spec has path parameters → emit replacePathParam
HasMultiPositional bool // spec has endpoints with 2+ positional params → emit usageErr
+ HasDataLayer bool // CLI has a local store (sync/search) → emit provenance helpers
}
// computeHelperFlags scans the spec's resources to determine which helpers are needed.
@@ -302,9 +303,11 @@ func (g *Generator) Generate() error {
case "readme.md.tmpl":
data = g.readmeData()
case "helpers.go.tmpl":
+ hFlags := computeHelperFlags(g.Spec)
+ hFlags.HasDataLayer = g.VisionSet.Store
data = &helpersTemplateData{
APISpec: g.Spec,
- HelperFlags: computeHelperFlags(g.Spec),
+ HelperFlags: hFlags,
}
default:
data = g.Spec
@@ -324,33 +327,54 @@ func (g *Generator) Generate() error {
promotedCommands := buildPromotedCommands(g.Spec)
hasPromoted := len(promotedCommands) > 0
+ // Build set of resource names that have promoted commands. Promoted commands
+ // replace the resource parent entirely — the promoted command wires sibling
+ // endpoints and sub-resources directly. Generating the unused parent would
+ // create a dead constructor (e.g., newBookingsCmd never called).
+ promotedResourceNames := make(map[string]bool)
+ // Map resource name → promoted endpoint name. The promoted command's RunE
+ // inlines this endpoint's logic, so the standalone file is dead code.
+ promotedEndpointNames := make(map[string]string)
+ for _, pc := range promotedCommands {
+ promotedResourceNames[pc.ResourceName] = true
+ promotedEndpointNames[pc.ResourceName] = pc.EndpointName
+ }
+
// Generate per-resource parent files + per-endpoint command files
// This produces more files (one per endpoint) which improves Breadth scoring
for name, resource := range g.Spec.Resources {
- // Parent file: wires subcommands together
- // When promoted commands exist, hide resource parents from --help
- parentData := struct {
- ResourceName string
- FuncPrefix string
- CommandPath string
- Resource spec.Resource
- Hidden bool
- *spec.APISpec
- }{
- ResourceName: name,
- FuncPrefix: name,
- CommandPath: name,
- Resource: resource,
- Hidden: hasPromoted,
- APISpec: g.Spec,
- }
- parentPath := filepath.Join("internal", "cli", name+".go")
- if err := g.renderTemplate("command_parent.go.tmpl", parentPath, parentData); err != nil {
- return fmt.Errorf("rendering parent command %s: %w", name, err)
+ // Skip parent file for promoted resources — the promoted command replaces it.
+ // Sub-resource parents and endpoint files are still needed (wired by the promoted command).
+ if !promotedResourceNames[name] {
+ // Parent file: wires subcommands together
+ // When promoted commands exist, hide resource parents from --help
+ parentData := struct {
+ ResourceName string
+ FuncPrefix string
+ CommandPath string
+ Resource spec.Resource
+ Hidden bool
+ *spec.APISpec
+ }{
+ ResourceName: name,
+ FuncPrefix: name,
+ CommandPath: name,
+ Resource: resource,
+ Hidden: hasPromoted,
+ APISpec: g.Spec,
+ }
+ parentPath := filepath.Join("internal", "cli", name+".go")
+ if err := g.renderTemplate("command_parent.go.tmpl", parentPath, parentData); err != nil {
+ return fmt.Errorf("rendering parent command %s: %w", name, err)
+ }
}
// Per-endpoint files
for eName, endpoint := range resource.Endpoints {
+ // Skip the promoted endpoint — its logic is inlined in the promoted command's RunE.
+ if promotedEndpointNames[name] == eName {
+ continue
+ }
epData := struct {
ResourceName string
FuncPrefix string
@@ -376,24 +400,30 @@ func (g *Generator) Generate() error {
// Sub-resource parent + endpoint files
for subName, subResource := range resource.SubResources {
- subParentData := struct {
- ResourceName string
- FuncPrefix string
- CommandPath string
- Resource spec.Resource
- Hidden bool
- *spec.APISpec
- }{
- ResourceName: subName,
- FuncPrefix: name + "-" + subName,
- CommandPath: name + " " + subName,
- Resource: subResource,
- Hidden: hasPromoted,
- APISpec: g.Spec,
- }
- subParentPath := filepath.Join("internal", "cli", name+"_"+subName+".go")
- if err := g.renderTemplate("command_parent.go.tmpl", subParentPath, subParentData); err != nil {
- return fmt.Errorf("rendering sub-parent %s/%s: %w", name, subName, err)
+ // Skip single-endpoint sub-resource parents under promoted resources.
+ // The promoted command wires the endpoint directly, making the parent dead code.
+ // Multi-endpoint sub-resource parents are still needed (the promoted command uses them).
+ skipSubParent := promotedResourceNames[name] && len(subResource.Endpoints) == 1
+ if !skipSubParent {
+ subParentData := struct {
+ ResourceName string
+ FuncPrefix string
+ CommandPath string
+ Resource spec.Resource
+ Hidden bool
+ *spec.APISpec
+ }{
+ ResourceName: subName,
+ FuncPrefix: name + "-" + subName,
+ CommandPath: name + " " + subName,
+ Resource: subResource,
+ Hidden: hasPromoted,
+ APISpec: g.Spec,
+ }
+ subParentPath := filepath.Join("internal", "cli", name+"_"+subName+".go")
+ if err := g.renderTemplate("command_parent.go.tmpl", subParentPath, subParentData); err != nil {
+ return fmt.Errorf("rendering sub-parent %s/%s: %w", name, subName, err)
+ }
}
for eName, endpoint := range subResource.Endpoints {
@@ -632,13 +662,6 @@ func (g *Generator) Generate() error {
}
}
- // Build set of resource names that have promoted commands — root.go.tmpl
- // skips registering these as separate root commands to avoid duplicate names.
- promotedResourceNames := make(map[string]bool)
- for _, pc := range promotedCommands {
- promotedResourceNames[pc.ResourceName] = true
- }
-
rootData := struct {
*spec.APISpec
VisionSet VisionTemplateSet
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index d7661bda..0983c21a 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -22,9 +22,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
specPath string
expectedFiles int
}{
- {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 34},
- {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 40},
- {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 36},
+ {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 30},
+ {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 34},
+ {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 34},
}
for _, tt := range tests {
@@ -580,6 +580,51 @@ func TestGeneratedHelpers_ConditionalClassifyDeleteError(t *testing.T) {
})
}
+func TestGeneratedHelpers_ConditionalDataLayerFunctions(t *testing.T) {
+ t.Parallel()
+
+ // A simple spec with no data-layer features. The profiler will compute
+ // VisionSet.Store = false, so HasDataLayer stays false and provenance
+ // helpers should be omitted.
+ apiSpec := &spec.APISpec{
+ Name: "testdatalayer",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "api_key", Header: "X-Api-Key", EnvVars: []string{"TEST_API_KEY"}},
+ Config: spec.ConfigSpec{Format: "toml", Path: "~/.config/testdatalayer-pp-cli/config.toml"},
+ Resources: map[string]spec.Resource{
+ "items": {
+ Description: "Manage items",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {Method: "GET", Path: "/items", Description: "List items"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "testdatalayer-pp-cli")
+ gen := New(apiSpec, outputDir)
+ // Force VisionSet with Store=false to bypass profiler (which marks
+ // read-heavy specs as offline-valuable). We're testing the template
+ // conditional, not the profiler's decision.
+ gen.VisionSet = VisionTemplateSet{Store: false, Export: true}
+ require.NoError(t, gen.Generate())
+
+ helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+ require.NoError(t, err)
+ content := string(helpersGo)
+
+ // Without data layer, provenance helpers should be omitted
+ assert.NotContains(t, content, "DataProvenance")
+ assert.NotContains(t, content, "printProvenance")
+ assert.NotContains(t, content, "wrapWithProvenance")
+ assert.NotContains(t, content, "defaultDBPath")
+
+ // Core helpers should still be present
+ assert.Contains(t, content, "classifyAPIError")
+ assert.Contains(t, content, "printOutputWithFlags")
+}
+
// --- Unit 3: Top-Level Command Promotion Tests ---
func TestToKebab(t *testing.T) {
@@ -753,12 +798,12 @@ func TestGeneratedOutput_PromotedCommandExists(t *testing.T) {
require.NoError(t, gen.Generate())
// Promoted command file SHOULD exist — it provides a user-friendly shortcut.
- // The resource group command is hidden from --help when promoted commands exist.
promotedFile := filepath.Join(outputDir, "internal", "cli", "promoted_users.go")
assert.FileExists(t, promotedFile)
- // The resource group command should still exist (but hidden)
- assert.FileExists(t, filepath.Join(outputDir, "internal", "cli", "users.go"))
+ // The resource parent command should NOT be generated — the promoted command replaces it.
+ // Generating both would leave the parent as dead code (never wired to root).
+ assert.NoFileExists(t, filepath.Join(outputDir, "internal", "cli", "users.go"))
}
func TestGeneratedOutput_PromotedCommandCompiles(t *testing.T) {
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index aec845bf..1e0f4085 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -438,12 +438,7 @@ func (c *Client) dryRun(method, targetURL, path string, params map[string]string
return nil, 0, err
}
if authHeader != "" {
- // Mask token for safety
- auth := authHeader
- if len(auth) > 20 {
- auth = auth[:15] + "..."
- }
- fmt.Fprintf(os.Stderr, " Authorization: %s\n", auth)
+ fmt.Fprintf(os.Stderr, " Authorization: %s\n", maskToken(authHeader))
}
fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
return json.RawMessage(`{"dry_run": true}`), 0, nil
@@ -566,6 +561,17 @@ func sanitizeJSONResponse(body []byte) []byte {
}
+// maskToken redacts all but the last 4 characters of a token for safe display.
+func maskToken(token string) string {
+ if token == "" {
+ return ""
+ }
+ if len(token) <= 4 {
+ return "****"
+ }
+ return "****" + token[len(token)-4:]
+}
+
func truncateBody(b []byte) string {
s := string(b)
if len(s) > 200 {
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index f39be82c..878887dc 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -1088,6 +1088,7 @@ func findField(obj map[string]any, names ...string) string {
return ""
}
+{{- if .HasDataLayer}}
// DataProvenance describes where data came from and when it was last synced.
type DataProvenance struct {
Source string `json:"source"` // "live" or "local"
@@ -1147,3 +1148,4 @@ func defaultDBPath(name string) string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", name, "data.db")
}
+{{- end}}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 0fd4a2e7..e94bdea4 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"os"
+ "regexp"
"sort"
"strconv"
"strings"
@@ -17,11 +18,21 @@ import (
"golang.org/x/text/language"
)
-const (
+var (
maxResources = 50
maxEndpointsPerResource = 50
)
+// SetMaxEndpointsPerResource overrides the default limit of 50 endpoints per
+// resource. Use this when generating from large specs (e.g., Cal.com organizations
+// which has 50+ endpoints). The limit prevents runaway generation from malformed
+// specs while allowing legitimate large resources to be fully covered.
+func SetMaxEndpointsPerResource(n int) {
+ if n > 0 {
+ maxEndpointsPerResource = n
+ }
+}
+
// stripBrokenRefs attempts to remove broken component references from a spec.
// It extracts the broken key name from the error message, finds and removes it
// from the raw JSON/YAML, then returns the cleaned data.
@@ -2046,6 +2057,7 @@ func operationIDToName(operationID, resourceName string, commonPrefix []string)
}
name := strings.TrimPrefix(original, "api_")
+ name = stripOperationIDControllerAndDate(name)
resourceVariants := operationIDResourceVariants(resourceName)
// Also strip common prefix segments (e.g., "gmail", "users" from "gmail_users_messages_list")
@@ -2120,6 +2132,28 @@ func stripOperationIDVersionPrefix(name string) string {
}
}
+// operationIDDatePattern matches embedded version dates (YYYY_MM_DD) in snake_case operationIDs.
+// These appear in specs like Cal.com: BookingsController_2024-08-13_getBooking → 2024_08_13
+var operationIDDatePattern = regexp.MustCompile(`_\d{4}_\d{2}_\d{2}_?`)
+
+// stripOperationIDControllerAndDate removes controller class names and embedded
+// version dates from operationIDs. APIs auto-generated from NestJS, FastAPI, or
+// similar frameworks include these patterns (e.g., BookingsController_2024-08-13_getBooking).
+func stripOperationIDControllerAndDate(name string) string {
+ // Strip controller segments: "_controller_" mid-name or "_controller" suffix
+ name = strings.ReplaceAll(name, "_controller_", "_")
+ name = strings.TrimSuffix(name, "_controller")
+
+ // Strip embedded version dates (YYYY_MM_DD)
+ name = operationIDDatePattern.ReplaceAllString(name, "_")
+
+ // Clean up doubled underscores from removals
+ for strings.Contains(name, "__") {
+ name = strings.ReplaceAll(name, "__", "_")
+ }
+ return strings.Trim(name, "_")
+}
+
func stripOperationIDResourcePrefix(name string, variants []string) string {
if name == "" || len(variants) == 0 {
return name
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 3a5cd159..3e5d4796 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -285,6 +285,14 @@ func TestOperationIDToName(t *testing.T) {
{operationID: "GetApplicationCommandPermissions", resourceName: "applications", want: "get-command-permissions"},
{operationID: "", resourceName: "users", want: ""},
{operationID: "list", resourceName: "users", want: "list"},
+ // Cal.com-style: controller class names + embedded version dates
+ {operationID: "BookingsController_2024-08-13_getBooking", resourceName: "bookings", want: "get"},
+ {operationID: "BookingsController_2024-08-13_createBooking", resourceName: "bookings", want: "create"},
+ {operationID: "EventTypesController_2024-06-14_getEventTypes", resourceName: "event-types", want: "get"},
+ // Controller suffix without date
+ {operationID: "OrganizationsController_getOrg", resourceName: "organizations", want: "get-org"},
+ // No controller/version pattern — should be unchanged
+ {operationID: "getBookingByUid", resourceName: "bookings", want: "get-by-uid"},
}
for _, tt := range tests {
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 4b90903b..d8646244 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -75,6 +75,7 @@ func specChecksum(path string) (string, error) {
type GenerateManifestParams struct {
APIName string
SpecSrcs []string // --spec args (URLs or file paths)
+ SpecURL string // --spec-url: explicit provenance URL (when --spec is a local downloaded file)
DocsURL string // --docs URL, if used
OutputDir string
}
@@ -101,24 +102,40 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
m.SpecURL = src
} else {
m.SpecPath = src
+ // Compute checksum and format from the actual input spec file.
+ if data, err := os.ReadFile(src); err == nil {
+ m.SpecFormat = detectSpecFormat(data)
+ h := sha256.Sum256(data)
+ m.SpecChecksum = "sha256:" + hex.EncodeToString(h[:])
+ }
}
}
- // Detect format and checksum from any spec file cached in the output dir.
- for _, name := range []string{"spec.json", "spec.yaml", "spec.yml"} {
- specFile := filepath.Join(p.OutputDir, name)
- data, err := os.ReadFile(specFile)
- if err != nil {
- continue
- }
- if m.SpecFormat == "" {
- m.SpecFormat = detectSpecFormat(data)
- }
- cs, err := specChecksum(specFile)
- if err == nil {
- m.SpecChecksum = cs
+ // Explicit --spec-url overrides: when the user passed a local file that was
+ // downloaded from a URL, record the original URL for reproducibility.
+ if p.SpecURL != "" {
+ m.SpecURL = p.SpecURL
+ }
+
+ // Fallback: detect format and checksum from any spec file cached in the output dir.
+ if m.SpecFormat == "" || m.SpecChecksum == "" {
+ for _, name := range []string{"spec.json", "spec.yaml", "spec.yml"} {
+ specFile := filepath.Join(p.OutputDir, name)
+ data, err := os.ReadFile(specFile)
+ if err != nil {
+ continue
+ }
+ if m.SpecFormat == "" {
+ m.SpecFormat = detectSpecFormat(data)
+ }
+ if m.SpecChecksum == "" {
+ cs, err := specChecksum(specFile)
+ if err == nil {
+ m.SpecChecksum = cs
+ }
+ }
+ break
}
- break
}
// Look up catalog entry for category/description enrichment.
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 1108bd77..88329148 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -954,117 +954,90 @@ func checkWiring(dir string) WiringCheckResult {
}
}
-// checkCommandTree scans internal/cli/*.go for func new*Cmd() patterns,
-// then runs the built binary's --help to see which commands are registered.
-// Any newXxxCmd() whose command name doesn't appear in help is flagged.
+// checkCommandTree scans internal/cli/*.go for command constructor functions
+// (func newXxxCmd) and verifies each is wired via an AddCommand call somewhere
+// in the source. This is pure static analysis — no binary build or help-text
+// parsing needed — and correctly handles deeply nested command hierarchies that
+// help-text scraping misses.
func checkCommandTree(dir string) CommandTreeResult {
result := CommandTreeResult{}
cliDir := filepath.Join(dir, "internal", "cli")
files := listGoFiles(cliDir)
- // Scan for cobra Use: fields to get the actual command names users see.
- // Previous approach derived names from Go function names (newXxxCmd → xxx),
- // but function names encode file hierarchy (e.g., newBookingsPromotedCmd)
- // while Use: fields are the actual cobra names (e.g., "bookings").
+ // Phase 1: Find all command constructor definitions and their Use: names.
+ // A constructor is func newXxxCmd(...) — we extract both the function name
+ // and the cobra Use: field (the command name users see).
+ constructorRe := regexp.MustCompile(`(?m)^func\s+(new\w+Cmd)\s*\(`)
useFieldRe := regexp.MustCompile(`(?m)Use:\s*"([^"\s]+)`)
- definedCmds := make(map[string]struct{})
+
+ type cmdDef struct {
+ constructor string // e.g. "newBookingsCmd"
+ useName string // e.g. "bookings"
+ }
+
+ var allDefs []cmdDef
+ allSource := strings.Builder{}
+
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
continue
}
- matches := useFieldRe.FindAllStringSubmatch(string(data), -1)
- for _, match := range matches {
- // Extract just the command name (before any positional args)
- cmdName := strings.Fields(match[1])[0]
- if cmdName != "" {
- definedCmds[cmdName] = struct{}{}
+ content := string(data)
+ allSource.WriteString(content)
+ allSource.WriteString("\n")
+
+ // Find constructors in this file and pair with their Use: field
+ constructors := constructorRe.FindAllStringSubmatch(content, -1)
+ useFields := useFieldRe.FindAllStringSubmatch(content, -1)
+
+ // Build a lookup of Use: names for this file
+ var useNames []string
+ for _, m := range useFields {
+ name := strings.Fields(m[1])[0]
+ if name != "" {
+ useNames = append(useNames, name)
}
}
- }
-
- result.Defined = len(definedCmds)
- if result.Defined == 0 {
- return result
- }
- // Try to build the binary and get help output
- cliName := findCLIName(dir)
- if cliName == "" {
- // Can't verify without a binary, treat all as registered
- result.Registered = result.Defined
- return result
- }
-
- binaryPath, err := buildDogfoodBinary(dir, cliName)
- if err != nil {
- result.Registered = result.Defined
- return result
+ for i, m := range constructors {
+ funcName := m[1]
+ // Skip the root command constructor — it's not added via AddCommand
+ if funcName == "newRootCmd" {
+ continue
+ }
+ useName := funcName
+ if i < len(useNames) {
+ useName = useNames[i]
+ }
+ allDefs = append(allDefs, cmdDef{constructor: funcName, useName: useName})
+ }
}
- defer func() { _ = os.Remove(binaryPath) }()
- helpOut, err := runDogfoodCmd(binaryPath, 15*time.Second, "--help")
- if err != nil {
- result.Registered = result.Defined
+ result.Defined = len(allDefs)
+ if result.Defined == 0 {
return result
}
- // Recursively gather help output from all command levels.
- // Many CLIs have 3+ levels (e.g., bookings → attendees → add).
- // Only checking two levels misses nested subcommands.
- helpLower := strings.ToLower(helpOut)
- var walkHelp func(parentArgs []string, depth int)
- walkHelp = func(parentArgs []string, depth int) {
- if depth > 4 {
- return // safety cap
- }
- args := append(parentArgs, "--help")
- out, err := runDogfoodCmd(binaryPath, 15*time.Second, args...)
- if err != nil {
- return
- }
- helpLower += "\n" + strings.ToLower(out)
- for _, sub := range extractCommandNames(out) {
- walkHelp(append(parentArgs, sub), depth+1)
- }
- }
- for _, topCmd := range extractCommandNames(helpOut) {
- walkHelp([]string{topCmd}, 1)
- }
-
- for cmdName := range definedCmds {
- if commandFoundInHelp(helpLower, cmdName) {
+ // Phase 2: Check which constructors are called from other functions.
+ // A constructor is "wired" if it appears as a call (funcName + "(") outside
+ // its own definition. This catches both direct AddCommand(newXxxCmd(...))
+ // and indirect patterns like: sub := newXxxCmd(flags); cmd.AddCommand(sub).
+ source := allSource.String()
+ for _, def := range allDefs {
+ // Count occurrences of "constructorName(" in all source.
+ // >=2 means at least one call site beyond the func definition itself.
+ if strings.Count(source, def.constructor+"(") >= 2 {
result.Registered++
} else {
- result.Unregistered = append(result.Unregistered, cmdName)
+ result.Unregistered = append(result.Unregistered, def.useName)
}
}
sort.Strings(result.Unregistered)
return result
}
-// commandFoundInHelp checks if a command name (kebab-case) appears in help output.
-// For compound names like "api-get-category", the function name encodes the parent-child
-// hierarchy (newApiGetCategoryCmd → api-get-category), but help output shows them
-// separately: "api" at top level and "get-category" in the api subcommand help.
-// This function checks the full name first, then progressively strips leading segments
-// to match subcommand names in their parent's help output.
-func commandFoundInHelp(helpLower, cmdName string) bool {
- if strings.Contains(helpLower, cmdName) {
- return true
- }
- // Try suffix matching: "api-get-category" → "get-category" → "category"
- parts := strings.SplitN(cmdName, "-", 2)
- for len(parts) == 2 && parts[1] != "" {
- if strings.Contains(helpLower, parts[1]) {
- return true
- }
- parts = strings.SplitN(parts[1], "-", 2)
- }
- return false
-}
-
// extractCommandNames extracts command names from cobra --help output.
// It looks for the "Available Commands:" section.
func extractCommandNames(helpOutput string) []string {
diff --git a/internal/pipeline/dogfood_test.go b/internal/pipeline/dogfood_test.go
index 35deec66..a37e620d 100644
--- a/internal/pipeline/dogfood_test.go
+++ b/internal/pipeline/dogfood_test.go
@@ -330,48 +330,112 @@ func TestCheckCommandTree(t *testing.T) {
cliDir := filepath.Join(dir, "internal", "cli")
require.NoError(t, os.MkdirAll(cliDir, 0o755))
- // Define commands with cobra Use: fields (what users see in --help)
+ // Two constructors wired via AddCommand, one unwired
+ writeTestFile(t, filepath.Join(cliDir, "root.go"), `package cli
+func newRootCmd() {
+ rootCmd.AddCommand(newFooCmd())
+ rootCmd.AddCommand(newBarCmd())
+}
+`)
writeTestFile(t, filepath.Join(cliDir, "foo.go"), `package cli
func newFooCmd() { cmd := &cobra.Command{Use: "foo"} }
`)
writeTestFile(t, filepath.Join(cliDir, "bar.go"), `package cli
func newBarCmd() { cmd := &cobra.Command{Use: "bar"} }
+`)
+ writeTestFile(t, filepath.Join(cliDir, "orphan.go"), `package cli
+func newOrphanCmd() { cmd := &cobra.Command{Use: "orphan"} }
`)
- // Without a buildable binary, checkCommandTree can't verify registration,
- // so it treats all as registered. We test the scanning logic directly.
result := checkCommandTree(dir)
- assert.Equal(t, 2, result.Defined)
- // No cmd/ directory means no binary, so all treated as registered
+ assert.Equal(t, 3, result.Defined) // foo, bar, orphan (root excluded)
assert.Equal(t, 2, result.Registered)
- assert.Empty(t, result.Unregistered)
+ assert.Equal(t, []string{"orphan"}, result.Unregistered)
}
-func TestCheckCommandTree_UseFieldExtraction(t *testing.T) {
+func TestCheckCommandTree_DeeplyNested(t *testing.T) {
dir := t.TempDir()
cliDir := filepath.Join(dir, "internal", "cli")
require.NoError(t, os.MkdirAll(cliDir, 0o755))
- // Use: fields may include positional args — we extract just the command name
- writeTestFile(t, filepath.Join(cliDir, "api.go"), `package cli
-cmd := &cobra.Command{Use: "get-category <id>"}
-cmd2 := &cobra.Command{Use: "list-teams"}
+ // Simulate deeply nested commands like Cal.com's organizations hierarchy.
+ // All are wired via AddCommand — static analysis should find them all.
+ writeTestFile(t, filepath.Join(cliDir, "root.go"), `package cli
+func newRootCmd() {
+ rootCmd.AddCommand(newOrganizationsCmd(&flags))
+}
+`)
+ writeTestFile(t, filepath.Join(cliDir, "organizations.go"), `package cli
+func newOrganizationsCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{Use: "organizations"}
+ cmd.AddCommand(newOrgAttributesCmd(flags))
+ cmd.AddCommand(newOrgRolesCmd(flags))
+ cmd.AddCommand(newOrgOooCmd(flags))
+ return cmd
+}
`)
- writeTestFile(t, filepath.Join(cliDir, "auth.go"), `package cli
-cmd := &cobra.Command{Use: "login"}
+ writeTestFile(t, filepath.Join(cliDir, "org_attributes.go"), `package cli
+func newOrgAttributesCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{Use: "attributes"}
+ return cmd
+}
+`)
+ writeTestFile(t, filepath.Join(cliDir, "org_roles.go"), `package cli
+func newOrgRolesCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{Use: "roles"}
+ return cmd
+}
`)
- writeTestFile(t, filepath.Join(cliDir, "sync.go"), `package cli
-cmd := &cobra.Command{Use: "sync"}
+ writeTestFile(t, filepath.Join(cliDir, "org_ooo.go"), `package cli
+func newOrgOooCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{Use: "ooo"}
+ return cmd
+}
`)
result := checkCommandTree(dir)
- // Should find 4 defined commands: get-category, list-teams, login, sync
+ // 4 non-root constructors, all wired
assert.Equal(t, 4, result.Defined)
- // Without a buildable binary, all treated as registered
assert.Equal(t, 4, result.Registered)
assert.Empty(t, result.Unregistered)
}
+func TestCheckCommandTree_IndirectWiring(t *testing.T) {
+ dir := t.TempDir()
+ cliDir := filepath.Join(dir, "internal", "cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ // Test indirect wiring: sub := newXxxCmd(flags); cmd.AddCommand(sub)
+ // This pattern is used by command_promoted.go.tmpl for multi-endpoint subresources.
+ writeTestFile(t, filepath.Join(cliDir, "root.go"), `package cli
+func newRootCmd() {
+ rootCmd.AddCommand(newParentCmd(&flags))
+}
+`)
+ writeTestFile(t, filepath.Join(cliDir, "parent.go"), `package cli
+func newParentCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{Use: "parent"}
+ {
+ sub := newChildCmd(flags)
+ sub.Hidden = false
+ cmd.AddCommand(sub)
+ }
+ return cmd
+}
+`)
+ writeTestFile(t, filepath.Join(cliDir, "child.go"), `package cli
+func newChildCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{Use: "child"}
+ return cmd
+}
+`)
+
+ result := checkCommandTree(dir)
+ assert.Equal(t, 2, result.Defined) // parent + child (root excluded)
+ assert.Equal(t, 2, result.Registered)
+ assert.Empty(t, result.Unregistered)
+}
+
func TestCheckConfigConsistency(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
← eae73f44 fix(skills): improve retro issue format — priority sections,
·
back to Cli Printing Press
·
feat(cli): auto-calibrate endpoint-per-resource limit from s dd5abb1f →