← back to Cli Printing Press
fix(cli): retro fixes from trigger-dev generation (#159)
f9e6c108be3c9b94dd5a9e78c6efaacc55934e23 · 2026-04-11 00:37:33 -0400 · Matt Van Horn
Four fixes from the Trigger.dev retro (issue #158):
1. Add internal YAML spec support to scorer tools (dogfood, verify,
scorecard). New spec_detect.go detects format and routes internal
YAML through spec.Parse() while preserving existing OpenAPI paths.
2. Fix unused variable in config.go template. Wrap os.ReadFile inside
the TOML format conditional so non-TOML configs don't produce
go vet failures.
3. Lower data gravity threshold from 8 to 4 for typed column extraction
in schema_builder.go. More entities now get typed columns beyond
id/data/synced_at, improving Type Fidelity scores.
4. Expand sync resource selection in profiler to include resources with
parameterless list endpoints regardless of pagination support.
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-04-09-003-fix-retro-trigger-dev-template-scorer-fixes-plan.mdA docs/retros/2026-04-09-trigger-dev-retro.mdM internal/generator/schema_builder.goM internal/generator/templates/config.go.tmplM internal/pipeline/dogfood.goM internal/pipeline/scorecard.goA internal/pipeline/spec_detect.goM internal/profiler/profiler.goM internal/profiler/profiler_test.go
Diff
commit f9e6c108be3c9b94dd5a9e78c6efaacc55934e23
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date: Sat Apr 11 00:37:33 2026 -0400
fix(cli): retro fixes from trigger-dev generation (#159)
Four fixes from the Trigger.dev retro (issue #158):
1. Add internal YAML spec support to scorer tools (dogfood, verify,
scorecard). New spec_detect.go detects format and routes internal
YAML through spec.Parse() while preserving existing OpenAPI paths.
2. Fix unused variable in config.go template. Wrap os.ReadFile inside
the TOML format conditional so non-TOML configs don't produce
go vet failures.
3. Lower data gravity threshold from 8 to 4 for typed column extraction
in schema_builder.go. More entities now get typed columns beyond
id/data/synced_at, improving Type Fidelity scores.
4. Expand sync resource selection in profiler to include resources with
parameterless list endpoints regardless of pagination support.
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...retro-trigger-dev-template-scorer-fixes-plan.md | 236 +++++++++++++++++++++
docs/retros/2026-04-09-trigger-dev-retro.md | 171 +++++++++++++++
internal/generator/schema_builder.go | 4 +-
internal/generator/templates/config.go.tmpl | 4 +-
internal/pipeline/dogfood.go | 7 +
internal/pipeline/scorecard.go | 11 +
internal/pipeline/spec_detect.go | 130 ++++++++++++
internal/profiler/profiler.go | 17 ++
internal/profiler/profiler_test.go | 88 ++++++++
9 files changed, 664 insertions(+), 4 deletions(-)
diff --git a/docs/plans/2026-04-09-003-fix-retro-trigger-dev-template-scorer-fixes-plan.md b/docs/plans/2026-04-09-003-fix-retro-trigger-dev-template-scorer-fixes-plan.md
new file mode 100644
index 00000000..860d9069
--- /dev/null
+++ b/docs/plans/2026-04-09-003-fix-retro-trigger-dev-template-scorer-fixes-plan.md
@@ -0,0 +1,236 @@
+---
+title: "fix: Retro trigger-dev template and scorer fixes"
+type: fix
+status: active
+date: 2026-04-09
+origin: docs/retros/2026-04-09-trigger-dev-retro.md
+---
+
+# fix: Retro trigger-dev template and scorer fixes
+
+## Overview
+
+Four fixes from the Trigger.dev retro (issue #158): scorer tools can't parse internal YAML specs, config.go template produces unused variable, store template doesn't populate typed columns from spec response fields, and sync template under-selects syncable resources.
+
+## Problem Frame
+
+The Printing Press generates CLIs that consistently hit the same friction points: go vet failures from template bugs, scorer tools that reject valid specs, and data layers that store everything as JSON blobs. These are generator-level issues that affect every future CLI, not just Trigger.dev.
+
+## Requirements Trace
+
+- R1. Dogfood, verify, and scorecard accept internal YAML specs the generator accepts
+- R2. Generated config.go passes `go vet` without manual fixes
+- R3. Per-entity tables include typed columns from spec response fields
+- R4. All resources with simple GET list endpoints are included in default sync
+
+## Scope Boundaries
+
+- No changes to the internal YAML spec format itself
+- No changes to the printing-press skill instructions (SKILL.md)
+- Typed columns (R3) only for resources whose spec defines response fields - JSON blob fallback preserved
+- Sync expansion (R4) only for resources with parameterless list paths - compound-path resources deferred
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/spec/spec.go` - `APISpec.Validate()` enforces "at least one resource" (line 282). `Parse()` and `ParseBytes()` are the internal YAML entry points.
+- `internal/pipeline/dogfood.go` - `loadDogfoodOpenAPISpec()` only tries OpenAPI parsers (line 259-282). No internal YAML path.
+- `internal/pipeline/scorecard.go` - `loadOpenAPISpec()` does raw JSON unmarshal. No YAML support.
+- `internal/generator/templates/config.go.tmpl` - `os.ReadFile` call at line 54, data only used inside TOML conditional.
+- `internal/generator/templates/store.go.tmpl` - Already has `.Columns` iteration (lines 79-125) and `.FTS5Fields` support. Infrastructure exists; profiler needs to populate it.
+- `internal/generator/templates/sync.go.tmpl` - `.SyncableResources` drives both `defaultSyncResources()` and `syncResourcePath()` (lines 462-483).
+- `internal/generator/generator.go` - Profiler populates template data including `SyncableResources`, `Tables`, and `Columns`.
+- `internal/spec/spec_test.go` - `TestValidation` already tests "no resources" (line 70).
+- `internal/generator/generator_test.go` - `TestGenerateProjectsCompile` runs end-to-end compilation.
+
+### Institutional Learnings
+
+- ESPN and Postman retros both found empty sync resources (F4/F5 in ESPN retro, Finding #2 in Postman). This is a recurring pattern, not a one-off.
+- Pagliacci retro found auth discovery gaps that better spec parsing in scorer tools would help validate.
+
+## Key Technical Decisions
+
+- **Add internal YAML format detection to scorer spec loading**: Rather than making scorers call `spec.Parse()` directly (which would require them to understand the full `APISpec` model), add a format-detection layer that routes YAML specs through `spec.Parse()` and extracts the paths and auth into the existing `openAPISpec` struct the scorers already use. This keeps the scorer interface unchanged.
+
+- **Config template: conditional ReadFile**: Wrap the `os.ReadFile` call inside the format conditional rather than always reading and sometimes ignoring. This eliminates the unused variable for non-TOML formats without changing behavior.
+
+- **Typed columns via profiler, not template changes**: The store template already iterates `.Columns`. The fix is in the profiler (generator.go) - when building table definitions, extract response fields from the spec and convert them to typed columns. The template needs no changes.
+
+- **Sync resource inclusion: relax path-parameter check**: The profiler currently excludes resources whose list endpoint has path parameters. Relax this to include resources whose list path has no parameters (simple `GET /api/v1/<resource>`) and exclude only resources with compound parameters. Log excluded resources with a comment explaining why.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Q: Do scorer tools need the full APISpec model?** No. They only need paths and auth. The fix routes internal YAML specs through `spec.Parse()` but then extracts just the fields scorers need into the existing `openAPISpec` struct.
+
+- **Q: Does the store template need changes for typed columns?** No. The template already has `.Columns` iteration. Only the profiler needs to populate the columns from spec response fields.
+
+### Deferred to Implementation
+
+- **Q: What Go types should spec response fields map to?** String fields -> TEXT, integer fields -> INTEGER, boolean -> INTEGER, datetime/timestamp -> DATETIME, everything else -> TEXT. Exact mapping to be determined from the spec's type system.
+
+- **Q: Should per-entity FTS indexes be generated from typed columns?** Likely yes (WU-3 in the retro mentions this), but this can be a follow-up if the typed column work is complex enough on its own.
+
+## Implementation Units
+
+- [ ] **Unit 1: Add internal YAML spec support to scorer tools**
+
+**Goal:** Dogfood, verify, and scorecard accept internal YAML specs
+
+**Requirements:** R1
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/pipeline/dogfood.go` (add YAML format detection in `loadDogfoodOpenAPISpec`)
+- Modify: `internal/pipeline/scorecard.go` (add YAML format detection in `loadOpenAPISpec`)
+- Test: `internal/pipeline/dogfood_test.go` or new `internal/pipeline/spec_loading_test.go`
+
+**Approach:**
+- Add a format detection function that checks whether the spec bytes start with `name:` or `resources:` (internal YAML markers) vs `openapi:` or `swagger:` (OpenAPI markers) vs JSON (`{` or `[`)
+- When internal YAML is detected, route through `spec.Parse()` to get an `APISpec`, then convert `APISpec.Resources` into the `openAPISpec` struct's `Paths` and `Auth` fields
+- This conversion function can be shared across dogfood, verify, and scorecard
+- When OpenAPI is detected, use the existing `ParseLenient()` path unchanged
+
+**Patterns to follow:**
+- `spec.Parse()` in `internal/spec/spec.go` for how internal YAML is parsed
+- `loadDogfoodOpenAPISpec()` in `internal/pipeline/dogfood.go` for the existing fallback pattern
+- `collectDogfoodSpecPaths()` for how OpenAPI paths are collected into the scorer format
+
+**Test scenarios:**
+- Happy path: Pass an internal YAML spec to dogfood, verify spec loads with correct paths and auth
+- Happy path: Pass an OpenAPI spec to dogfood, verify existing behavior unchanged
+- Edge case: Pass an empty YAML file, verify graceful error
+- Edge case: Pass a YAML file with `name:` but no `resources:`, verify "at least one resource" error propagates clearly
+- Integration: Run `printing-press dogfood --dir <test-cli> --spec <internal-yaml>` and verify it produces command-level results
+
+**Verification:**
+- `printing-press dogfood --spec <trigger-dev-spec.yaml>` no longer errors with "at least one resource"
+- `printing-press verify --spec <trigger-dev-spec.yaml>` produces pass/fail results per command
+- `printing-press scorecard --spec <trigger-dev-spec.yaml>` produces a score
+- Existing OpenAPI spec flows are unbroken
+
+- [ ] **Unit 2: Fix unused variable in config.go template**
+
+**Goal:** Generated config.go passes `go vet` without manual fixes
+
+**Requirements:** R2
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/generator/templates/config.go.tmpl` (wrap ReadFile in format conditional)
+- Test: `internal/generator/generator_test.go` (add vet check to compilation tests)
+
+**Approach:**
+- Move `data, err := os.ReadFile(path)` inside the TOML format conditional block
+- For non-TOML formats (default), use `if _, err := os.Stat(path); err == nil { /* file exists */ }` as a presence check if needed, or simply remove the read entirely since env vars take precedence
+- Alternatively, if the intent is to support JSON config reading in the future, add the JSON unmarshal branch now
+
+**Patterns to follow:**
+- Existing TOML conditional in config.go.tmpl (line 54-60)
+- Other templates' config patterns
+
+**Test scenarios:**
+- Happy path: Generate a CLI with bearer_token auth, run `go vet ./...`, verify no unused variable errors
+- Happy path: Generate a CLI with TOML config format, verify config file reading still works
+- Edge case: Generate with no config format specified, verify `go vet` passes
+
+**Verification:**
+- `go vet ./...` passes on freshly generated CLIs without manual intervention
+- `TestGenerateProjectsCompile` still passes
+
+- [ ] **Unit 3: Populate typed columns from spec response fields**
+
+**Goal:** Per-entity tables have typed columns beyond just id/data/synced_at
+
+**Requirements:** R3
+
+**Dependencies:** None (template infrastructure already exists)
+
+**Files:**
+- Modify: `internal/generator/generator.go` (profiler section that builds table definitions)
+- Modify: `internal/generator/templates/store.go.tmpl` (may need minor adjustments to column rendering)
+- Test: `internal/generator/generator_test.go` (add typed column assertions)
+
+**Approach:**
+- In the profiler, when building table definitions for each resource, inspect the resource's list endpoint response fields
+- For each response field, map the spec type to a SQLite type: string -> TEXT, integer -> INTEGER, number -> REAL, boolean -> INTEGER, datetime strings -> DATETIME
+- Include the top 8 fields as typed columns (id is already PRIMARY KEY; add status, name, created_at, updated_at, and the highest-gravity domain fields)
+- Keep the `data JSON NOT NULL` column as a catch-all for the complete response
+- The Upsert method should populate both the typed columns and the JSON blob
+
+**Patterns to follow:**
+- `.Columns` iteration in `store.go.tmpl` (lines 79-125) - the template already handles dynamic columns
+- `.FTS5Fields` extraction in the profiler - similar pattern of inspecting response schema to extract field metadata
+- `internal/spec/spec.go` `ResponseField` struct for field type information
+
+**Test scenarios:**
+- Happy path: Generate CLI from Stytch spec (has typed response fields), verify entity tables have typed columns beyond id/data/synced_at
+- Happy path: Generate CLI from a spec with no response fields defined, verify tables fall back to id/data/synced_at (JSON-blob-only)
+- Edge case: Response field has an unknown type, verify it maps to TEXT
+- Edge case: Response has >8 fields, verify only top 8 are extracted as columns
+- Integration: Generate, build, and run `sync` on a generated CLI, verify typed columns are populated during sync
+
+**Verification:**
+- Generated store.go has typed columns matching spec response fields
+- Type Fidelity scorecard dimension improves from 3/5 to 4/5 or higher
+- CLIs generated from specs without response fields still compile and work
+
+- [ ] **Unit 4: Expand defaultSyncResources to include all simple-path resources**
+
+**Goal:** All resources with parameterless GET list endpoints are synced by default
+
+**Requirements:** R4
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/generator/generator.go` (profiler logic for `.SyncableResources`)
+- Test: `internal/generator/generator_test.go` (sync resource count assertions)
+
+**Approach:**
+- In the profiler, change the syncable resource selection criteria: include any resource that has a list endpoint whose path contains no `{param}` placeholders
+- Resources with compound paths (e.g., `/projects/{projectRef}/envvars/{env}`) are excluded with a generated comment explaining why
+- The `query` resource type (execute-only, no list) should also be excluded
+- Add a `// Excluded from sync: envvars (requires projectRef), query (execute-only)` comment in the generated sync.go for clarity
+
+**Patterns to follow:**
+- Existing `.SyncableResources` population in `generator.go`
+- `syncResourcePath()` template pattern in `sync.go.tmpl`
+
+**Test scenarios:**
+- Happy path: Generate CLI from a spec with 9 resources (like trigger-dev), verify defaultSyncResources includes all simple-path resources (runs, schedules, queues, waitpoints, deployments, batches)
+- Edge case: Spec with only compound-path resources (all have {param} in list path), verify empty sync list with comments
+- Edge case: Resource has no list endpoint, verify excluded from sync
+- Happy path: Existing specs (stytch.yaml) produce the same or more sync resources than before (no regression)
+
+**Verification:**
+- Generated sync.go includes more resources than before for specs with many endpoints
+- Resources with compound paths are excluded with explanation
+- `TestGenerateProjectsCompile` still passes
+
+## System-Wide Impact
+
+- **Interaction graph:** Units 1-4 are independent (no cross-dependencies). Unit 1 changes how scorer tools load specs. Units 2-4 change what the generator emits.
+- **Error propagation:** Unit 1 adds a new code path in spec loading that could produce new error types. Ensure errors from `spec.Parse()` are wrapped clearly so users know which parser failed.
+- **Unchanged invariants:** The internal YAML spec format is unchanged. OpenAPI spec handling is unchanged. The generator's core template rendering is unchanged. The skill instructions (SKILL.md) are unchanged.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Unit 1 could break existing OpenAPI spec loading | Format detection routes to existing parsers for non-YAML specs. Existing test fixtures validate. |
+| Unit 3 typed columns could break existing sync/upsert | JSON blob column is preserved. Typed columns are additive. |
+| Unit 4 could include resources that shouldn't be synced | Path-parameter check is the guard. Only parameterless list paths are included. |
+
+## Sources & References
+
+- **Origin document:** `docs/retros/2026-04-09-trigger-dev-retro.md`
+- Related issue: mvanhorn/cli-printing-press#158
+- Spec parser: `internal/spec/spec.go`
+- Dogfood spec loader: `internal/pipeline/dogfood.go` lines 259-282
+- Store template: `internal/generator/templates/store.go.tmpl` lines 79-125
+- Sync template: `internal/generator/templates/sync.go.tmpl` lines 462-483
diff --git a/docs/retros/2026-04-09-trigger-dev-retro.md b/docs/retros/2026-04-09-trigger-dev-retro.md
new file mode 100644
index 00000000..b7e0e173
--- /dev/null
+++ b/docs/retros/2026-04-09-trigger-dev-retro.md
@@ -0,0 +1,171 @@
+# Printing Press Retro: trigger-dev
+
+## Session Stats
+- API: Trigger.dev
+- Spec source: internal YAML (hand-crafted from official docs, no OpenAPI spec available)
+- Scorecard: 88/100 (Grade A)
+- Verify pass rate: 95% (21/22), polished to 100%
+- Fix loops: 1
+- Manual code edits: 2 (usageErr missing, unused variable)
+- Features built from scratch: 8 (all transcendence features)
+
+## Findings
+
+### F1. usageErr function missing from generated helpers.go (Bug)
+- **What happened:** Generated CLI failed to build because `usageErr` was called in envvars and batches commands but not defined in helpers.go.
+- **Scorer correct?** N/A - this was a build failure, not a scoring issue.
+- **Root cause:** The helpers.go.tmpl template DOES include `usageErr` (line 98). The binary used was v1.2.1-dirty, which may have been built from a state before the function was added to the template. Alternatively, the template rendering conditionally excluded it.
+- **Cross-API check:** Would recur on any API with path parameters on promoted commands.
+- **Frequency:** Every API with promoted endpoints that have required positional args.
+- **Fallback if the Printing Press doesn't fix it:** Claude catches the build error and adds the function manually. Reliable but wastes time.
+- **Worth a Printing Press fix?** Yes - this is a build-breaking template bug.
+- **Inherent or fixable:** Fixable. The function is already in the template; ensure it renders unconditionally.
+- **Durable fix:** Verify that `usageErr` is always included in helpers.go output regardless of which endpoints are generated. Add a test case with a spec that has positional args.
+- **Test:** Generate a CLI from any spec with required positional args. Verify `usageErr` is in helpers.go and the CLI builds.
+- **Evidence:** Build error on first `go build` attempt, had to manually add the function.
+
+### F2. Unused variable `data` in generated config.go (Bug)
+- **What happened:** `data, err := os.ReadFile(path)` where `data` was declared but never used, causing `go vet` failure.
+- **Scorer correct?** N/A - build/vet failure.
+- **Root cause:** Generator template for config.go reads the config file but doesn't use the contents (presumably intended for JSON parsing that wasn't wired up).
+- **Cross-API check:** Would recur on every API.
+- **Frequency:** Every API.
+- **Fallback:** Claude fixes the vet error. Reliable but adds a fix loop.
+- **Worth a Printing Press fix?** Yes.
+- **Inherent or fixable:** Fixable. Either parse the JSON config data or use `_, err :=`.
+- **Durable fix:** Fix the config.go template to either (a) parse the config file JSON into the Config struct, or (b) use `_, _ = os.ReadFile(path)` if the file read is just a presence check.
+- **Test:** Generate any CLI, run `go vet ./...`, verify no unused variable errors in config.go.
+- **Evidence:** `go vet` failure after generation, line 45 of config.go.
+
+### F3. Dogfood/verify/scorecard can't parse internal YAML specs (Scorer bug)
+- **What happened:** All three tools (dogfood, verify, scorecard) failed with "at least one resource is required" when given the internal YAML spec that the generator successfully consumed.
+- **Scorer correct?** No - the scorer is wrong. The spec is valid (the generator parsed it fine), but the validation tools use a stricter parser that rejects the same spec.
+- **Root cause:** The dogfood/verify/scorecard validation path runs a spec parse that expects resources in a format the internal YAML parser doesn't require. The generator's spec parser is more lenient than the validator's spec parser.
+- **Cross-API check:** Would recur on any CLI generated from an internal YAML spec (not OpenAPI).
+- **Frequency:** Every API that uses internal YAML format instead of OpenAPI.
+- **Fallback:** Run verify/scorecard without --spec flag (works, but loses spec-dependent checks like path validity and auth protocol).
+- **Worth a Printing Press fix?** Yes - this blocks three verification tools from providing full coverage.
+- **Inherent or fixable:** Fixable. The spec parser used by the verification tools should accept the same format the generator accepts.
+- **Durable fix:** Ensure the spec validation in dogfood/verify/scorecard uses the same parser (internal/spec/) as the generator, or add a fallback path that skips spec validation gracefully when the spec format is internal YAML.
+- **Test:** Run `printing-press dogfood --dir <cli> --spec <internal-yaml-spec>`. Should not error on "at least one resource is required."
+- **Evidence:** All three tools errored with the same message during shipcheck.
+
+### F4. defaultSyncResources only syncs 4 of 9 resources (Template gap)
+- **What happened:** The spec defines 9 resources (runs, tasks, schedules, envvars, queues, deployments, batches, waitpoints, query) but defaultSyncResources only syncs 4 (queues, runs, schedules, waitpoints). Missing: tasks, batches, envvars, deployments, query.
+- **Scorer correct?** Yes - Data Pipeline Integrity scored 7/10, partly because of incomplete sync coverage.
+- **Root cause:** The generator's sync template selects resources for sync but uses heuristics that exclude resources without list endpoints or with complex path parameters (envvars requires projectRef). The "query" resource is an execute-only endpoint, not a list-and-sync resource.
+- **Cross-API check:** Would recur on APIs where some resources have complex list paths.
+- **Frequency:** Most APIs - many APIs have resources with compound paths.
+- **Fallback:** Claude adds sync resources manually. Unreliable - easy to forget.
+- **Worth a Printing Press fix?** Partially. Some resources (envvars with projectRef, query) genuinely can't be synced with the standard pattern. But deployments and batches could be synced.
+- **Inherent or fixable:** Partially fixable. The generator should sync all resources that have a standard GET list endpoint. Resources with compound paths (envvars/{projectRef}/{env}) need special handling.
+- **Durable fix:** In the sync template, include all resources whose list endpoint matches the pattern `GET /api/v*/<resource>` (no path params). For resources with path params, generate a sync path that requires the user to provide the param via config or flag.
+- **Test:** Generate a CLI from a spec with 5+ resources, verify defaultSyncResources includes all resources with simple list endpoints.
+- **Evidence:** `defaultSyncResources()` returns only 4 entries despite 9 resources in the spec.
+
+### F5. Per-entity tables are JSON blobs, not typed columns (Template gap)
+- **What happened:** All entity tables store data as `id TEXT, data JSON, synced_at DATETIME`. No typed columns for status, taskIdentifier, costInCents, etc. This forces JSON extraction for queries and scores low on Type Fidelity (3/5).
+- **Scorer correct?** Yes - Type Fidelity 3/5 is appropriate for JSON-blob-only tables.
+- **Root cause:** The generator creates per-entity tables but doesn't extract response fields into typed columns. The spec provides response_fields but the store template ignores them.
+- **Cross-API check:** Every API.
+- **Frequency:** Every API.
+- **Fallback:** Claude adds typed columns manually. Unreliable and time-consuming.
+- **Worth a Printing Press fix?** Yes - high impact. Typed columns improve query performance, enable better FTS indexing, and improve scorecard.
+- **Inherent or fixable:** Fixable. The spec's response fields can drive column generation.
+- **Durable fix:** When the spec defines response fields for a resource's list endpoint, generate typed columns in the entity table for the top 5-8 fields (id, status, name, created_at, etc.). Keep the `data JSON` column as a catch-all for the full response.
+- **Test:** Generate a CLI from a spec with response fields. Verify entity tables have typed columns beyond just id/data/synced_at.
+- **Evidence:** All tables in store.go use the same 3-column schema regardless of entity type. Scorecard Type Fidelity 3/5.
+
+### F6. Search uses generic Search only, no per-entity FTS (Recurring friction)
+- **What happened:** Dogfood pipeline check noted "search uses generic Search only." The generic `resources_fts` table is used for all search, but per-entity tables have no FTS indexes.
+- **Scorer correct?** Yes - this is correctly identified as a pipeline integrity gap.
+- **Root cause:** The generator creates per-entity tables but only creates one FTS index on the generic `resources` table. Per-entity FTS would require knowing which text fields to index.
+- **Cross-API check:** Every API.
+- **Frequency:** Every API.
+- **Fallback:** Works but search quality is limited.
+- **Worth a Printing Press fix?** Medium priority. The generic search works but per-entity FTS would produce better results.
+- **Inherent or fixable:** Fixable if spec response fields are available. The generator can identify text/string fields and create FTS indexes on per-entity tables.
+- **Durable fix:** When generating per-entity tables with typed columns (F5), also create FTS5 virtual tables for entities with text fields (e.g., `runs_fts` indexing taskIdentifier, status, tags).
+- **Test:** Generate a CLI, run `search "test"`, verify results come from per-entity FTS tables.
+- **Evidence:** Dogfood pipeline_check shows "search uses generic Search only."
+
+## Prioritized Improvements
+
+### P1 - High priority
+| Finding | Title | Component | Frequency | Fallback Reliability | Complexity |
+|---------|-------|-----------|-----------|---------------------|------------|
+| F3 | Dogfood/verify/scorecard can't parse internal YAML specs | Scorer (dogfood, verify, scorecard) | Every internal YAML API | Low - blocks three tools entirely | Medium |
+| F2 | Unused variable in config.go | Generator templates | Every API | High - Claude fixes it, but wastes a fix loop | Small |
+
+### P2 - Medium priority
+| Finding | Title | Component | Frequency | Fallback Reliability | Complexity |
+|---------|-------|-----------|-----------|---------------------|------------|
+| F5 | Per-entity tables are JSON blobs | Generator templates (store.go.tmpl) | Every API | Low - manual column addition is error-prone | Medium |
+| F4 | defaultSyncResources misses resources | Generator templates (sync.go.tmpl) | Most APIs | Medium - Claude sometimes catches it | Small |
+
+### P3 - Low priority
+| Finding | Title | Component | Frequency | Fallback Reliability | Complexity |
+|---------|-------|-----------|-----------|---------------------|------------|
+| F6 | Search uses generic Search only | Generator templates (store.go.tmpl, search.go.tmpl) | Every API | Medium - generic search works | Medium |
+
+### Skip
+| Finding | Title | Why unlikely to recur |
+|---------|-------|----------------------|
+| F1 | usageErr missing from helpers.go | Template already has it (line 98). Likely a stale binary issue (v1.2.1-dirty). If it recurs, promote to P1. |
+
+## Work Units
+
+### WU-1: Fix internal YAML spec validation in scorer tools (from F3)
+- **Goal:** Make dogfood, verify, and scorecard accept the same internal YAML spec format the generator accepts
+- **Target:** Scorer tools - spec validation path in dogfood, verify, and scorecard commands
+- **Acceptance criteria:**
+ - positive: `printing-press dogfood --spec trigger-dev-spec.yaml` runs without "at least one resource" error
+ - positive: `printing-press verify --spec trigger-dev-spec.yaml` produces command-level results
+ - negative: Invalid YAML specs still fail validation
+- **Scope boundary:** Does not change the internal YAML spec format itself
+- **Dependencies:** None
+- **Complexity:** Medium
+
+### WU-2: Fix unused variable in config.go template (from F2)
+- **Goal:** Generated config.go passes `go vet` without manual fixes
+- **Target:** Generator template `internal/generator/templates/config.go.tmpl`
+- **Acceptance criteria:**
+ - positive: Generate any CLI, `go vet ./...` passes on first run
+ - negative: Config file loading still works when a config file exists
+- **Scope boundary:** Does not change config loading behavior
+- **Dependencies:** None
+- **Complexity:** Small
+
+### WU-3: Add typed columns to per-entity tables (from F5, F6)
+- **Goal:** Per-entity tables have typed columns from spec response fields, improving query performance and Type Fidelity score
+- **Target:** Generator templates for store.go and related sync/search code
+- **Acceptance criteria:**
+ - positive: Entity tables have top 5-8 typed columns from spec response fields
+ - positive: Type Fidelity scores 4/5 or higher
+ - negative: CLIs generated from specs without response fields still work (JSON-blob fallback)
+- **Scope boundary:** Does not add per-entity FTS (that's a follow-up)
+- **Dependencies:** None
+- **Complexity:** Medium
+
+### WU-4: Improve defaultSyncResources coverage (from F4)
+- **Goal:** All resources with simple list endpoints are included in default sync
+- **Target:** Generator template for sync.go
+- **Acceptance criteria:**
+ - positive: Resources with `GET /api/v*/<resource>` list endpoints are synced by default
+ - negative: Resources with complex paths (compound params) are excluded with a comment explaining why
+- **Scope boundary:** Does not add parameter-aware sync for compound-path resources
+- **Dependencies:** None
+- **Complexity:** Small
+
+## Anti-patterns
+- Running with a dirty/stale binary version. The v1.2.1-dirty build may have caused the usageErr issue.
+- Writing internal YAML specs manually instead of using the generator's spec discovery. For well-documented APIs like Trigger.dev, a `--docs` generation from the official docs site would have been faster and more complete.
+
+## What the Printing Press Got Right
+- Generated 40+ commands from a hand-written internal YAML spec with correct path routing, auth headers, and pagination
+- SQLite store with per-entity tables, FTS5 search, and sync cursor management all worked out of the box
+- Agent-native flags (--json, --dry-run, --select, --compact, --agent) all wired correctly across all commands
+- Scorecard 88/100 Grade A on first generation with only 2 minor fixes needed
+- Doctor command correctly validated auth, API reachability, and credentials against the live API
+- Sync successfully populated runs and schedules from the live Trigger.dev API
+- All 8 transcendence features (watch, failures, health, costs, stale, bottleneck, timeline, env diff) integrated cleanly with the generated foundation
diff --git a/internal/generator/schema_builder.go b/internal/generator/schema_builder.go
index 3daabcf4..1341b089 100644
--- a/internal/generator/schema_builder.go
+++ b/internal/generator/schema_builder.go
@@ -49,7 +49,7 @@ func BuildSchema(s *spec.APISpec) []TableDef {
},
}
- if gravity >= 8 {
+ if gravity >= 4 {
fields := collectResponseFields(resource)
for _, f := range fields {
if isScalarField(f) && f.Name != "id" {
@@ -79,7 +79,7 @@ func BuildSchema(s *spec.APISpec) []TableDef {
}
textFields := collectTextFieldNames(resource)
- if len(textFields) >= 2 && gravity >= 6 {
+ if len(textFields) >= 2 && gravity >= 4 {
table.FTS5 = true
table.FTS5Fields = textFields
// Only use content-sync triggers when ALL FTS fields are
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 8fcee6c7..aa31a7a1 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -51,14 +51,14 @@ func Load(configPath string) (*Config, error) {
cfg.Path = path
// Try to load config file
+{{- if eq .Config.Format "toml"}}
data, err := os.ReadFile(path)
if err == nil {
-{{- if eq .Config.Format "toml"}}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parsing config %s: %w", path, err)
}
-{{- end}}
}
+{{- end}}
// Env var overrides
{{- if .Auth.Inferred}}
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index e957a897..db926641 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -257,6 +257,13 @@ func writeDogfoodResults(report *DogfoodReport, dir string) error {
}
func loadDogfoodOpenAPISpec(specPath string) (*openAPISpec, error) {
+ // Try internal YAML spec format first (starts with "name:" + "resources:").
+ if internal, err := tryLoadInternalYAMLSpec(specPath); err != nil {
+ return nil, err
+ } else if internal != nil {
+ return internalSpecToDogfoodSpec(internal), nil
+ }
+
data, err := os.ReadFile(specPath)
if err != nil {
return nil, fmt.Errorf("reading spec: %w", err)
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 6b8f6550..c8098985 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -9,6 +9,8 @@ import (
"slices"
"strconv"
"strings"
+
+ apispec "github.com/mvanhorn/cli-printing-press/internal/spec"
)
// infraCoreFiles are CLI infrastructure files excluded from workflow/insight scoring.
@@ -998,6 +1000,15 @@ func loadOpenAPISpec(specPath string) (*openAPISpecInfo, error) {
return nil, fmt.Errorf("reading spec: %w", err)
}
+ // Detect internal YAML spec format and convert to openAPISpecInfo.
+ if isInternalYAMLSpec(data) {
+ internal, err := apispec.ParseBytes(data)
+ if err != nil {
+ return nil, fmt.Errorf("parsing internal YAML spec: %w", err)
+ }
+ return internalSpecToOpenAPISpecInfo(internal), nil
+ }
+
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing spec JSON: %w", err)
diff --git a/internal/pipeline/spec_detect.go b/internal/pipeline/spec_detect.go
new file mode 100644
index 00000000..7a5d9413
--- /dev/null
+++ b/internal/pipeline/spec_detect.go
@@ -0,0 +1,130 @@
+package pipeline
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "slices"
+
+ apispec "github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+// isInternalYAMLSpec returns true if data looks like an internal YAML spec
+// (starts with "name:" and contains a "resources:" section) rather than OpenAPI.
+func isInternalYAMLSpec(data []byte) bool {
+ // Internal YAML specs start with "name:" (possibly after comments/blank lines).
+ // OpenAPI specs start with "openapi:" or have a top-level "paths:" key.
+ lines := bytes.Split(data, []byte("\n"))
+ for _, line := range lines {
+ trimmed := bytes.TrimSpace(line)
+ if len(trimmed) == 0 || trimmed[0] == '#' {
+ continue
+ }
+ if bytes.HasPrefix(trimmed, []byte("name:")) {
+ return bytes.Contains(data, []byte("\nresources:"))
+ }
+ // If the first non-comment line is openapi: or swagger:, it's OpenAPI
+ if bytes.HasPrefix(trimmed, []byte("openapi:")) || bytes.HasPrefix(trimmed, []byte("swagger:")) {
+ return false
+ }
+ // If it starts with { it's JSON (OpenAPI)
+ if trimmed[0] == '{' {
+ return false
+ }
+ break
+ }
+ return false
+}
+
+// internalSpecToDogfoodSpec converts a parsed internal YAML APISpec into the
+// openAPISpec struct used by dogfood/verify.
+func internalSpecToDogfoodSpec(s *apispec.APISpec) *openAPISpec {
+ return &openAPISpec{
+ Paths: collectInternalSpecPaths(s),
+ Auth: s.Auth,
+ }
+}
+
+// internalSpecToOpenAPISpecInfo converts a parsed internal YAML APISpec into
+// the openAPISpecInfo struct used by scorecard.
+func internalSpecToOpenAPISpecInfo(s *apispec.APISpec) *openAPISpecInfo {
+ info := &openAPISpecInfo{
+ Paths: collectInternalSpecPaths(s),
+ SecuritySchemes: make(map[string]openAPISecurityScheme),
+ }
+
+ // Map auth config to a synthetic security scheme so scorecard auth
+ // evaluation works the same as with OpenAPI specs.
+ if s.Auth.Type != "" && s.Auth.Type != "none" {
+ schemeName := s.Auth.Scheme
+ if schemeName == "" {
+ schemeName = s.Auth.Type
+ }
+ scheme := openAPISecurityScheme{Key: schemeName}
+ switch s.Auth.Type {
+ case "bearer_token":
+ scheme.Type = "http"
+ scheme.Scheme = "bearer"
+ case "api_key":
+ scheme.Type = "apikey"
+ scheme.In = s.Auth.In
+ if scheme.In == "" {
+ scheme.In = "header"
+ }
+ scheme.HeaderName = s.Auth.Header
+ case "oauth2":
+ scheme.Type = "oauth2"
+ case "cookie", "composed":
+ scheme.Type = "apikey"
+ scheme.In = "cookie"
+ default:
+ scheme.Type = s.Auth.Type
+ }
+ info.SecuritySchemes[schemeName] = scheme
+ info.SecurityRequirements = []securityRequirementSet{
+ {Alternatives: [][]string{{schemeName}}},
+ }
+ }
+
+ return info
+}
+
+// collectInternalSpecPaths extracts all endpoint paths from an internal YAML spec.
+func collectInternalSpecPaths(s *apispec.APISpec) []string {
+ var paths []string
+ for _, resource := range s.Resources {
+ collectInternalResourcePaths(resource, &paths)
+ }
+ slices.Sort(paths)
+ return slices.Compact(paths)
+}
+
+func collectInternalResourcePaths(r apispec.Resource, paths *[]string) {
+ for _, endpoint := range r.Endpoints {
+ if endpoint.Path != "" {
+ *paths = append(*paths, endpoint.Path)
+ }
+ }
+ for _, sub := range r.SubResources {
+ collectInternalResourcePaths(sub, paths)
+ }
+}
+
+// tryLoadInternalYAMLSpec reads specPath and, if it's an internal YAML spec,
+// parses it and returns the APISpec. Returns nil, nil if not internal YAML.
+func tryLoadInternalYAMLSpec(specPath string) (*apispec.APISpec, error) {
+ data, err := os.ReadFile(specPath)
+ if err != nil {
+ return nil, fmt.Errorf("reading spec: %w", err)
+ }
+
+ if !isInternalYAMLSpec(data) {
+ return nil, nil
+ }
+
+ parsed, err := apispec.ParseBytes(data)
+ if err != nil {
+ return nil, fmt.Errorf("parsing internal YAML spec: %w", err)
+ }
+ return parsed, nil
+}
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 0f318a7c..c927b707 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -275,6 +275,15 @@ func Profile(s *spec.APISpec) *APIProfile {
}
}
}
+ } else if method == "GET" && !strings.Contains(endpoint.Path, "{") && !hasRequiredScopeParams(endpoint) && looksLikeCollectionEndpoint(endpointNameLower) {
+ // Catch-all for simple GET collection endpoints that isListEndpoint
+ // didn't recognise (e.g., response is an untyped object with no
+ // wrapper field defined in the spec's types map).
+ // Only include endpoints whose name suggests a collection (list, all,
+ // index, etc.) — exclude singular getters like "get" or "show".
+ if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing) {
+ syncable[resourceName] = endpoint.Path
+ }
}
if endpoint.Pagination != nil {
@@ -658,6 +667,14 @@ func findEntityTypeEnum(endpoint spec.Endpoint) *spec.Param {
return nil
}
+// looksLikeCollectionEndpoint returns true when the endpoint name suggests it
+// returns a list of items rather than a single resource. Used as a guard for
+// the catch-all syncable-resource heuristic so that singleton getters like
+// "get" or "show" are excluded.
+func looksLikeCollectionEndpoint(nameLower string) bool {
+ return containsAny(nameLower, []string{"list", "all", "index", "search", "query", "browse", "find"})
+}
+
func hasLifecycleField(params []spec.Param) bool {
for _, param := range params {
if isLifecycleParam(param) {
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index 2a00f0e2..a76600dc 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -578,3 +578,91 @@ func TestProfileWrapperObjectDetection_NoFalsePositive(t *testing.T) {
}
assert.NotContains(t, syncNames, "settings", "non-wrapper object should not be syncable")
}
+
+func TestProfileSimpleListEndpointSyncable(t *testing.T) {
+ // Simulates the trigger-dev pattern: resources with parameterless GET list
+ // endpoints that return untyped objects (no wrapper field in types map, no
+ // pagination). These should still be syncable.
+ s := &spec.APISpec{
+ Name: "trigger-dev",
+ Resources: map[string]spec.Resource{
+ "deployments": {
+ Endpoints: map[string]spec.Endpoint{
+ "listDeployments": {
+ Method: "GET",
+ Path: "/v3/deployments",
+ Response: spec.ResponseDef{Type: "object"},
+ },
+ "get": {
+ Method: "GET",
+ Path: "/v3/deployments/{deploymentId}",
+ Response: spec.ResponseDef{Type: "object"},
+ },
+ },
+ },
+ "batches": {
+ Endpoints: map[string]spec.Endpoint{
+ "listBatches": {
+ Method: "GET",
+ Path: "/v3/batches",
+ Response: spec.ResponseDef{Type: "object"},
+ },
+ },
+ },
+ "runs": {
+ Endpoints: map[string]spec.Endpoint{
+ "listRuns": {
+ Method: "GET",
+ Path: "/v3/runs",
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{
+ CursorParam: "cursor",
+ LimitParam: "perPage",
+ },
+ },
+ },
+ },
+ "envvars": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/v3/projects/{projectRef}/envvars/{env}",
+ Response: spec.ResponseDef{Type: "object"},
+ },
+ },
+ },
+ "query": {
+ Endpoints: map[string]spec.Endpoint{
+ "create": {
+ Method: "POST",
+ Path: "/v3/query",
+ },
+ },
+ },
+ },
+ }
+
+ profile := Profile(s)
+
+ syncNames := make([]string, len(profile.SyncableResources))
+ syncPaths := make(map[string]string)
+ for i, sr := range profile.SyncableResources {
+ syncNames[i] = sr.Name
+ syncPaths[sr.Name] = sr.Path
+ }
+
+ // deployments and batches have parameterless GET list endpoints
+ assert.Contains(t, syncNames, "deployments", "parameterless GET list endpoint should be syncable")
+ assert.Contains(t, syncNames, "batches", "parameterless GET list endpoint should be syncable")
+ assert.Equal(t, "/v3/deployments", syncPaths["deployments"])
+ assert.Equal(t, "/v3/batches", syncPaths["batches"])
+
+ // runs has pagination so it should also be syncable
+ assert.Contains(t, syncNames, "runs")
+
+ // envvars has path params so it should be excluded
+ assert.NotContains(t, syncNames, "envvars", "compound-path resource should not be syncable")
+
+ // query is POST-only so it should be excluded
+ assert.NotContains(t, syncNames, "query", "POST-only resource should not be syncable")
+}
← 4d3c31f8 fix(cli): always emit usageErr helper (#162)
·
back to Cli Printing Press
·
feat(cli): printing press improvements from agent-capture re 911dc290 →