← back to Cli Printing Press
feat(pipeline): press intelligence engine - dynamic plans, competitor intel, doc-to-spec, scorecard
731b0ce7086d1d99034bb016b3cadb29d43dc8b8 · 2026-03-25 07:00:31 -0700 · Matt Van Horn
The press now thinks like OSC: research, plan, execute, plan again.
- Dynamic plan generation (planner.go): each phase writes the next
phase's plan informed by what it learned. Research results feed
scaffold plans, scorecard feeds ship/hold decisions.
- Competitor intelligence (research.go enhanced): analyzes competing
CLIs' GitHub issues, README, and PRs to identify unmet features,
pain points, and command count targets.
- Doc-to-spec generator (docspec/docspec.go): --docs flag crawls API
documentation and auto-generates YAML specs. Notion CLI generated
from docs, 7/7 gates pass. No hand-writing ever.
- Steinberger scorecard (scorecard.go): auto-scores generated CLIs
on 8 dimensions (output modes, auth, errors, UX, README, doctor,
agent-native, caching). Produces % score and A-F grade.
- Learning system (learnings.go): saves issues/fixes after each run,
loads for future runs, auto-suggests --lenient for known patterns.
- Self-improvement (selfimprove.go): scorecard gaps auto-generate
fix plans for the press templates.
- Pipeline wiring: CompleteAndPlanNext replaces static seeds with
dynamic plans. First two phases (preflight, research) are static,
rest are generated after prior phase completes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Files touched
A docs/plans/2026-03-25-feat-press-intelligence-engine-plan.mdM internal/cli/root.goA internal/docspec/docspec.goA internal/docspec/docspec_test.goA internal/pipeline/learnings.goM internal/pipeline/pipeline.goA internal/pipeline/planner.goM internal/pipeline/research.goA internal/pipeline/scorecard.goA internal/pipeline/selfimprove.goM internal/pipeline/state.go
Diff
commit 731b0ce7086d1d99034bb016b3cadb29d43dc8b8
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Wed Mar 25 07:00:31 2026 -0700
feat(pipeline): press intelligence engine - dynamic plans, competitor intel, doc-to-spec, scorecard
The press now thinks like OSC: research, plan, execute, plan again.
- Dynamic plan generation (planner.go): each phase writes the next
phase's plan informed by what it learned. Research results feed
scaffold plans, scorecard feeds ship/hold decisions.
- Competitor intelligence (research.go enhanced): analyzes competing
CLIs' GitHub issues, README, and PRs to identify unmet features,
pain points, and command count targets.
- Doc-to-spec generator (docspec/docspec.go): --docs flag crawls API
documentation and auto-generates YAML specs. Notion CLI generated
from docs, 7/7 gates pass. No hand-writing ever.
- Steinberger scorecard (scorecard.go): auto-scores generated CLIs
on 8 dimensions (output modes, auth, errors, UX, README, doctor,
agent-native, caching). Produces % score and A-F grade.
- Learning system (learnings.go): saves issues/fixes after each run,
loads for future runs, auto-suggests --lenient for known patterns.
- Self-improvement (selfimprove.go): scorecard gaps auto-generate
fix plans for the press templates.
- Pipeline wiring: CompleteAndPlanNext replaces static seeds with
dynamic plans. First two phases (preflight, research) are static,
rest are generated after prior phase completes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
...26-03-25-feat-press-intelligence-engine-plan.md | 419 +++++++++++++++++++++
internal/cli/root.go | 50 +++
internal/docspec/docspec.go | 331 ++++++++++++++++
internal/docspec/docspec_test.go | 141 +++++++
internal/pipeline/learnings.go | 122 ++++++
internal/pipeline/pipeline.go | 22 +-
internal/pipeline/planner.go | 250 ++++++++++++
internal/pipeline/research.go | 347 ++++++++++++++++-
internal/pipeline/scorecard.go | 397 +++++++++++++++++++
internal/pipeline/selfimprove.go | 165 ++++++++
internal/pipeline/state.go | 24 ++
11 files changed, 2259 insertions(+), 9 deletions(-)
diff --git a/docs/plans/2026-03-25-feat-press-intelligence-engine-plan.md b/docs/plans/2026-03-25-feat-press-intelligence-engine-plan.md
new file mode 100644
index 00000000..a9eafd8c
--- /dev/null
+++ b/docs/plans/2026-03-25-feat-press-intelligence-engine-plan.md
@@ -0,0 +1,419 @@
+---
+title: "Press Intelligence Engine - The Press Plans Its Own Work"
+type: feat
+status: active
+date: 2026-03-25
+---
+
+# Press Intelligence Engine - The Press Plans Its Own Work
+
+## The Insight
+
+OSC is powerful because it constantly writes ce:plan files before doing anything. The printing press pipeline has phases but they use static seed templates. The press should think like OSC: research, plan, execute, plan again, execute again. Each phase writes a plan informed by what the previous phase learned.
+
+This plan makes the press SMARTER, not just wider. No CLI shipping until the press itself is excellent.
+
+## What Makes the Press Dumb Today
+
+1. **Static seed templates** - Every scaffold plan says the same thing regardless of what research found
+2. **No competitor awareness** - The press doesn't know that competing CLIs exist, let alone what they're missing
+3. **No doc-to-spec** - If there's no OpenAPI spec, the press gives up and asks a human to write YAML
+4. **No plan chaining** - Research results don't flow into scaffold plans. Dogfood results don't flow into ship plans.
+5. **No learning** - The press makes the same mistakes on every API. Flat resource problem? Hits it every time.
+
+## What Makes the Press Smart After This Plan
+
+1. **Dynamic plan generation** - Each phase writes the NEXT phase's plan using what it learned
+2. **Competitor intelligence** - Reads competing CLIs' GitHub issues, README, and PRs to know what users want
+3. **Doc-to-spec** - Crawls API docs and auto-generates specs. No hand-writing ever.
+4. **Plan chaining** - research.json feeds scaffold plan. dogfood-results.json feeds ship plan. Everything connects.
+5. **Learning from past runs** - The press remembers what went wrong (flat resources, empty auth, bad refs) and avoids it
+
+## Implementation Units
+
+### Unit 1: Dynamic Plan Generation (Plan Chaining)
+
+**Goal:** Each pipeline phase writes the next phase's plan dynamically, incorporating what it learned.
+
+**Files:**
+- `internal/pipeline/seeds.go` - replace static templates with dynamic plan generators
+- `internal/pipeline/planner.go` - new file: dynamic plan writer that reads prior phase outputs
+
+**How it works today:**
+```
+Research phase completes → writes research.json
+Scaffold phase starts → reads STATIC seed template (ignores research.json)
+```
+
+**How it works after:**
+```
+Research phase completes → writes research.json
+Research phase → calls generateScaffoldPlan(research.json)
+ → writes scaffold plan that says:
+ "This API has 3 competing CLIs. The best has 40 commands.
+ Generate at least 45 commands. Competitor X is missing
+ transaction sync - include it. Use --lenient because
+ similar specs had broken refs."
+Scaffold phase starts → reads DYNAMIC plan (informed by research)
+```
+
+**Implementation:**
+
+```go
+// internal/pipeline/planner.go
+package pipeline
+
+// GenerateNextPlan writes a ce:plan-style plan file for the next phase,
+// incorporating outputs from all completed prior phases.
+func GenerateNextPlan(state *PipelineState, nextPhase string) (string, error) {
+ var context PlanContext
+
+ // Load all available prior phase outputs
+ if research, err := LoadResearch(PipelineDir(state.APIName)); err == nil {
+ context.Research = research
+ }
+ if dogfood, err := LoadDogfoodResults(PipelineDir(state.APIName)); err == nil {
+ context.Dogfood = dogfood
+ }
+ if comparative, err := LoadComparativeResult(PipelineDir(state.APIName)); err == nil {
+ context.Comparative = comparative
+ }
+
+ // Generate phase-specific plan using context
+ switch nextPhase {
+ case PhaseScaffold:
+ return generateScaffoldPlan(state, context)
+ case PhaseEnrich:
+ return generateEnrichPlan(state, context)
+ case PhaseReview:
+ return generateReviewPlan(state, context)
+ case PhaseShip:
+ return generateShipPlan(state, context)
+ default:
+ return RenderSeed(nextPhase, seedDataFromState(state))
+ }
+}
+```
+
+Each generated plan includes:
+- What prior phases discovered (competitor count, novelty score, demand signals)
+- Specific instructions based on findings ("use --lenient", "target 45+ commands", "add transaction-sync endpoint")
+- Known pitfalls from the press's learning system (see Unit 5)
+
+**Verification:** `printing-press print petstore` generates dynamic scaffold plan that references research results, not the static seed template.
+
+### Unit 2: Competitor Intelligence Engine
+
+**Goal:** Research phase analyzes competing CLIs' GitHub repos to produce actionable insights.
+
+**File:** `internal/pipeline/research.go` (extend existing)
+
+**New functions:**
+
+```go
+func analyzeCompetitorRepo(owner, repo string) (*CompetitorAnalysis, error)
+```
+
+For each competing CLI discovered:
+1. **Fetch open issues labeled enhancement/feature** via GitHub API
+2. **Fetch README** and parse for command listings
+3. **Fetch recent closed PRs** (unmerged = abandoned features = demand signals)
+4. **Search issues for pain points** ("bug", "broken", "doesn't work")
+
+**New types:**
+
+```go
+type CompetitorAnalysis struct {
+ RepoURL string `json:"repo_url"`
+ CommandsFound []string `json:"commands_found"`
+ FeatureRequests []string `json:"feature_requests"`
+ AbandonedPRs []string `json:"abandoned_prs"`
+ PainPoints []string `json:"pain_points"`
+ CommandCount int `json:"command_count"`
+}
+
+// Added to ResearchResult
+type CompetitorInsights struct {
+ Analyses []CompetitorAnalysis `json:"analyses"`
+ CommandTarget int `json:"command_target"` // max(competitor commands) * 1.2
+ UnmetFeatures []string `json:"unmet_features"` // features no competitor has
+ PainPointsToAvoid []string `json:"pain_points_to_avoid"`
+}
+```
+
+**How this feeds into planning:** The dynamic scaffold plan (Unit 1) reads `CompetitorInsights.CommandTarget` and includes it as a goal: "Generate at least N commands." It lists `UnmetFeatures` as endpoints to prioritize in the spec.
+
+**Verification:** `RunResearch("plaid", ...)` produces competitor insights for `landakram/plaid-cli` with feature requests and command count.
+
+### Unit 3: Doc-to-Spec Generator
+
+**Goal:** `printing-press generate --docs <url>` auto-generates a YAML spec from API documentation.
+
+**File:** `internal/docspec/docspec.go` (new package)
+
+**What it does:**
+
+1. HTTP GET the docs page
+2. Regex scan for endpoint patterns: `(GET|POST|PUT|PATCH|DELETE)\s+/[a-zA-Z0-9/{}_.-]+`
+3. Extract parameter tables from HTML (detect `<table>` with parameter/type/required headers)
+4. Extract JSON examples from `<pre>` and `<code>` blocks
+5. Find auth method (scan for "Bearer", "API key", "OAuth", "Authorization")
+6. Find base URL (scan for `https://api.`)
+7. Generate internal YAML spec format
+
+**Design principle:** Good enough to compile, not perfect. The spec is a starting point. Missing fields get sensible defaults (`type: string`, `required: false`). The goal is 7/7 quality gates, not 100% API coverage.
+
+**File:** `internal/cli/root.go` - add `--docs` flag to generate command
+
+**Verification:** `printing-press generate --docs "https://developers.notion.com/reference" --name notion --output /tmp/notion-test` produces a CLI that passes 7/7 gates.
+
+### Unit 4: Press Learning System
+
+**Goal:** The press remembers what went wrong on past runs and avoids repeating mistakes.
+
+**File:** `internal/pipeline/learnings.go` (new)
+
+**How it works:**
+
+After each pipeline run, the press writes a learnings file:
+
+```go
+type PressLearning struct {
+ APIName string `json:"api_name"`
+ Date time.Time `json:"date"`
+ SpecType string `json:"spec_type"` // "openapi", "docs", "yaml"
+ Issues []Issue `json:"issues"`
+ Fixes []Fix `json:"fixes"`
+}
+
+type Issue struct {
+ Phase string `json:"phase"`
+ Gate string `json:"gate"` // which quality gate failed
+ Error string `json:"error"`
+ Pattern string `json:"pattern"` // categorized: "empty-auth", "broken-ref", "flat-resource"
+}
+
+type Fix struct {
+ Pattern string `json:"pattern"`
+ Solution string `json:"solution"` // "--lenient", "guard empty EnvVars", "strip common prefix"
+}
+```
+
+Stored at `~/.cache/printing-press/learnings.json`. Before each run, the press loads learnings and:
+- If pattern "broken-ref" seen before: auto-enable `--lenient`
+- If pattern "flat-resource" seen for similar spec size: warn in the scaffold plan
+- If pattern "empty-auth" seen: the template already guards against this (learned from Fly.io/Telegram)
+
+**How this feeds into planning:** The dynamic scaffold plan (Unit 1) includes a "Known Pitfalls" section generated from learnings: "APIs of this size (100+ paths) often produce flat resources. The enrich phase should check for common path prefix stripping."
+
+**Verification:** After generating a CLI that required --lenient, the learning is saved. On the next run of a similar spec, --lenient is auto-suggested in the plan.
+
+### Unit 5: Wire Pipeline Phases to Use Dynamic Plans
+
+**Goal:** The `print` command's pipeline actually uses dynamic plans instead of static seeds.
+
+**Files:**
+- `internal/pipeline/pipeline.go` - update `InitPipeline` to call `GenerateNextPlan` after each phase completes
+- `internal/pipeline/state.go` - add `LearningsPath` field to track learning file location
+
+**Current flow in `InitPipeline`:**
+```go
+for _, phase := range PhaseOrder {
+ seed, _ := RenderSeed(phase, seedData)
+ os.WriteFile(state.PlanPath(phase), seed, 0644)
+ state.MarkSeedWritten(phase)
+}
+```
+
+**New flow:**
+```go
+// Write only the first phase's plan (preflight is always static)
+seed, _ := RenderSeed(PhasePreflight, seedData)
+os.WriteFile(state.PlanPath(PhasePreflight), seed, 0644)
+
+// Subsequent phase plans are generated AFTER the prior phase completes
+// (in the pipeline runner, not at init time)
+```
+
+Then in the phase completion handler:
+```go
+func (s *PipelineState) CompleteAndPlanNext(phase string) error {
+ s.Complete(phase)
+ nextPhase := s.NextPhase()
+ if nextPhase == "" {
+ return nil // all done
+ }
+ plan, err := GenerateNextPlan(s, nextPhase)
+ if err != nil {
+ // Fall back to static seed
+ plan, _ = RenderSeed(nextPhase, seedDataFromState(s))
+ }
+ return os.WriteFile(s.PlanPath(nextPhase), []byte(plan), 0644)
+}
+```
+
+**Verification:** `printing-press print petstore` writes Phase 0 plan at init, then dynamically generates Phase 1 plan only after Phase 0 completes, incorporating Phase 0 outputs.
+
+### Unit 6: Automated Steinberger Scorecard
+
+**Goal:** After every CLI generation, the press automatically scores the output against the Steinberger 10/10 bar AND against discovered competitors. Produces a report card.
+
+**File:** `internal/pipeline/scorecard.go` (new)
+
+**The scorecard runs automatically in the Review phase.** It reads the generated CLI and scores it:
+
+```go
+type Scorecard struct {
+ APIName string `json:"api_name"`
+ SteinbergerScore SteinerScore `json:"steinberger_score"`
+ CompetitorScores []CompScore `json:"competitor_scores"`
+ OverallGrade string `json:"overall_grade"` // A/B/C/D/F
+ GapReport []string `json:"gap_report"`
+}
+
+type SteinerScore struct {
+ OutputModes int `json:"output_modes"` // 0-10 (--json, --plain, --select)
+ Auth int `json:"auth"` // 0-10 (env var, config, OAuth)
+ ErrorHandling int `json:"error_handling"` // 0-10 (typed errors, hints)
+ TerminalUX int `json:"terminal_ux"` // 0-10 (color, NO_COLOR)
+ README int `json:"readme"` // 0-10 (quickstart, examples)
+ Doctor int `json:"doctor"` // 0-10 (auth, connectivity)
+ AgentNative int `json:"agent_native"` // 0-10 (--json, --select, --dry-run)
+ LocalCache int `json:"local_cache"` // 0-10 (SQLite, offline)
+ Total int `json:"total"` // 0-80
+ Percentage int `json:"percentage"` // total/80 * 100
+}
+
+type CompScore struct {
+ CompetitorName string `json:"competitor_name"`
+ OurCommands int `json:"our_commands"`
+ TheirCommands int `json:"their_commands"`
+ OurScore int `json:"our_score"` // 0-100 from comparative
+ TheirScore int `json:"their_score"` // 0-100 estimated
+ WeWin bool `json:"we_win"`
+}
+```
+
+**How it measures each dimension:**
+
+| Dimension | How Measured (automated, no manual inspection) |
+|-----------|----------------------------------------------|
+| Output modes | grep generated root.go for "json", "plain", "select" flags |
+| Auth | grep config.go for env var count + check auth.go exists |
+| Error handling | grep helpers.go for "hint:" messages + count distinct exit codes |
+| Terminal UX | grep helpers.go for "colorEnabled", "NO_COLOR" |
+| README | count sections in README.md (Quick Start, Output Formats, Agent Usage, Troubleshooting) |
+| Doctor | grep doctor.go for health check count |
+| Agent native | grep root.go for "json", "select", "dry-run" flags |
+| Local cache | check if any SQLite or cache package exists (currently always 0) |
+
+**vs Competitors:** Uses the 6-dimension comparative scoring already built in `comparative.go`, but now runs automatically and produces a side-by-side report.
+
+**Output:** `scorecard.md` in the pipeline directory:
+
+```
+# Plaid CLI Scorecard
+
+## Steinberger Score: 69% (55/80)
+| Dimension | Score | Steinberger 10/10 | Gap |
+|----------------|-------|-------------------|-----|
+| Output modes | 8 | --json + --select | -- |
+| Auth | 7 | env + config | no keyring |
+| Error handling | 8 | hints on 401/404 | -- |
+| Terminal UX | 6 | color + NO_COLOR | no spinner |
+| README | 7 | quickstart + examples | no screenshots |
+| Doctor | 7 | auth + connectivity | -- |
+| Agent native | 8 | json + select + dry-run | -- |
+| Local cache | 0 | none | no caching |
+
+## vs Competitors
+| Dimension | plaid-cli (ours) | landakram/plaid-cli | We win? |
+|-----------|-----------------|---------------------|---------|
+| Commands | 51 | 8 | YES |
+| Install | go install | go install | TIE |
+| Auth | env + config | env only | YES |
+| JSON out | yes | yes | TIE |
+| Dry run | yes | no | YES |
+| Active | 2026-03-25 | 2021-08-01 | YES |
+
+## Grade: B+ (69% Steinberger, 4/6 dimensions beat competitor)
+```
+
+**How this feeds into planning:** The dynamic ship plan (Unit 1) reads the scorecard. If grade < B, the ship plan says "DO NOT SHIP - fix these gaps first." If grade >= B, it includes the scorecard in the README as proof of quality.
+
+**Verification:** Generate petstore CLI, run scorecard, verify it produces a valid markdown report with percentage scores.
+
+### Unit 7: Dogfood-Driven Press Improvement Loop
+
+**Goal:** After scoring, the press identifies its own weaknesses and creates a ce:plan to fix them.
+
+**File:** `internal/pipeline/selfimprove.go` (new)
+
+**How it works:**
+
+After the scorecard runs, if any Steinberger dimension scores < 5/10:
+
+1. Identify the template file responsible for that dimension
+2. Write a `fix-<dimension>-plan.md` in the pipeline directory
+3. The plan describes what template change would improve the score
+
+Example: if README scores 4/10 because the generated README is missing a "Troubleshooting" section:
+```markdown
+# Fix: README Template Missing Troubleshooting Section
+
+## Problem
+Generated README for plaid-cli scored 4/10 on the Steinberger README dimension.
+Missing sections: Troubleshooting, detailed examples per command.
+
+## Fix
+Edit internal/generator/templates/readme.md.tmpl:
+- Add Troubleshooting section with common error codes
+- Add per-command examples using exampleLine template function
+
+## Acceptance
+Re-run scorecard. README dimension should score >= 7/10.
+```
+
+**This is the press improving itself.** Each CLI it generates is a test. Each test produces a scorecard. Each scorecard gap produces a plan. Each plan, when executed, makes the NEXT CLI better.
+
+**Verification:** Generate a CLI, get a scorecard with a gap, verify a fix plan is auto-generated.
+
+## Acceptance Criteria
+
+### Press Intelligence (Units 1-5)
+- [ ] Dynamic plan generation: each phase writes the next phase's plan using prior outputs
+- [ ] Competitor intelligence: research phase fetches issues/README/PRs from competing CLIs
+- [ ] Doc-to-spec: `--docs` flag generates YAML spec from API documentation
+- [ ] Learning system: press saves issues/fixes and loads them for future runs
+- [ ] Pipeline wiring: `print` command uses dynamic plans, not static seeds
+
+### Automated Scoring (Units 6-7)
+- [ ] Steinberger scorecard runs automatically after every generation
+- [ ] Scorecard produces percentage score (0-100%) on 8 dimensions
+- [ ] Competitor comparison produces side-by-side table
+- [ ] Overall grade (A/B/C/D/F) determined automatically
+- [ ] Gaps below 5/10 auto-generate fix plans for the press templates
+- [ ] Fix plans are valid ce:plan files that can be executed with ce:work
+
+### Dogfood Validation (generate CLIs to test the press)
+- [ ] Generate Plaid CLI with competitor intelligence - scorecard shows improvement over raw generation
+- [ ] Generate PagerDuty CLI with --lenient + dynamic plans - score > 60% Steinberger
+- [ ] Generate at least 1 CLI from docs (Notion or Airtable) using doc-to-spec
+- [ ] Each generated CLI is a TEST of the press, not a product
+- [ ] `go test ./...` passes with all new packages
+
+## Scope Boundaries
+
+- CLIs are dogfood tests of the press, NOT products to ship
+- Do NOT create Homebrew tap or push CLIs to repos yet
+- Do NOT write the epic README (press capabilities aren't done yet)
+- Social demand signals are out of scope (nice-to-have for later)
+- The self-improvement loop (Unit 7) generates plans but does NOT auto-execute them
+
+## Sources
+
+- OSC plan-execute-plan pattern: `~/.claude/skills/osc-work/SKILL.md`, `osc-plan/SKILL.md`
+- Current static seeds: `internal/pipeline/seeds.go`
+- Current research phase: `internal/pipeline/research.go`
+- Overnight learnings: `docs/plans/overnight-hardening-results.md`
+- Steinberger gap analysis: generated CLIs score 6.9/10 - dynamic planning is part of closing to 8+
diff --git a/internal/cli/root.go b/internal/cli/root.go
index cb5df0a5..f4a7e8ee 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -11,11 +11,13 @@ import (
"strings"
"time"
+ "github.com/mvanhorn/cli-printing-press/internal/docspec"
"github.com/mvanhorn/cli-printing-press/internal/generator"
"github.com/mvanhorn/cli-printing-press/internal/openapi"
"github.com/mvanhorn/cli-printing-press/internal/pipeline"
"github.com/mvanhorn/cli-printing-press/internal/spec"
"github.com/spf13/cobra"
+ "gopkg.in/yaml.v3"
)
var version = "0.1.0"
@@ -45,11 +47,58 @@ func newGenerateCmd() *cobra.Command {
var refresh bool
var force bool
var lenient bool
+ var docsURL string
cmd := &cobra.Command{
Use: "generate",
Short: "Generate a Go CLI project from an API spec",
RunE: func(cmd *cobra.Command, args []string) error {
+ if docsURL != "" {
+ apiName := cliName
+ if apiName == "" {
+ apiName = "myapi"
+ }
+ docSpec, err := docspec.GenerateFromDocs(docsURL, apiName)
+ if err != nil {
+ return fmt.Errorf("generating spec from docs: %w", err)
+ }
+ docYAML, err := yaml.Marshal(docSpec)
+ if err != nil {
+ return fmt.Errorf("marshaling doc spec: %w", err)
+ }
+ // Re-parse through the standard path so validation is consistent
+ parsed, err := spec.ParseBytes(docYAML)
+ if err != nil {
+ return fmt.Errorf("parsing generated spec: %w", err)
+ }
+
+ if outputDir == "" {
+ outputDir = parsed.Name + "-cli"
+ }
+ absOut, err := filepath.Abs(outputDir)
+ if err != nil {
+ return fmt.Errorf("resolving output path: %w", err)
+ }
+ if force {
+ if err := os.RemoveAll(absOut); err != nil {
+ return fmt.Errorf("removing existing output dir: %w", err)
+ }
+ }
+
+ gen := generator.New(parsed, absOut)
+ if err := gen.Generate(); err != nil {
+ return fmt.Errorf("generating project: %w", err)
+ }
+ if validate {
+ if err := gen.Validate(); err != nil {
+ return fmt.Errorf("validating generated project: %w", err)
+ }
+ }
+
+ fmt.Fprintf(os.Stderr, "Generated %s-cli at %s (from docs)\n", parsed.Name, absOut)
+ return nil
+ }
+
if len(specFiles) == 0 {
return fmt.Errorf("--spec is required")
}
@@ -124,6 +173,7 @@ func newGenerateCmd() *cobra.Command {
cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh cached remote spec before generating")
cmd.Flags().BoolVar(&force, "force", false, "Remove existing output directory before generating")
cmd.Flags().BoolVar(&lenient, "lenient", false, "Skip validation errors from broken $refs in OpenAPI specs")
+ cmd.Flags().StringVar(&docsURL, "docs", "", "API documentation URL to generate spec from")
return cmd
}
diff --git a/internal/docspec/docspec.go b/internal/docspec/docspec.go
new file mode 100644
index 00000000..8c94e5d6
--- /dev/null
+++ b/internal/docspec/docspec.go
@@ -0,0 +1,331 @@
+package docspec
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+var (
+ endpointRe = regexp.MustCompile(`(GET|POST|PUT|PATCH|DELETE)\s+(/[a-zA-Z0-9/{}_.\-]+)`)
+ baseURLRe = regexp.MustCompile(`https://api\.[a-zA-Z0-9.\-]+`)
+ bearerRe = regexp.MustCompile(`(?i)(Bearer|Authorization:\s*Bearer)`)
+ apiKeyRe = regexp.MustCompile(`(?i)(API[_ ]key|api_key|X-API-Key)`)
+ oauthRe = regexp.MustCompile(`(?i)OAuth`)
+ paramRowRe = regexp.MustCompile(`(?i)<t[dr][^>]*>\s*(\w+)\s*</t[dr]>\s*<t[dr][^>]*>\s*(string|integer|int|boolean|bool|number|float|array|object)\s*</t[dr]>`)
+ jsonKeyRe = regexp.MustCompile(`"(\w+)"\s*:`)
+ preBlockRe = regexp.MustCompile(`(?s)<(?:pre|code)[^>]*>(.*?)</(?:pre|code)>`)
+)
+
+// GenerateFromDocs fetches an API documentation page and extracts a best-effort
+// APISpec by scanning for endpoint patterns, auth hints, and parameters.
+func GenerateFromDocs(docsURL, apiName string) (*spec.APISpec, error) {
+ body, err := fetchHTML(docsURL)
+ if err != nil {
+ return nil, fmt.Errorf("fetching docs: %w", err)
+ }
+
+ endpoints := extractEndpoints(body)
+ if len(endpoints) == 0 {
+ return nil, fmt.Errorf("no endpoints found in %s", docsURL)
+ }
+
+ resources := groupByResource(endpoints)
+ auth := detectAuth(body)
+ baseURL := detectBaseURL(body)
+ params := extractParams(body)
+
+ // Attach extracted params as body params to POST/PUT/PATCH endpoints
+ for resName, res := range resources {
+ for epName, ep := range res.Endpoints {
+ if ep.Method == "POST" || ep.Method == "PUT" || ep.Method == "PATCH" {
+ ep.Body = params
+ res.Endpoints[epName] = ep
+ }
+ }
+ resources[resName] = res
+ }
+
+ apiSpec := &spec.APISpec{
+ Name: apiName,
+ Description: fmt.Sprintf("CLI for %s (generated from docs)", apiName),
+ Version: "1.0.0",
+ BaseURL: baseURL,
+ Auth: auth,
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: fmt.Sprintf("~/.config/%s-cli/config.toml", apiName),
+ },
+ Resources: resources,
+ }
+
+ if err := apiSpec.Validate(); err != nil {
+ return nil, fmt.Errorf("generated spec failed validation: %w", err)
+ }
+
+ return apiSpec, nil
+}
+
+func fetchHTML(url string) (string, error) {
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Get(url)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+type rawEndpoint struct {
+ Method string
+ Path string
+}
+
+func extractEndpoints(body string) []rawEndpoint {
+ seen := map[string]bool{}
+ var endpoints []rawEndpoint
+
+ matches := endpointRe.FindAllStringSubmatch(body, -1)
+ for _, m := range matches {
+ key := m[1] + " " + m[2]
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ endpoints = append(endpoints, rawEndpoint{Method: m[1], Path: m[2]})
+ }
+ return endpoints
+}
+
+func groupByResource(endpoints []rawEndpoint) map[string]spec.Resource {
+ groups := map[string][]rawEndpoint{}
+
+ for _, ep := range endpoints {
+ seg := firstSegment(ep.Path)
+ groups[seg] = append(groups[seg], ep)
+ }
+
+ resources := map[string]spec.Resource{}
+ for seg, eps := range groups {
+ res := spec.Resource{
+ Description: fmt.Sprintf("Operations on %s", seg),
+ Endpoints: map[string]spec.Endpoint{},
+ }
+ nameCount := map[string]int{}
+ for _, ep := range eps {
+ name := endpointName(ep.Method, ep.Path)
+ nameCount[name]++
+ if nameCount[name] > 1 {
+ name = fmt.Sprintf("%s_%d", name, nameCount[name])
+ }
+
+ pathParams := extractPathParams(ep.Path)
+
+ res.Endpoints[name] = spec.Endpoint{
+ Method: ep.Method,
+ Path: ep.Path,
+ Description: fmt.Sprintf("%s %s", ep.Method, ep.Path),
+ Params: pathParams,
+ Response: spec.ResponseDef{
+ Type: "object",
+ },
+ }
+ }
+ resources[seg] = res
+ }
+
+ return resources
+}
+
+func firstSegment(path string) string {
+ path = strings.TrimPrefix(path, "/")
+ parts := strings.SplitN(path, "/", 2)
+ seg := parts[0]
+ // Clean version prefixes like v1, v2
+ if len(seg) >= 2 && seg[0] == 'v' && seg[1] >= '0' && seg[1] <= '9' {
+ if len(parts) > 1 {
+ sub := strings.SplitN(parts[1], "/", 2)
+ return sub[0]
+ }
+ }
+ return seg
+}
+
+func endpointName(method, path string) string {
+ parts := strings.Split(strings.TrimSuffix(path, "/"), "/")
+ last := parts[len(parts)-1]
+
+ // If the last segment is a path param like {id}, use the one before it
+ if strings.HasPrefix(last, "{") && len(parts) >= 2 {
+ last = parts[len(parts)-2]
+ }
+
+ // Clean up the segment
+ last = strings.ReplaceAll(last, "{", "")
+ last = strings.ReplaceAll(last, "}", "")
+ last = strings.ReplaceAll(last, "-", "_")
+
+ prefix := strings.ToLower(method)
+ switch prefix {
+ case "get":
+ prefix = "get"
+ case "post":
+ prefix = "create"
+ case "put":
+ prefix = "update"
+ case "patch":
+ prefix = "update"
+ case "delete":
+ prefix = "delete"
+ }
+
+ return prefix + "_" + last
+}
+
+func extractPathParams(path string) []spec.Param {
+ re := regexp.MustCompile(`\{(\w+)\}`)
+ matches := re.FindAllStringSubmatch(path, -1)
+ var params []spec.Param
+ for _, m := range matches {
+ params = append(params, spec.Param{
+ Name: m[1],
+ Type: "string",
+ Required: true,
+ Positional: true,
+ Description: fmt.Sprintf("The %s identifier", m[1]),
+ })
+ }
+ return params
+}
+
+func detectAuth(body string) spec.AuthConfig {
+ upper := strings.ToUpper(body)
+ _ = upper
+
+ if bearerRe.MatchString(body) {
+ return spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{
+ "API_TOKEN",
+ },
+ }
+ }
+ if apiKeyRe.MatchString(body) {
+ return spec.AuthConfig{
+ Type: "api_key",
+ Header: "X-API-Key",
+ Format: "{token}",
+ EnvVars: []string{
+ "API_KEY",
+ },
+ }
+ }
+ if oauthRe.MatchString(body) {
+ return spec.AuthConfig{
+ Type: "oauth2",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{
+ "API_TOKEN",
+ },
+ }
+ }
+
+ // Default
+ return spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{
+ "API_TOKEN",
+ },
+ }
+}
+
+func detectBaseURL(body string) string {
+ matches := baseURLRe.FindAllString(body, -1)
+ if len(matches) > 0 {
+ // Return the first match, preferring longer ones (more specific)
+ best := matches[0]
+ for _, m := range matches[1:] {
+ if len(m) > len(best) {
+ best = m
+ }
+ }
+ return best
+ }
+ return "https://api.example.com"
+}
+
+func extractParams(body string) []spec.Param {
+ seen := map[string]bool{}
+ var params []spec.Param
+
+ // Strategy 1: HTML table rows with name + type
+ rowMatches := paramRowRe.FindAllStringSubmatch(body, -1)
+ for _, m := range rowMatches {
+ name := m[1]
+ typ := normalizeType(m[2])
+ if !seen[name] {
+ seen[name] = true
+ params = append(params, spec.Param{
+ Name: name,
+ Type: typ,
+ Description: fmt.Sprintf("The %s parameter", name),
+ })
+ }
+ }
+
+ // Strategy 2: JSON keys from <pre>/<code> blocks
+ preMatches := preBlockRe.FindAllStringSubmatch(body, -1)
+ for _, pm := range preMatches {
+ block := pm[1]
+ keyMatches := jsonKeyRe.FindAllStringSubmatch(block, -1)
+ for _, km := range keyMatches {
+ name := km[1]
+ if !seen[name] {
+ seen[name] = true
+ params = append(params, spec.Param{
+ Name: name,
+ Type: "string",
+ Description: fmt.Sprintf("The %s parameter", name),
+ })
+ }
+ }
+ }
+
+ return params
+}
+
+func normalizeType(t string) string {
+ switch strings.ToLower(t) {
+ case "integer", "int":
+ return "integer"
+ case "boolean", "bool":
+ return "boolean"
+ case "number", "float":
+ return "number"
+ case "array":
+ return "array"
+ case "object":
+ return "object"
+ default:
+ return "string"
+ }
+}
diff --git a/internal/docspec/docspec_test.go b/internal/docspec/docspec_test.go
new file mode 100644
index 00000000..34570825
--- /dev/null
+++ b/internal/docspec/docspec_test.go
@@ -0,0 +1,141 @@
+package docspec
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const sampleDocs = `<html><body>
+<h1>My API</h1>
+<p>Base URL: https://api.myservice.com/v1</p>
+<p>Authentication: Pass your API key in the X-API-Key header.</p>
+
+<h2>Users</h2>
+<pre>GET /v1/users</pre>
+<pre>POST /v1/users</pre>
+<pre>GET /v1/users/{id}</pre>
+<pre>PUT /v1/users/{id}</pre>
+<pre>DELETE /v1/users/{id}</pre>
+
+<h2>Projects</h2>
+<pre>GET /v1/projects</pre>
+<pre>POST /v1/projects</pre>
+
+<h3>Parameters</h3>
+<table>
+<tr><td>name</td><td>string</td></tr>
+<tr><td>email</td><td>string</td></tr>
+<tr><td>age</td><td>integer</td></tr>
+<tr><td>active</td><td>boolean</td></tr>
+</table>
+
+<h3>Example</h3>
+<pre>{"title": "My Project", "description": "A project"}</pre>
+</body></html>`
+
+func TestGenerateFromDocs(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(sampleDocs))
+ }))
+ defer srv.Close()
+
+ apiSpec, err := GenerateFromDocs(srv.URL, "myapi")
+ require.NoError(t, err)
+
+ assert.Equal(t, "myapi", apiSpec.Name)
+ assert.Equal(t, "https://api.myservice.com", apiSpec.BaseURL)
+ assert.Equal(t, "api_key", apiSpec.Auth.Type)
+ assert.Contains(t, apiSpec.Resources, "users")
+ assert.Contains(t, apiSpec.Resources, "projects")
+
+ users := apiSpec.Resources["users"]
+ assert.GreaterOrEqual(t, len(users.Endpoints), 3)
+
+ projects := apiSpec.Resources["projects"]
+ assert.GreaterOrEqual(t, len(projects.Endpoints), 2)
+}
+
+func TestExtractEndpoints(t *testing.T) {
+ body := `GET /users POST /users GET /users/{id} DELETE /items/{id}`
+ eps := extractEndpoints(body)
+ assert.Len(t, eps, 4)
+ assert.Equal(t, "GET", eps[0].Method)
+ assert.Equal(t, "/users", eps[0].Path)
+}
+
+func TestDetectAuth(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ want string
+ }{
+ {"bearer", "Use Authorization: Bearer token", "bearer_token"},
+ {"api_key", "Pass your API key in the header", "api_key"},
+ {"oauth", "We support OAuth 2.0 flows", "oauth2"},
+ {"default", "No auth mentioned here", "bearer_token"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ auth := detectAuth(tt.body)
+ assert.Equal(t, tt.want, auth.Type)
+ })
+ }
+}
+
+func TestDetectBaseURL(t *testing.T) {
+ assert.Equal(t, "https://api.stripe.com", detectBaseURL("Base URL: https://api.stripe.com/v1"))
+ assert.Equal(t, "https://api.example.com", detectBaseURL("No URL here"))
+}
+
+func TestFirstSegment(t *testing.T) {
+ assert.Equal(t, "users", firstSegment("/users"))
+ assert.Equal(t, "users", firstSegment("/v1/users"))
+ assert.Equal(t, "users", firstSegment("/v2/users/{id}"))
+ assert.Equal(t, "items", firstSegment("/items/search"))
+}
+
+func TestEndpointName(t *testing.T) {
+ assert.Equal(t, "get_users", endpointName("GET", "/v1/users"))
+ assert.Equal(t, "create_users", endpointName("POST", "/v1/users"))
+ assert.Equal(t, "get_users", endpointName("GET", "/v1/users/{id}"))
+ assert.Equal(t, "delete_items", endpointName("DELETE", "/items/{id}"))
+}
+
+func TestExtractParams(t *testing.T) {
+ body := `<table><tr><td>name</td><td>string</td></tr><tr><td>count</td><td>integer</td></tr></table>
+<pre>{"title": "hello", "active": true}</pre>`
+ params := extractParams(body)
+ assert.GreaterOrEqual(t, len(params), 2)
+
+ names := map[string]bool{}
+ for _, p := range params {
+ names[p.Name] = true
+ }
+ assert.True(t, names["name"])
+ assert.True(t, names["count"])
+}
+
+func TestExtractPathParams(t *testing.T) {
+ params := extractPathParams("/users/{user_id}/posts/{post_id}")
+ assert.Len(t, params, 2)
+ assert.Equal(t, "user_id", params[0].Name)
+ assert.True(t, params[0].Required)
+ assert.True(t, params[0].Positional)
+ assert.Equal(t, "post_id", params[1].Name)
+}
+
+func TestNoEndpointsError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("<html><body>Nothing here</body></html>"))
+ }))
+ defer srv.Close()
+
+ _, err := GenerateFromDocs(srv.URL, "empty")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no endpoints found")
+}
diff --git a/internal/pipeline/learnings.go b/internal/pipeline/learnings.go
new file mode 100644
index 00000000..b75e473a
--- /dev/null
+++ b/internal/pipeline/learnings.go
@@ -0,0 +1,122 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// PressLearning records what happened during a single pipeline run.
+type PressLearning struct {
+ APIName string `json:"api_name"`
+ Date time.Time `json:"date"`
+ SpecType string `json:"spec_type"`
+ Issues []LearningIssue `json:"issues"`
+ Fixes []LearningFix `json:"fixes"`
+}
+
+// LearningIssue records a problem encountered during a pipeline phase.
+type LearningIssue struct {
+ Phase string `json:"phase"`
+ Gate string `json:"gate"`
+ Error string `json:"error"`
+ Pattern string `json:"pattern"`
+}
+
+// LearningFix records a solution applied for a known pattern.
+type LearningFix struct {
+ Pattern string `json:"pattern"`
+ Solution string `json:"solution"`
+}
+
+// LearningsDB is the on-disk store of all pipeline learnings.
+type LearningsDB struct {
+ Learnings []PressLearning `json:"learnings"`
+}
+
+// LearningsPath returns the path to the learnings database file.
+func LearningsPath() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return filepath.Join(".cache", "printing-press", "learnings.json")
+ }
+ return filepath.Join(home, ".cache", "printing-press", "learnings.json")
+}
+
+// LoadLearnings reads the learnings database from disk.
+func LoadLearnings() (*LearningsDB, error) {
+ path := LearningsPath()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return &LearningsDB{}, nil
+ }
+ return nil, err
+ }
+ var db LearningsDB
+ if err := json.Unmarshal(data, &db); err != nil {
+ return nil, err
+ }
+ return &db, nil
+}
+
+// SaveLearning appends a learning to the database and writes to disk.
+func SaveLearning(learning PressLearning) error {
+ db, err := LoadLearnings()
+ if err != nil {
+ db = &LearningsDB{}
+ }
+ db.Learnings = append(db.Learnings, learning)
+
+ path := LearningsPath()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(db, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0o644)
+}
+
+// SuggestFlags returns CLI flag suggestions based on past learnings for
+// similar spec types and sizes.
+func SuggestFlags(specSize int, specType string) []string {
+ db, err := LoadLearnings()
+ if err != nil || len(db.Learnings) == 0 {
+ return nil
+ }
+
+ // Collect patterns from past runs with the same spec type
+ patternCounts := make(map[string]int)
+ for _, l := range db.Learnings {
+ if l.SpecType != specType {
+ continue
+ }
+ for _, issue := range l.Issues {
+ if issue.Pattern != "" {
+ patternCounts[issue.Pattern]++
+ }
+ }
+ }
+
+ var flags []string
+ if patternCounts["broken-ref"] > 0 {
+ flags = append(flags, "--lenient")
+ }
+ if patternCounts["missing-auth"] > 0 {
+ flags = append(flags, "--skip-auth-check")
+ }
+ if patternCounts["large-spec"] > 0 || specSize > 500000 {
+ flags = append(flags, "--chunk-size=100")
+ }
+ if patternCounts["slow-generation"] > 0 {
+ flags = append(flags, "--timeout=600")
+ }
+ if patternCounts["circular-ref"] > 0 {
+ flags = append(flags, "--max-depth=5")
+ }
+
+ return flags
+}
diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go
index 0c84fa9a..19a01b48 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -56,17 +56,35 @@ func Init(apiName string, opts Options) (*PipelineState, error) {
PipelineDir: pipeDir,
}
- for _, phase := range PhaseOrder {
+ // Write only the first two phases as static seeds (preflight + research).
+ // Subsequent phases are generated dynamically after prior phases complete.
+ staticPhases := []string{PhasePreflight, PhaseResearch}
+ for _, phase := range staticPhases {
content, err := RenderSeed(phase, seedData)
if err != nil {
return nil, fmt.Errorf("rendering seed for %s: %w", phase, err)
}
-
planPath := state.PlanPath(phase)
if err := os.WriteFile(planPath, []byte(content), 0o644); err != nil {
return nil, fmt.Errorf("writing plan seed for %s: %w", phase, err)
}
+ state.MarkSeedWritten(phase)
+ }
+ // For remaining phases, write placeholder plans that will be replaced
+ // dynamically when prior phases complete (via CompleteAndPlanNext).
+ for _, phase := range PhaseOrder {
+ if phase == PhasePreflight || phase == PhaseResearch {
+ continue
+ }
+ content, err := RenderSeed(phase, seedData)
+ if err != nil {
+ return nil, fmt.Errorf("rendering seed for %s: %w", phase, err)
+ }
+ planPath := state.PlanPath(phase)
+ if err := os.WriteFile(planPath, []byte(content), 0o644); err != nil {
+ return nil, fmt.Errorf("writing plan seed for %s: %w", phase, err)
+ }
state.MarkSeedWritten(phase)
}
diff --git a/internal/pipeline/planner.go b/internal/pipeline/planner.go
new file mode 100644
index 00000000..d2dafd2a
--- /dev/null
+++ b/internal/pipeline/planner.go
@@ -0,0 +1,250 @@
+package pipeline
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// PlanContext aggregates outputs from completed phases for dynamic plan generation.
+type PlanContext struct {
+ SeedData SeedData
+ Research *ResearchResult
+ Dogfood *DogfoodResults
+ Scorecard *Scorecard
+ Learnings *LearningsDB
+}
+
+// GenerateNextPlan writes a dynamic plan for the next phase, informed by
+// all available prior phase outputs. Falls back to static seed if no
+// dynamic generation is available for that phase.
+func GenerateNextPlan(state *PipelineState, nextPhase string) (string, error) {
+ pipeDir := PipelineDir(state.APIName)
+ ctx := PlanContext{
+ SeedData: SeedData{
+ APIName: state.APIName,
+ OutputDir: state.OutputDir,
+ SpecURL: state.SpecURL,
+ PipelineDir: pipeDir,
+ },
+ }
+
+ // Load all available prior phase outputs (silently ignore missing ones)
+ ctx.Research, _ = LoadResearch(pipeDir)
+ ctx.Dogfood, _ = LoadDogfoodResults(pipeDir)
+ ctx.Scorecard, _ = LoadScorecard(pipeDir)
+ ctx.Learnings, _ = LoadLearnings()
+
+ switch nextPhase {
+ case PhaseScaffold:
+ return generateScaffoldPlan(ctx)
+ case PhaseEnrich:
+ return generateEnrichPlan(ctx)
+ case PhaseReview:
+ return generateReviewPlan(ctx)
+ case PhaseComparative:
+ return generateComparativePlan(ctx)
+ case PhaseShip:
+ return generateShipPlan(ctx)
+ default:
+ // Preflight, Research use static seeds
+ return RenderSeed(nextPhase, ctx.SeedData)
+ }
+}
+
+func generateScaffoldPlan(ctx PlanContext) (string, error) {
+ var b strings.Builder
+ writePlanHeader(&b, ctx.SeedData.APIName, "scaffold", "Generate the CLI with intelligence from research")
+
+ b.WriteString("## Phase Goal\n\n")
+ b.WriteString(fmt.Sprintf("Generate the %s CLI from the validated OpenAPI spec, incorporating research insights.\n\n", ctx.SeedData.APIName))
+
+ b.WriteString("## Context\n\n")
+ writePipelineContext(&b, ctx.SeedData)
+
+ // Dynamic section: research insights
+ if ctx.Research != nil {
+ b.WriteString("## Research Insights\n\n")
+ b.WriteString(fmt.Sprintf("- **Novelty score:** %d/10 (%s)\n", ctx.Research.NoveltyScore, ctx.Research.Recommendation))
+ b.WriteString(fmt.Sprintf("- **Alternatives found:** %d\n", len(ctx.Research.Alternatives)))
+
+ if ctx.Research.CompetitorInsights != nil {
+ ci := ctx.Research.CompetitorInsights
+ b.WriteString(fmt.Sprintf("- **Command target:** %d (based on competitor analysis)\n", ci.CommandTarget))
+ if len(ci.UnmetFeatures) > 0 {
+ b.WriteString("- **Unmet features to include:**\n")
+ for _, f := range ci.UnmetFeatures[:min(5, len(ci.UnmetFeatures))] {
+ b.WriteString(fmt.Sprintf(" - %s\n", f))
+ }
+ }
+ if len(ci.PainPointsToAvoid) > 0 {
+ b.WriteString("- **Pain points to avoid:**\n")
+ for _, p := range ci.PainPointsToAvoid[:min(3, len(ci.PainPointsToAvoid))] {
+ b.WriteString(fmt.Sprintf(" - %s\n", p))
+ }
+ }
+ }
+ b.WriteString("\n")
+ }
+
+ // Dynamic section: learnings from past runs
+ if ctx.Learnings != nil && len(ctx.Learnings.Learnings) > 0 {
+ b.WriteString("## Known Pitfalls (from past runs)\n\n")
+ suggestions := SuggestFlags(0, "openapi")
+ if len(suggestions) > 0 {
+ b.WriteString("Suggested flags based on past issues:\n")
+ for _, s := range suggestions {
+ b.WriteString(fmt.Sprintf("- `%s`\n", s))
+ }
+ b.WriteString("\n")
+ }
+ }
+
+ b.WriteString("## What This Phase Must Produce\n\n")
+ b.WriteString(fmt.Sprintf("- Generated CLI source tree in %s\n", ctx.SeedData.OutputDir))
+ b.WriteString("- All seven generator quality gates passing\n")
+ b.WriteString(fmt.Sprintf("- Working CLI binary for %s\n\n", ctx.SeedData.APIName))
+
+ b.WriteString("## Codebase Pointers\n\n")
+ b.WriteString("- Generator entrypoint: printing-press generate --spec <url> --output <dir>\n")
+ b.WriteString("- Generator implementation: internal/generator/\n")
+ b.WriteString("- Quality gate logic: internal/generator/validate.go\n")
+
+ return b.String(), nil
+}
+
+func generateEnrichPlan(ctx PlanContext) (string, error) {
+ var b strings.Builder
+ writePlanHeader(&b, ctx.SeedData.APIName, "enrich", "Enrich the CLI using competitor intelligence")
+
+ b.WriteString("## Phase Goal\n\n")
+ b.WriteString("Produce an overlay that adds missing endpoints and improves descriptions based on competitor analysis.\n\n")
+
+ writePipelineContext(&b, ctx.SeedData)
+
+ if ctx.Research != nil && ctx.Research.CompetitorInsights != nil {
+ ci := ctx.Research.CompetitorInsights
+ b.WriteString("## Competitor-Driven Enrichments\n\n")
+ if len(ci.UnmetFeatures) > 0 {
+ b.WriteString("Features that competitors requested but never got (add these):\n")
+ for _, f := range ci.UnmetFeatures {
+ b.WriteString(fmt.Sprintf("- [ ] %s\n", f))
+ }
+ b.WriteString("\n")
+ }
+ if ci.CommandTarget > 0 {
+ b.WriteString(fmt.Sprintf("**Target:** Generate at least %d commands (competitors max: %d)\n\n", ci.CommandTarget, int(float64(ci.CommandTarget)/1.2)))
+ }
+ }
+
+ b.WriteString("## What This Phase Must Produce\n\n")
+ b.WriteString(fmt.Sprintf("- overlay.yaml in %s\n", ctx.SeedData.PipelineDir))
+ b.WriteString("- At least one verified enrichment\n")
+ b.WriteString("- Overlay valid for downstream merge and regeneration\n\n")
+
+ b.WriteString("## Codebase Pointers\n\n")
+ b.WriteString("- Overlay model: internal/pipeline/overlay.go\n")
+ b.WriteString("- Overlay merge: internal/pipeline/merge.go\n")
+
+ return b.String(), nil
+}
+
+func generateReviewPlan(ctx PlanContext) (string, error) {
+ var b strings.Builder
+ writePlanHeader(&b, ctx.SeedData.APIName, "review", "Review with automated scoring")
+
+ b.WriteString("## Phase Goal\n\n")
+ b.WriteString("Score the generated CLI against the Steinberger quality bar and competitor CLIs.\n\n")
+
+ writePipelineContext(&b, ctx.SeedData)
+
+ b.WriteString("## Steps\n\n")
+ b.WriteString("1. Run the Steinberger scorecard (internal/pipeline/scorecard.go)\n")
+ b.WriteString("2. Run dogfood Tier 1 (no auth) on the generated binary\n")
+ b.WriteString("3. Augment README with real dogfood output\n")
+ b.WriteString("4. Run anti-AI text filter on README\n")
+ b.WriteString("5. If any Steinberger dimension < 5/10, generate fix plans (self-improvement)\n\n")
+
+ b.WriteString("## What This Phase Must Produce\n\n")
+ b.WriteString(fmt.Sprintf("- scorecard.md in %s\n", ctx.SeedData.PipelineDir))
+ b.WriteString(fmt.Sprintf("- dogfood-results.json in %s\n", ctx.SeedData.PipelineDir))
+ b.WriteString("- Augmented README with real output\n")
+ b.WriteString("- Fix plans for any low-scoring dimensions\n")
+
+ return b.String(), nil
+}
+
+func generateComparativePlan(ctx PlanContext) (string, error) {
+ var b strings.Builder
+ writePlanHeader(&b, ctx.SeedData.APIName, "comparative", "Compare against competitors")
+
+ b.WriteString("## Phase Goal\n\n")
+ b.WriteString("Score our CLI vs discovered alternatives on 6 dimensions.\n\n")
+
+ writePipelineContext(&b, ctx.SeedData)
+
+ if ctx.Scorecard != nil {
+ b.WriteString("## Current Steinberger Score\n\n")
+ b.WriteString(fmt.Sprintf("- Overall: %d%% (%s)\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+ if len(ctx.Scorecard.GapReport) > 0 {
+ b.WriteString("- Gaps:\n")
+ for _, g := range ctx.Scorecard.GapReport {
+ b.WriteString(fmt.Sprintf(" - %s\n", g))
+ }
+ }
+ b.WriteString("\n")
+ }
+
+ b.WriteString("## What This Phase Must Produce\n\n")
+ b.WriteString(fmt.Sprintf("- comparative-analysis.md in %s\n\n", ctx.SeedData.PipelineDir))
+
+ return b.String(), nil
+}
+
+func generateShipPlan(ctx PlanContext) (string, error) {
+ var b strings.Builder
+ writePlanHeader(&b, ctx.SeedData.APIName, "ship", "Package and prepare for release")
+
+ b.WriteString("## Phase Goal\n\n")
+ b.WriteString("Package the CLI for distribution.\n\n")
+
+ writePipelineContext(&b, ctx.SeedData)
+
+ // Dynamic: ship/hold decision based on scores
+ if ctx.Scorecard != nil {
+ b.WriteString("## Ship Decision\n\n")
+ if ctx.Scorecard.Steinberger.Percentage >= 65 {
+ b.WriteString(fmt.Sprintf("**SHIP** - Steinberger score %d%% (grade %s) meets threshold.\n\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+ } else {
+ b.WriteString(fmt.Sprintf("**HOLD** - Steinberger score %d%% (grade %s) is below 65%% threshold.\n", ctx.Scorecard.Steinberger.Percentage, ctx.Scorecard.OverallGrade))
+ b.WriteString("Fix the gaps identified in the scorecard before shipping.\n\n")
+ }
+ }
+
+ b.WriteString("## What This Phase Must Produce\n\n")
+ b.WriteString(fmt.Sprintf("- Git repository initialized in %s\n", ctx.SeedData.OutputDir))
+ b.WriteString("- GoReleaser config validated\n")
+ b.WriteString(fmt.Sprintf("- Morning report in %s\n", ctx.SeedData.PipelineDir))
+
+ return b.String(), nil
+}
+
+// Helper functions
+
+func writePlanHeader(b *strings.Builder, apiName, phase, title string) {
+ b.WriteString("---\n")
+ b.WriteString(fmt.Sprintf("title: \"%s CLI Pipeline - %s\"\n", apiName, title))
+ b.WriteString("type: feat\n")
+ b.WriteString("status: seed\n")
+ b.WriteString(fmt.Sprintf("pipeline_phase: %s\n", phase))
+ b.WriteString(fmt.Sprintf("pipeline_api: %s\n", apiName))
+ b.WriteString(fmt.Sprintf("date: %s\n", time.Now().Format("2006-01-02")))
+ b.WriteString("---\n\n")
+}
+
+func writePipelineContext(b *strings.Builder, sd SeedData) {
+ b.WriteString("## Context\n\n")
+ b.WriteString(fmt.Sprintf("- Pipeline directory: %s\n", sd.PipelineDir))
+ b.WriteString(fmt.Sprintf("- Output directory: %s\n", sd.OutputDir))
+ b.WriteString(fmt.Sprintf("- Spec URL: %s\n\n", sd.SpecURL))
+}
diff --git a/internal/pipeline/research.go b/internal/pipeline/research.go
index dfe50ae8..4ec47bbc 100644
--- a/internal/pipeline/research.go
+++ b/internal/pipeline/research.go
@@ -1,12 +1,15 @@
package pipeline
import (
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
+ "math"
"net/http"
"os"
"path/filepath"
+ "regexp"
"strings"
"time"
@@ -15,13 +18,32 @@ import (
// ResearchResult holds the output of the research phase.
type ResearchResult struct {
- APIName string `json:"api_name"`
- NoveltyScore int `json:"novelty_score"` // 1-10
- Alternatives []Alternative `json:"alternatives"`
- Gaps []string `json:"gaps"` // what alternatives miss
- Patterns []string `json:"patterns"` // what alternatives do well
- Recommendation string `json:"recommendation"` // "proceed", "proceed-with-gaps", "skip"
- ResearchedAt time.Time `json:"researched_at"`
+ APIName string `json:"api_name"`
+ NoveltyScore int `json:"novelty_score"` // 1-10
+ Alternatives []Alternative `json:"alternatives"`
+ Gaps []string `json:"gaps"` // what alternatives miss
+ Patterns []string `json:"patterns"` // what alternatives do well
+ Recommendation string `json:"recommendation"` // "proceed", "proceed-with-gaps", "skip"
+ ResearchedAt time.Time `json:"researched_at"`
+ CompetitorInsights *CompetitorInsights `json:"competitor_insights,omitempty"`
+}
+
+// CompetitorAnalysis holds intelligence gathered from a single competitor repo.
+type CompetitorAnalysis struct {
+ RepoURL string `json:"repo_url"`
+ CommandsFound []string `json:"commands_found"`
+ FeatureRequests []string `json:"feature_requests"`
+ AbandonedPRs []string `json:"abandoned_prs"`
+ PainPoints []string `json:"pain_points"`
+ CommandCount int `json:"command_count"`
+}
+
+// CompetitorInsights aggregates intelligence across all analyzed competitors.
+type CompetitorInsights struct {
+ Analyses []CompetitorAnalysis `json:"analyses"`
+ CommandTarget int `json:"command_target"`
+ UnmetFeatures []string `json:"unmet_features"`
+ PainPointsToAvoid []string `json:"pain_points_to_avoid"`
}
// Alternative represents a known competing CLI tool.
@@ -75,6 +97,25 @@ func RunResearch(apiName, catalogDir, pipelineDir string) (*ResearchResult, erro
// Step 5: Analyze gaps and patterns
result.Gaps, result.Patterns = analyzeAlternatives(result.Alternatives)
+ // Step 5.5: Competitor intelligence - analyze GitHub repos
+ var analyses []CompetitorAnalysis
+ for _, alt := range result.Alternatives {
+ owner, repo := parseGitHubURL(alt.URL)
+ if owner == "" || repo == "" {
+ continue
+ }
+ analysis, err := analyzeCompetitorRepo(owner, repo)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: competitor analysis failed for %s/%s: %v\n", owner, repo, err)
+ continue
+ }
+ analyses = append(analyses, *analysis)
+ }
+ if len(analyses) > 0 {
+ insights := synthesizeInsights(analyses)
+ result.CompetitorInsights = &insights
+ }
+
// Step 6: Write research.json
if err := writeResearchJSON(result, pipelineDir); err != nil {
return result, fmt.Errorf("writing research.json: %w", err)
@@ -304,3 +345,295 @@ func writeResearchJSON(result *ResearchResult, pipelineDir string) error {
}
return os.WriteFile(filepath.Join(pipelineDir, "research.json"), data, 0o644)
}
+
+// parseGitHubURL extracts owner and repo from a GitHub URL.
+// Returns empty strings if the URL is not a valid GitHub repo URL.
+func parseGitHubURL(url string) (owner, repo string) {
+ // Match https://github.com/owner/repo or https://github.com/owner/repo/...
+ url = strings.TrimSuffix(url, "/")
+ url = strings.TrimSuffix(url, ".git")
+ if !strings.Contains(url, "github.com/") {
+ return "", ""
+ }
+ parts := strings.Split(url, "github.com/")
+ if len(parts) < 2 {
+ return "", ""
+ }
+ segments := strings.SplitN(parts[1], "/", 3)
+ if len(segments) < 2 {
+ return "", ""
+ }
+ return segments[0], segments[1]
+}
+
+// ghIssue models a GitHub issue from the API.
+type ghIssue struct {
+ Title string `json:"title"`
+ Body string `json:"body"`
+ HTMLURL string `json:"html_url"`
+ State string `json:"state"`
+ CreatedAt string `json:"created_at"`
+}
+
+// ghPull models a GitHub pull request from the API.
+type ghPull struct {
+ Title string `json:"title"`
+ HTMLURL string `json:"html_url"`
+ MergedAt *string `json:"merged_at"`
+}
+
+// ghReadmeResponse models the GitHub README API response.
+type ghReadmeResponse struct {
+ Content string `json:"content"`
+ Encoding string `json:"encoding"`
+}
+
+// analyzeCompetitorRepo gathers intelligence from a competitor's GitHub repo.
+func analyzeCompetitorRepo(owner, repo string) (*CompetitorAnalysis, error) {
+ client := &http.Client{Timeout: 15 * time.Second}
+ repoURL := fmt.Sprintf("https://github.com/%s/%s", owner, repo)
+ analysis := &CompetitorAnalysis{
+ RepoURL: repoURL,
+ }
+
+ // Fetch feature requests (issues labeled enhancement or feature-request)
+ featureIssues, err := fetchIssues(client, owner, repo, "enhancement,feature-request")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: failed to fetch labeled issues for %s/%s: %v\n", owner, repo, err)
+ }
+ // If no labeled issues found, fall back to any open issues
+ if len(featureIssues) == 0 {
+ featureIssues, err = fetchIssues(client, owner, repo, "")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: failed to fetch open issues for %s/%s: %v\n", owner, repo, err)
+ }
+ }
+ for _, issue := range featureIssues {
+ analysis.FeatureRequests = append(analysis.FeatureRequests, issue.Title)
+ // Extract pain points from issue bodies mentioning common frustration signals
+ if containsPainSignal(issue.Title) || containsPainSignal(issue.Body) {
+ analysis.PainPoints = append(analysis.PainPoints, issue.Title)
+ }
+ }
+
+ // Fetch README and parse for CLI commands
+ readmeContent, err := fetchReadme(client, owner, repo)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: failed to fetch README for %s/%s: %v\n", owner, repo, err)
+ } else {
+ analysis.CommandsFound = parseCommandsFromReadme(readmeContent)
+ analysis.CommandCount = len(analysis.CommandsFound)
+ }
+
+ // Fetch abandoned PRs (closed but not merged)
+ abandonedPRs, err := fetchAbandonedPRs(client, owner, repo)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: failed to fetch PRs for %s/%s: %v\n", owner, repo, err)
+ }
+ for _, pr := range abandonedPRs {
+ analysis.AbandonedPRs = append(analysis.AbandonedPRs, pr.Title)
+ }
+
+ return analysis, nil
+}
+
+// newGitHubRequest creates an http.Request with appropriate headers.
+func newGitHubRequest(url string) (*http.Request, error) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ if token := os.Getenv("GITHUB_TOKEN"); token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ return req, nil
+}
+
+func fetchIssues(client *http.Client, owner, repo, labels string) ([]ghIssue, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues?state=open&per_page=10", owner, repo)
+ if labels != "" {
+ url += "&labels=" + labels
+ }
+
+ req, err := newGitHubRequest(url)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
+ }
+
+ var issues []ghIssue
+ if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
+ return nil, err
+ }
+ return issues, nil
+}
+
+func fetchReadme(client *http.Client, owner, repo string) (string, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/readme", owner, repo)
+
+ req, err := newGitHubRequest(url)
+ if err != nil {
+ return "", err
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("GitHub API returned %d", resp.StatusCode)
+ }
+
+ var readme ghReadmeResponse
+ if err := json.NewDecoder(resp.Body).Decode(&readme); err != nil {
+ return "", err
+ }
+
+ if readme.Encoding != "base64" {
+ return "", fmt.Errorf("unexpected encoding: %s", readme.Encoding)
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(readme.Content, "\n", ""))
+ if err != nil {
+ return "", fmt.Errorf("decoding base64: %w", err)
+ }
+ return string(decoded), nil
+}
+
+func fetchAbandonedPRs(client *http.Client, owner, repo string) ([]ghPull, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls?state=closed&per_page=5", owner, repo)
+
+ req, err := newGitHubRequest(url)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
+ }
+
+ var prs []ghPull
+ if err := json.NewDecoder(resp.Body).Decode(&prs); err != nil {
+ return nil, err
+ }
+
+ // Filter to only abandoned (closed but not merged)
+ var abandoned []ghPull
+ for _, pr := range prs {
+ if pr.MergedAt == nil {
+ abandoned = append(abandoned, pr)
+ }
+ }
+ return abandoned, nil
+}
+
+// commandPattern matches CLI command patterns in READMEs.
+// Looks for lines like: `toolname subcommand`, `$ toolname subcommand`, or indented command blocks.
+var commandPattern = regexp.MustCompile(`(?m)^\s*(?:\$\s+)?(\w[\w-]+)\s+([\w-]+)(?:\s|$)`)
+
+func parseCommandsFromReadme(content string) []string {
+ seen := make(map[string]bool)
+ var commands []string
+
+ matches := commandPattern.FindAllStringSubmatch(content, -1)
+ for _, m := range matches {
+ cmd := m[2]
+ // Skip common non-command words
+ if isCommonWord(cmd) {
+ continue
+ }
+ if !seen[cmd] {
+ seen[cmd] = true
+ commands = append(commands, cmd)
+ }
+ }
+ return commands
+}
+
+func isCommonWord(w string) bool {
+ common := map[string]bool{
+ "the": true, "and": true, "for": true, "with": true, "that": true,
+ "this": true, "from": true, "are": true, "was": true, "will": true,
+ "can": true, "has": true, "have": true, "not": true, "but": true,
+ "all": true, "your": true, "you": true, "use": true, "how": true,
+ "about": true, "more": true, "when": true, "into": true,
+ }
+ return common[strings.ToLower(w)]
+}
+
+// containsPainSignal checks if text contains signals of user frustration.
+func containsPainSignal(text string) bool {
+ lower := strings.ToLower(text)
+ signals := []string{
+ "bug", "broken", "crash", "error", "fail", "slow",
+ "confusing", "unclear", "missing", "wrong", "doesn't work",
+ "not working", "can't", "cannot", "won't", "frustrat",
+ }
+ for _, s := range signals {
+ if strings.Contains(lower, s) {
+ return true
+ }
+ }
+ return false
+}
+
+// synthesizeInsights aggregates competitor analyses into actionable insights.
+func synthesizeInsights(analyses []CompetitorAnalysis) CompetitorInsights {
+ insights := CompetitorInsights{
+ Analyses: analyses,
+ }
+
+ // Find max command count and set target to 1.2x
+ maxCommands := 0
+ for _, a := range analyses {
+ if a.CommandCount > maxCommands {
+ maxCommands = a.CommandCount
+ }
+ }
+ insights.CommandTarget = int(math.Ceil(float64(maxCommands) * 1.2))
+
+ // Collect all unique feature requests as unmet features
+ seenFeatures := make(map[string]bool)
+ for _, a := range analyses {
+ for _, f := range a.FeatureRequests {
+ lower := strings.ToLower(f)
+ if !seenFeatures[lower] {
+ seenFeatures[lower] = true
+ insights.UnmetFeatures = append(insights.UnmetFeatures, f)
+ }
+ }
+ }
+
+ // Collect all unique pain points
+ seenPains := make(map[string]bool)
+ for _, a := range analyses {
+ for _, p := range a.PainPoints {
+ lower := strings.ToLower(p)
+ if !seenPains[lower] {
+ seenPains[lower] = true
+ insights.PainPointsToAvoid = append(insights.PainPointsToAvoid, p)
+ }
+ }
+ }
+
+ return insights
+}
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
new file mode 100644
index 00000000..a7292c9c
--- /dev/null
+++ b/internal/pipeline/scorecard.go
@@ -0,0 +1,397 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// Scorecard holds the auto-scored evaluation of a generated CLI against the Steinberger bar.
+type Scorecard struct {
+ APIName string `json:"api_name"`
+ Steinberger SteinerScore `json:"steinberger"`
+ CompetitorScores []CompScore `json:"competitor_scores"`
+ OverallGrade string `json:"overall_grade"`
+ GapReport []string `json:"gap_report"`
+}
+
+// SteinerScore breaks down the Steinberger bar into 8 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
+ LocalCache int `json:"local_cache"` // 0-10
+ Total int `json:"total"` // 0-80
+ Percentage int `json:"percentage"` // 0-100
+}
+
+// CompScore compares our score against a competitor on a single dimension.
+type CompScore struct {
+ Name string `json:"name"`
+ OurScore int `json:"our_score"`
+ TheirScore int `json:"their_score"`
+ WeWin bool `json:"we_win"`
+}
+
+// RunScorecard evaluates generated CLI files and produces a scorecard.
+func RunScorecard(outputDir, pipelineDir string) (*Scorecard, error) {
+ sc := &Scorecard{}
+
+ // Infer API name from outputDir basename
+ sc.APIName = filepath.Base(outputDir)
+
+ // Score each Steinberger dimension by inspecting generated files
+ sc.Steinberger.OutputModes = scoreOutputModes(outputDir)
+ sc.Steinberger.Auth = scoreAuth(outputDir)
+ sc.Steinberger.ErrorHandling = scoreErrorHandling(outputDir)
+ sc.Steinberger.TerminalUX = scoreTerminalUX(outputDir)
+ sc.Steinberger.README = scoreREADME(outputDir)
+ sc.Steinberger.Doctor = scoreDoctor(outputDir)
+ sc.Steinberger.AgentNative = scoreAgentNative(outputDir)
+ sc.Steinberger.LocalCache = scoreLocalCache(outputDir)
+
+ sc.Steinberger.Total = sc.Steinberger.OutputModes +
+ sc.Steinberger.Auth +
+ sc.Steinberger.ErrorHandling +
+ sc.Steinberger.TerminalUX +
+ sc.Steinberger.README +
+ sc.Steinberger.Doctor +
+ sc.Steinberger.AgentNative +
+ sc.Steinberger.LocalCache
+
+ if sc.Steinberger.Total > 0 {
+ sc.Steinberger.Percentage = (sc.Steinberger.Total * 100) / 80
+ }
+
+ // Grade
+ sc.OverallGrade = computeGrade(sc.Steinberger.Percentage)
+
+ // Gap report for dimensions below 5
+ sc.GapReport = buildGapReport(sc.Steinberger)
+
+ // Competitor comparison from research.json
+ sc.CompetitorScores = buildCompetitorScores(sc.Steinberger.Total, pipelineDir)
+
+ // Write scorecard.md
+ if err := writeScorecardMD(sc, pipelineDir); err != nil {
+ return sc, fmt.Errorf("writing scorecard.md: %w", err)
+ }
+
+ return sc, nil
+}
+
+func scoreOutputModes(dir string) int {
+ content := readFileContent(filepath.Join(dir, "cmd", "root.go"))
+ score := 0
+ if strings.Contains(content, "json") {
+ score += 2
+ }
+ if strings.Contains(content, "plain") {
+ score += 2
+ }
+ if strings.Contains(content, "select") {
+ score += 2
+ }
+ if strings.Contains(content, "table") {
+ score += 2
+ }
+ if strings.Contains(content, "csv") {
+ score += 2
+ }
+ if score > 10 {
+ score = 10
+ }
+ return score
+}
+
+func scoreAuth(dir string) int {
+ score := 0
+ configContent := readFileContent(filepath.Join(dir, "internal", "config.go"))
+ envCount := strings.Count(configContent, "os.Getenv")
+ if envCount >= 2 {
+ score += 8
+ } else if envCount >= 1 {
+ score += 5
+ }
+ if fileExists(filepath.Join(dir, "internal", "auth.go")) {
+ score += 2
+ }
+ if score > 10 {
+ score = 10
+ }
+ return score
+}
+
+func scoreErrorHandling(dir string) int {
+ content := readFileContent(filepath.Join(dir, "internal", "helpers.go"))
+ score := 0
+ if strings.Contains(content, "hint:") || strings.Contains(content, "Hint:") {
+ score += 5
+ }
+ // Count distinct exit codes (os.Exit calls with different values)
+ exitCount := strings.Count(content, "os.Exit(")
+ if exitCount > 5 {
+ exitCount = 5
+ }
+ score += exitCount
+ if score > 10 {
+ score = 10
+ }
+ return score
+}
+
+func scoreTerminalUX(dir string) int {
+ content := readFileContent(filepath.Join(dir, "internal", "helpers.go"))
+ score := 0
+ if strings.Contains(content, "colorEnabled") {
+ score += 5
+ }
+ if strings.Contains(content, "NO_COLOR") {
+ score += 3
+ }
+ if strings.Contains(content, "isatty") {
+ score += 2
+ }
+ if score > 10 {
+ score = 10
+ }
+ return score
+}
+
+func scoreREADME(dir string) int {
+ content := readFileContent(filepath.Join(dir, "README.md"))
+ score := 0
+ sections := map[string]int{
+ "Quick Start": 2,
+ "Output Formats": 2,
+ "Agent Usage": 2,
+ "Troubleshooting": 2,
+ "Doctor": 2,
+ }
+ for section, pts := range sections {
+ if strings.Contains(content, section) {
+ score += pts
+ }
+ }
+ if score > 10 {
+ score = 10
+ }
+ return score
+}
+
+func scoreDoctor(dir string) int {
+ content := readFileContent(filepath.Join(dir, "cmd", "doctor.go"))
+ if content == "" {
+ content = readFileContent(filepath.Join(dir, "internal", "doctor.go"))
+ }
+ healthChecks := strings.Count(content, "http.Get")
+ score := healthChecks * 2
+ if score > 10 {
+ score = 10
+ }
+ return score
+}
+
+func scoreAgentNative(dir string) int {
+ rootContent := readFileContent(filepath.Join(dir, "cmd", "root.go"))
+ helpersContent := readFileContent(filepath.Join(dir, "internal", "helpers.go"))
+ combined := rootContent + helpersContent
+
+ score := 0
+ if strings.Contains(combined, "json") {
+ score += 3
+ }
+ if strings.Contains(combined, "select") {
+ score += 3
+ }
+ if strings.Contains(combined, "dry-run") || strings.Contains(combined, "dryRun") || strings.Contains(combined, "dry_run") {
+ score += 3
+ }
+ if strings.Contains(combined, "non-interactive") || strings.Contains(combined, "nonInteractive") {
+ score += 1
+ }
+ if score > 10 {
+ score = 10
+ }
+ return score
+}
+
+func scoreLocalCache(dir string) int {
+ // Check for sqlite or cache-related imports across all Go files
+ for _, name := range []string{"internal/cache.go", "internal/store.go", "cmd/root.go"} {
+ content := readFileContent(filepath.Join(dir, name))
+ if strings.Contains(content, "sqlite") || strings.Contains(content, "bolt") || strings.Contains(content, "badger") {
+ return 5
+ }
+ }
+ return 0
+}
+
+func readFileContent(path string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return string(data)
+}
+
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+func computeGrade(percentage int) string {
+ switch {
+ case percentage >= 80:
+ return "A"
+ case percentage >= 65:
+ return "B"
+ case percentage >= 50:
+ return "C"
+ case percentage >= 35:
+ return "D"
+ default:
+ return "F"
+ }
+}
+
+func buildGapReport(s SteinerScore) []string {
+ var gaps []string
+ dimensions := []struct {
+ name string
+ score int
+ }{
+ {"output_modes", s.OutputModes},
+ {"auth", s.Auth},
+ {"error_handling", s.ErrorHandling},
+ {"terminal_ux", s.TerminalUX},
+ {"readme", s.README},
+ {"doctor", s.Doctor},
+ {"agent_native", s.AgentNative},
+ {"local_cache", s.LocalCache},
+ }
+ for _, d := range dimensions {
+ if d.score < 5 {
+ gaps = append(gaps, fmt.Sprintf("%s scored %d/10 - needs improvement", d.name, d.score))
+ }
+ }
+ return gaps
+}
+
+func buildCompetitorScores(ourTotal int, pipelineDir string) []CompScore {
+ research, err := LoadResearch(pipelineDir)
+ if err != nil {
+ return nil
+ }
+ var scores []CompScore
+ for _, alt := range research.Alternatives {
+ theirScore := estimateCompetitorTotal(alt)
+ scores = append(scores, CompScore{
+ Name: alt.Name,
+ OurScore: ourTotal,
+ TheirScore: theirScore,
+ WeWin: ourTotal > theirScore,
+ })
+ }
+ return scores
+}
+
+func estimateCompetitorTotal(alt Alternative) int {
+ score := 0
+ if alt.HasJSON {
+ score += 6 // output_modes partial credit
+ }
+ if alt.HasAuth {
+ score += 5 // auth partial credit
+ }
+ // Assume basic error handling and terminal UX
+ score += 3
+ score += 3
+ // README and doctor are unknowns - give partial credit
+ score += 4
+ score += 2
+ // Agent native: partial if they have JSON
+ if alt.HasJSON {
+ score += 3
+ }
+ return score
+}
+
+func writeScorecardMD(sc *Scorecard, pipelineDir string) error {
+ if err := os.MkdirAll(pipelineDir, 0o755); err != nil {
+ return err
+ }
+
+ var b strings.Builder
+ b.WriteString(fmt.Sprintf("# Scorecard: %s\n\n", sc.APIName))
+ b.WriteString(fmt.Sprintf("**Overall Grade: %s** (%d%%)\n\n", sc.OverallGrade, sc.Steinberger.Percentage))
+
+ // Steinberger dimensions table
+ b.WriteString("## Steinberger Bar\n\n")
+ b.WriteString("| Dimension | Score | Max |\n")
+ b.WriteString("|-----------|-------|-----|\n")
+ s := sc.Steinberger
+ dimensions := []struct {
+ name string
+ score int
+ }{
+ {"Output Modes", s.OutputModes},
+ {"Auth", s.Auth},
+ {"Error Handling", s.ErrorHandling},
+ {"Terminal UX", s.TerminalUX},
+ {"README", s.README},
+ {"Doctor", s.Doctor},
+ {"Agent Native", s.AgentNative},
+ {"Local Cache", s.LocalCache},
+ }
+ for _, d := range dimensions {
+ bar := strings.Repeat("#", d.score) + strings.Repeat(".", 10-d.score)
+ b.WriteString(fmt.Sprintf("| %s | %d/10 %s |\n", d.name, d.score, bar))
+ }
+ b.WriteString(fmt.Sprintf("| **Total** | **%d/80** |\n\n", s.Total))
+
+ // Competitor comparison
+ if len(sc.CompetitorScores) > 0 {
+ b.WriteString("## Competitor Comparison\n\n")
+ b.WriteString("| Competitor | Ours | Theirs | Winner |\n")
+ b.WriteString("|------------|------|--------|--------|\n")
+ for _, cs := range sc.CompetitorScores {
+ winner := "Them"
+ if cs.WeWin {
+ winner = "Us"
+ }
+ b.WriteString(fmt.Sprintf("| %s | %d | %d | %s |\n", cs.Name, cs.OurScore, cs.TheirScore, winner))
+ }
+ b.WriteString("\n")
+ }
+
+ // Gap report
+ if len(sc.GapReport) > 0 {
+ b.WriteString("## Gaps\n\n")
+ for _, g := range sc.GapReport {
+ b.WriteString(fmt.Sprintf("- %s\n", g))
+ }
+ b.WriteString("\n")
+ }
+
+ return os.WriteFile(filepath.Join(pipelineDir, "scorecard.md"), []byte(b.String()), 0o644)
+}
+
+// LoadScorecard reads a scorecard from a pipeline directory's scorecard.json.
+func LoadScorecard(pipelineDir string) (*Scorecard, error) {
+ data, err := os.ReadFile(filepath.Join(pipelineDir, "scorecard.json"))
+ if err != nil {
+ return nil, err
+ }
+ var sc Scorecard
+ if err := json.Unmarshal(data, &sc); err != nil {
+ return nil, err
+ }
+ return &sc, nil
+}
diff --git a/internal/pipeline/selfimprove.go b/internal/pipeline/selfimprove.go
new file mode 100644
index 00000000..a2b5c28c
--- /dev/null
+++ b/internal/pipeline/selfimprove.go
@@ -0,0 +1,165 @@
+package pipeline
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// templateMapping maps Steinberger dimensions to the template files responsible.
+var templateMapping = map[string][]string{
+ "output_modes": {"root.go.tmpl", "helpers.go.tmpl"},
+ "auth": {"config.go.tmpl", "auth.go.tmpl"},
+ "error_handling": {"helpers.go.tmpl"},
+ "terminal_ux": {"helpers.go.tmpl"},
+ "readme": {"readme.md.tmpl"},
+ "doctor": {"doctor.go.tmpl"},
+ "agent_native": {"root.go.tmpl", "helpers.go.tmpl"},
+ "local_cache": {},
+}
+
+// dimensionAdvice maps each dimension to concrete improvement guidance.
+var dimensionAdvice = map[string]string{
+ "output_modes": `Add support for all five output modes in the root command:
+- --format=json (structured machine output)
+- --format=plain (human-readable default)
+- --format=table (columnar alignment)
+- --format=csv (spreadsheet-friendly)
+- --select=FIELD (jq-style field extraction)
+
+Templates to modify: root.go.tmpl (add format flag), helpers.go.tmpl (add formatting functions).`,
+
+ "auth": `Ensure the config template reads at least two env vars (API key + optional base URL).
+Add an auth.go.tmpl that validates credentials on startup and provides clear error
+messages when credentials are missing.
+
+Templates to modify: config.go.tmpl (add env var reads), auth.go.tmpl (add validation).`,
+
+ "error_handling": `Add actionable hint messages for common failure modes:
+- "hint: set ENV_VAR to authenticate" when 401 received
+- "hint: check your network connection" on connection errors
+- "hint: run 'doctor' to diagnose" as a catch-all
+Use distinct exit codes: 1=general, 2=auth, 3=network, 4=not-found, 5=rate-limited.
+
+Template to modify: helpers.go.tmpl (add hintedError function and exit code constants).`,
+
+ "terminal_ux": `Add color support with NO_COLOR and isatty detection:
+- Check NO_COLOR env var to disable colors
+- Use isatty to detect pipe vs terminal
+- Wrap colorEnabled flag around all ANSI output
+
+Template to modify: helpers.go.tmpl (add color detection and formatting functions).`,
+
+ "readme": `Ensure README template includes all five scored sections:
+- Quick Start (install + first command)
+- Output Formats (examples of each format)
+- Agent Usage (how to use in scripts/agents)
+- Troubleshooting (common errors and fixes)
+- Doctor (what the doctor command checks)
+
+Template to modify: readme.md.tmpl (add missing sections).`,
+
+ "doctor": `Add health check HTTP calls for each API dependency:
+- Base API URL reachability
+- Auth endpoint validation
+- Rate limit status check
+- Each check should use http.Get and report pass/fail
+
+Template to modify: doctor.go.tmpl (add http.Get health check functions).`,
+
+ "agent_native": `Ensure all agent-friendly features are present:
+- --format=json for structured output
+- --select=FIELD for field extraction
+- --dry-run to preview API calls without executing
+- --non-interactive to suppress prompts
+
+Templates to modify: root.go.tmpl (add flags), helpers.go.tmpl (add dry-run logic).`,
+
+ "local_cache": `This requires a new template. Create cache.go.tmpl that:
+- Uses a local SQLite or bolt database in ~/.cache/<cli-name>/
+- Caches GET responses with configurable TTL
+- Provides --no-cache flag to bypass
+- Provides --clear-cache flag to purge
+
+Note: No existing template covers this - a new cache.go.tmpl is needed.`,
+}
+
+// GenerateFixPlans creates a fix plan markdown file for each Steinberger
+// dimension scoring below 5/10.
+func GenerateFixPlans(scorecard *Scorecard, pipelineDir string) ([]string, error) {
+ if err := os.MkdirAll(pipelineDir, 0o755); err != nil {
+ return nil, fmt.Errorf("creating pipeline dir: %w", err)
+ }
+
+ dimensions := []struct {
+ name string
+ score int
+ }{
+ {"output_modes", scorecard.Steinberger.OutputModes},
+ {"auth", scorecard.Steinberger.Auth},
+ {"error_handling", scorecard.Steinberger.ErrorHandling},
+ {"terminal_ux", scorecard.Steinberger.TerminalUX},
+ {"readme", scorecard.Steinberger.README},
+ {"doctor", scorecard.Steinberger.Doctor},
+ {"agent_native", scorecard.Steinberger.AgentNative},
+ {"local_cache", scorecard.Steinberger.LocalCache},
+ }
+
+ var plans []string
+ for _, d := range dimensions {
+ if d.score >= 5 {
+ continue
+ }
+
+ planPath := filepath.Join(pipelineDir, fmt.Sprintf("fix-%s-plan.md", d.name))
+ content := buildFixPlan(scorecard.APIName, d.name, d.score)
+ if err := os.WriteFile(planPath, []byte(content), 0o644); err != nil {
+ return plans, fmt.Errorf("writing fix plan for %s: %w", d.name, err)
+ }
+ plans = append(plans, planPath)
+ }
+
+ return plans, nil
+}
+
+func buildFixPlan(apiName, dimension string, currentScore int) string {
+ var b strings.Builder
+
+ b.WriteString(fmt.Sprintf("# Fix Plan: %s\n\n", dimension))
+ b.WriteString(fmt.Sprintf("**API:** %s\n", apiName))
+ b.WriteString(fmt.Sprintf("**Current Score:** %d/10\n", currentScore))
+ b.WriteString(fmt.Sprintf("**Target Score:** 8/10\n\n"))
+
+ // Templates involved
+ templates := templateMapping[dimension]
+ if len(templates) > 0 {
+ b.WriteString("## Templates to Modify\n\n")
+ for _, t := range templates {
+ b.WriteString(fmt.Sprintf("- `templates/%s`\n", t))
+ }
+ } else {
+ b.WriteString("## Templates to Create\n\n")
+ b.WriteString("- `templates/cache.go.tmpl` (new)\n")
+ }
+ b.WriteString("\n")
+
+ // Improvement advice
+ b.WriteString("## What to Change\n\n")
+ if advice, ok := dimensionAdvice[dimension]; ok {
+ b.WriteString(advice)
+ } else {
+ b.WriteString("No specific guidance available for this dimension.\n")
+ }
+ b.WriteString("\n\n")
+
+ // Verification
+ b.WriteString("## Verification\n\n")
+ b.WriteString("After applying changes, re-run the scorecard:\n\n")
+ b.WriteString("```bash\n")
+ b.WriteString(fmt.Sprintf("printing-press scorecard --api %s\n", apiName))
+ b.WriteString("```\n\n")
+ b.WriteString(fmt.Sprintf("The %s dimension should score at least 8/10.\n", dimension))
+
+ return b.String()
+}
diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
index c91bc71a..aec39709 100644
--- a/internal/pipeline/state.go
+++ b/internal/pipeline/state.go
@@ -183,6 +183,30 @@ func (s *PipelineState) Complete(phase string) {
s.Phases[phase] = p
}
+// CompleteAndPlanNext marks a phase as completed, then generates a dynamic
+// plan for the next phase using outputs from all completed phases.
+func (s *PipelineState) CompleteAndPlanNext(phase string) error {
+ s.Complete(phase)
+ nextPhase := s.NextPhase()
+ if nextPhase == "" {
+ return nil // all phases done
+ }
+
+ plan, err := GenerateNextPlan(s, nextPhase)
+ if err != nil {
+ // Fall back to existing seed plan (already written at init time)
+ fmt.Fprintf(os.Stderr, "warning: dynamic plan generation failed for %s, using seed: %v\n", nextPhase, err)
+ return nil
+ }
+
+ planPath := s.PlanPath(nextPhase)
+ if err := os.WriteFile(planPath, []byte(plan), 0o644); err != nil {
+ return fmt.Errorf("writing dynamic plan for %s: %w", nextPhase, err)
+ }
+ s.MarkExpanded(nextPhase)
+ return nil
+}
+
// Fail marks a phase as failed.
func (s *PipelineState) Fail(phase string) {
p := s.Phases[phase]
← ffb7a7e6 feat(parser): add lenient mode + comprehensive test suite fr
·
back to Cli Printing Press
·
feat(pipeline): full press run with MakeBestCLI, scorecard f 99de67ec →