← back to Cli Printing Press
feat(cli): printing-press patch — AST-inject PR #218 features into published CLIs (#221)
16ed5a566b6cb511c58a190f0a4fc8aa3de2a3ef · 2026-04-18 02:20:50 -0700 · Trevin Chow
Adds `printing-press patch <cli-dir>` so the three self-contained PR #218
features (--profile, --deliver, feedback) can land in already-published
library CLIs without a full regenerate.
The patcher AST-injects (via github.com/dave/dst) into internal/cli/root.go:
* 4 fields on rootFlags
* 3 imports (bytes/io/os)
* 2 persistent flag registrations
* deliver-setup + profile-lookup blocks in PersistentPreRunE
* 2 AddCommand calls
* post-Execute deliverBuf flush
And drops in three templated files (profile.go, deliver.go, feedback.go),
substituting .Name + .Owner from .printing-press.json + the existing
copyright header. No spec, module path, or VisionSet reconstruction.
Validated against ESPN (25 novel commands, synthetic spec): all originals
preserved, all three new commands functional, build green.
Resource-level collisions (e.g. a spec whose own feedback resource already
generated feedback.go) are detected and refused unless --force. All AST
mutations are idempotent.
Async --wait / jobs deliberately out of scope — that unit requires spec-
driven per-endpoint mutation; handle those CLIs via reprint.
See docs/plans/2026-04-18-001-feat-patch-library-clis-v2-plan.md for the
full architecture and rollout plan.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-04-18-001-feat-patch-library-clis-v2-plan.mdM go.modM go.sumA internal/cli/patch_cmd.goM internal/cli/root.goM internal/generator/generator.goA internal/patch/ast_inject.goA internal/patch/ast_inject_test.goA internal/patch/collisions.goA internal/patch/dropins.goA internal/patch/patch.go
Diff
commit 16ed5a566b6cb511c58a190f0a4fc8aa3de2a3ef
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat Apr 18 02:20:50 2026 -0700
feat(cli): printing-press patch — AST-inject PR #218 features into published CLIs (#221)
Adds `printing-press patch <cli-dir>` so the three self-contained PR #218
features (--profile, --deliver, feedback) can land in already-published
library CLIs without a full regenerate.
The patcher AST-injects (via github.com/dave/dst) into internal/cli/root.go:
* 4 fields on rootFlags
* 3 imports (bytes/io/os)
* 2 persistent flag registrations
* deliver-setup + profile-lookup blocks in PersistentPreRunE
* 2 AddCommand calls
* post-Execute deliverBuf flush
And drops in three templated files (profile.go, deliver.go, feedback.go),
substituting .Name + .Owner from .printing-press.json + the existing
copyright header. No spec, module path, or VisionSet reconstruction.
Validated against ESPN (25 novel commands, synthetic spec): all originals
preserved, all three new commands functional, build green.
Resource-level collisions (e.g. a spec whose own feedback resource already
generated feedback.go) are detected and refused unless --force. All AST
mutations are idempotent.
Async --wait / jobs deliberately out of scope — that unit requires spec-
driven per-endpoint mutation; handle those CLIs via reprint.
See docs/plans/2026-04-18-001-feat-patch-library-clis-v2-plan.md for the
full architecture and rollout plan.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
...26-04-18-001-feat-patch-library-clis-v2-plan.md | 232 ++++++++++++
go.mod | 3 +
go.sum | 10 +
internal/cli/patch_cmd.go | 109 ++++++
internal/cli/root.go | 1 +
internal/generator/generator.go | 5 +
internal/patch/ast_inject.go | 396 +++++++++++++++++++++
internal/patch/ast_inject_test.go | 148 ++++++++
internal/patch/collisions.go | 68 ++++
internal/patch/dropins.go | 108 ++++++
internal/patch/patch.go | 188 ++++++++++
11 files changed, 1268 insertions(+)
diff --git a/docs/plans/2026-04-18-001-feat-patch-library-clis-v2-plan.md b/docs/plans/2026-04-18-001-feat-patch-library-clis-v2-plan.md
new file mode 100644
index 00000000..d3ecf230
--- /dev/null
+++ b/docs/plans/2026-04-18-001-feat-patch-library-clis-v2-plan.md
@@ -0,0 +1,232 @@
+---
+title: "feat(cli): printing-press patch v2 — AST injection + rollout to 21 library CLIs"
+type: feat
+status: draft
+date: 2026-04-18
+supersedes: 2026-04-17-003-feat-patch-library-clis-plan.md
+---
+
+# feat(cli): `printing-press patch` v2 — AST injection + rollout to 21 library CLIs
+
+## What changed from v1
+
+The v1 plan proposed re-rendering `root.go` from current templates. Document review surfaced four P0 findings:
+1. 10 of 21 CLIs have no spec on disk (plan-driven, sniff-driven, internal-yaml, graphql-sdl)
+2. Re-rendering would silently delete novel `AddCommand` lines on synthetic CLIs like ESPN (25 novel commands) — `go build` doesn't catch orphaned-but-unreferenced files
+3. `feedback` resource collision with Pagliacci's spec-derived `newFeedbackCmd`
+4. Missing `internal/cliutil/*` and `agent_context.go` in older CLIs would break re-rendered root.go imports
+
+The AST-injection pivot makes all four disappear. A prototype (`/tmp/ast-prototype/`, ~270 LOC using `github.com/dave/dst`) was validated against ESPN: produced a buildable binary with all 25 novel commands intact plus the three new ones (`profile`, `feedback`, plus `--profile` / `--deliver` flags). No spec read, no template re-render, no module-path rewrite, no `VisionSet`/`Narrative`/`PromotedCommands` reconstruction.
+
+## Goal
+
+Ship `printing-press patch <library-cli-dir>` and apply it to all 21 CLIs in `~/Code/printing-press-library/library/` so PR #218's three compounding features (`--profile`, `--deliver`, `feedback`) land without a full reprint. One PR per category against the library repo.
+
+## Non-goals
+
+- **Async `--wait` / `jobs` deliberately out of scope.** That unit re-renders per-endpoint command files, which requires a spec. For the 10 spec-less CLIs this is impossible, and for the 11 spec-present ones, the per-endpoint mutation is large enough that a targeted reprint is cheaper and safer. Address via reprint when a specific CLI needs async.
+- **Scorecard re-run not included.** Scorecard is orthogonal (machine-only change from PR #218); it runs correctly against any library CLI already.
+- **README untouched.** Some CLIs have hand-curated READMEs with agent-hints; re-rendering risks loss. SKILL.md is also left untouched for the same reason.
+- **No backfill of existing manifests.** The separate `fix(cli): publish archives spec` PR handles *future* publishes.
+
+## Architecture
+
+### AST injection (automated via `dst`)
+
+Applied to `internal/cli/root.go` in each library CLI:
+
+| Target | Mutation | Idempotency check |
+|---|---|---|
+| `rootFlags` struct | Append 4 fields: `profileName string`, `deliverSpec string`, `deliverBuf *bytes.Buffer`, `deliverSink DeliverSink` | Skip if field already present (by name) |
+| Import block | Add `bytes`, `io`, `os` (whichever are absent) | Skip each import if already present |
+| `Execute()` body | Insert 2 `PersistentFlags().StringVar` calls after the last existing flag registration | Skip if `--profile` already registered |
+| `PersistentPreRunE` body | Insert deliver-setup block + profile-lookup block at the top of the function body | Skip if `flags.deliverSpec` reference already present |
+| `Execute()` body | Insert 2 `rootCmd.AddCommand(...)` calls (`newProfileCmd`, `newFeedbackCmd`) after the last existing AddCommand | Skip if `newProfileCmd` already registered |
+| Post-`rootCmd.Execute()` | Insert deliverBuf flush block before final `return err` | Skip if `Deliver(` already called in function |
+
+All insertion points located via `dst.Inspect` with positional matching (last `PersistentFlags` call, last `AddCommand` call, function body of named function). Run `goimports` post-pass to sort the new imports.
+
+### Drop-in files (rendered from templates, substituting only `{{.Name}}` and `{{envName .Name}}`)
+
+- `internal/cli/profile.go` — 328 LOC, self-contained, uses `{{.Name}}` only
+- `internal/cli/deliver.go` — 113 LOC, self-contained, uses `{{.Name}}` only
+- `internal/cli/feedback.go` — 222 LOC, self-contained, uses `{{.Name}}` and `{{envName .Name}}`
+
+`{{.Name}}` comes from `.printing-press.json`'s `api_name` field. `currentYear` hard-coded to the current year. No other template variables needed (these templates do not reference `modulePath`, `Owner`, `VisionSet`, or any spec-derived data).
+
+### Collision detection (refuse to patch)
+
+Before any write, scan the target CLI for conflicts. Fail loudly with an actionable message:
+
+| Conflict | Signal | Example case |
+|---|---|---|
+| `feedback` resource in spec | `internal/cli/feedback.go` already exists | Pagliacci (has Pagliacci-specific feedback API) |
+| `profile` resource in spec | `internal/cli/profile.go` already exists | hypothetical |
+| `deliver` resource in spec | `internal/cli/deliver.go` already exists | hypothetical |
+| `jobs` resource in spec | `internal/cli/jobs.go` already exists | N/A currently, but async unit adds one |
+| `--profile` / `--deliver` flag already registered | AST scan | Already-patched CLI |
+| Custom `rootFlags` fields named `profileName`/`deliverSpec`/`deliverBuf`/`deliverSink` | AST scan | Already-patched CLI (→ skip silently) |
+
+Resource-level collisions are **fatal**. Flag-level collisions are **idempotent** (patcher has already run — no-op).
+
+## Implementation
+
+### Layout
+
+```
+internal/patch/
+ patch.go ~200 LOC public API (Patch, Options, Report)
+ ast_inject.go ~250 LOC dst-based injection routines
+ ast_inject_test.go ~200 LOC golden tests for each mutation
+ dropins.go ~80 LOC render profile.go, deliver.go, feedback.go
+ dropins_test.go ~60 LOC verify rendered content
+ collisions.go ~50 LOC detect + report conflicts
+ collisions_test.go ~80 LOC per-collision-type test
+cmd/printing-press/
+ patch.go ~60 LOC cobra subcommand, flag plumbing
+```
+
+### `Patch(opts)` flow
+
+```go
+type Options struct {
+ Dir string // CLI root, e.g. ~/.../library/productivity/cal-com
+ DryRun bool // print file list + diffs, write nothing
+ Force bool // overwrite even if collisions detected
+ SkipBuild bool // skip post-patch `go build` gate
+}
+
+type Report struct {
+ Dir string
+ Name string // from .printing-press.json
+ FilesCreated []string
+ FilesModified []string
+ Collisions []Collision // empty on success
+ BuildOK bool
+ BuildOutput string // captured on failure
+}
+
+func Patch(opts Options) (*Report, error)
+```
+
+Steps:
+1. Read `.printing-press.json` → `api_name`, `cli_name`
+2. Read existing `internal/cli/root.go` — refuse if missing
+3. Parse root.go with `decorator.Parse`
+4. Detect collisions (resource-level → fatal unless `--force`)
+5. Apply AST mutations (all idempotent — safe to re-run)
+6. Write patched root.go
+7. Render drop-ins (profile.go, deliver.go, feedback.go) — skip any that already exist
+8. `gofmt -w internal/cli/` + `goimports -w internal/cli/root.go`
+9. Unless `--skip-build`: `go build ./...` in the CLI dir, capture output
+10. Return `Report`
+
+### Public CLI surface
+
+```
+printing-press patch <library-cli-dir> [flags]
+ --dry-run Show what would change without writing
+ --force Overwrite on resource-level collision
+ --skip-build Skip post-patch `go build` gate
+ --json Emit Report as JSON (for batch scripting)
+```
+
+## Rollout: apply to 21 library CLIs
+
+### Phase 1: build the patcher
+
+Single PR against `cli-printing-press`. Tests cover:
+- Golden root.go patched for a simple-shape CLI (cal-com fixture)
+- Golden root.go patched for a synthetic CLI (ESPN fixture — asserts all 25 novel AddCommand lines preserved)
+- Collision detected for a spec-with-feedback-resource fixture (Pagliacci-shaped)
+- Idempotency: second run is a no-op (reports 0 modifications)
+- `--dry-run` writes nothing
+
+### Phase 2: dry-run audit across all 21 CLIs
+
+```bash
+for dir in ~/Code/printing-press-library/library/*/*/; do
+ printing-press patch "$dir" --dry-run --json > /tmp/patch-dryrun/$(basename "$dir").json
+done
+```
+
+Inspect results. Expected outcomes:
+- **~19 CLIs**: clean patch (3 files created, 1 modified, 0 collisions)
+- **~1 CLI**: resource-level collision (Pagliacci's `feedback`). Mark for manual review.
+- **~1 CLI**: may expose an unexpected root.go shape (very old version). Mark for reprint.
+
+Do not proceed until dry-run is clean across the intended cohort.
+
+### Phase 3: apply by category, one PR each
+
+Against `printing-press-library`. Order chosen for low-to-high stakes:
+
+| PR | Category | CLIs | Why this order |
+|---|---|---|---|
+| 1 | `other/` + `devices/` + `social-and-messaging/` | weather-goat (empty cats filtered out) | Smallest blast radius, rehearsal |
+| 2 | `developer-tools/` | agent-capture, postman-explore, trigger-dev | Self-contained, easy to validate |
+| 3 | `media-and-entertainment/` | archive-is, espn, hackernews, movie-goat, steam-web | Largest novel-command surface — prove AST preserves them |
+| 4 | `food-and-dining/` | pagliacci-pizza (manual if collision), recipe-goat | Exercises collision handling |
+| 5 | `commerce/` | dominos-pp-cli, instacart, yahoo-finance | |
+| 6 | `marketing/` + `travel/` + `payments/` + `monitoring/` | dub, flightgoat, kalshi | |
+| 7 | `productivity/` + `project-management/` + `sales-and-crm/` | cal-com, slack, linear, hubspot | Largest command surfaces (cal-com has 30+), save for last |
+
+Per-CLI validation before committing each batch:
+```bash
+printing-press patch <cli-dir> --json | tee /tmp/patch-report.json
+# Requires BuildOK: true, FilesCreated: 3, FilesModified: 1
+<cli-binary> --help | grep -E "^\s+(profile|feedback)\s" # new commands present
+<cli-binary> --help | grep -E "^\s+--(profile|deliver)\s" # new flags present
+<cli-binary> profile list # smoke test
+<cli-binary> feedback --help # smoke test
+```
+
+Commit message per batch:
+```
+feat(cli): apply PR #218 patch — profile, deliver, feedback (<category>)
+
+Applied via printing-press patch on <date>. No reprint.
+CLIs: <list>
+```
+
+### Phase 4: edge-case handling
+
+- **Pagliacci's `feedback` collision**: skip the drop-in, AST-inject only. Document in the commit message. The CLI gets `--profile` / `--deliver` but not the `feedback` subcommand (because its own `feedback` command takes the name).
+- **Version-skew CLIs (`dominos-pp-cli` at `0.4.0`, `agent-capture` at `1.0.x`)**: dry-run first; if the AST shape doesn't match (e.g., different `rootFlags` structure), mark for reprint instead of hand-patching.
+- **Build failures post-patch**: revert the working tree in that CLI's directory and mark for reprint. Do not ship a half-patched CLI.
+
+### Phase 5: follow-up (separate PR)
+
+`fix(cli): generate archives plan/sniff source spec` — closes the 10-CLI spec gap forward so future patch/emboss runs have a spec to work from. Out of scope for this rollout but worth landing soon.
+
+## Resolved concerns from v1 review
+
+| v1 concern | Status under v2 |
+|---|---|
+| No spec on disk (P0) | Not needed — AST patch reads only `.printing-press.json` |
+| Novel commands silently deleted (P0) | Impossible — AST-insert doesn't touch existing AddCommand lines; prototype verified on ESPN |
+| Feedback resource collision (P0) | First-class: collision detection refuses to patch, user resolves manually |
+| Missing cliutil/agent_context (P0) | Not touched — patch doesn't reference them |
+| HasAsyncJobs build failure (P1) | Gone — async unit dropped from scope |
+| Non-goal contradiction (P1) | Resolved — patch truly does only PR #218's three features, no template re-render |
+| Shipcheck bypass (P1) | `go build` is the gate; scorecard unchanged; verify per-CLI via `--help` smoke test |
+| ModuleOverride complexity (P1) | Gone — no imports touched, existing module path preserved trivially |
+| SKILL.md narrative loss (P1) | Gone — SKILL.md not in scope |
+| Identity shift / immutability (P1) | Addressed — patched CLIs remain a single base run; no provenance rewrite |
+| Hash-based idempotency brittleness (P2) | Gone — idempotency via AST presence checks, not content hashes |
+| RenderSubset over-abstraction (P2) | Gone — no re-render |
+| Adoption: who runs patch going forward (P2) | Explicit ownership: backfill is one-shot via this rollout; future features that qualify for patch will include patch runs as part of their PR |
+
+## Risks
+
+- **AST patch fails on an unexpected root.go shape** — mitigation: `--dry-run` first; idempotency checks return no-op rather than guessing; refuse to patch if `rootCmd.PersistentFlags()` block not found
+- **`goimports` reorders imports in ways that produce a noisy diff** — mitigation: acceptable; diff noise is a one-time cost
+- **Per-CLI build failures surface unknown dependencies** — mitigation: each CLI batched separately; failed ones marked for reprint, not bulk-forced
+- **Pagliacci's collision case is not the only one** — mitigation: Phase 2 dry-run exposes all collisions before any writes; adjust per-CLI plan accordingly
+
+## Estimated scope
+
+- `internal/patch/*`: ~900 LOC total (implementation + tests)
+- `cmd/printing-press/patch.go`: ~60 LOC
+- One PR against `cli-printing-press`, roughly half the size of PR #218
+- Rollout: 7 PRs against `printing-press-library`, each touching 1-5 CLIs
diff --git a/go.mod b/go.mod
index 01f2b989..b34a9ff1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/mvanhorn/cli-printing-press
go 1.26.1
require (
+ github.com/dave/dst v0.27.4
github.com/getkin/kin-openapi v0.133.0
github.com/mark3labs/mcp-go v0.47.0
github.com/spf13/cobra v1.10.2
@@ -30,4 +31,6 @@ require (
github.com/spf13/pflag v1.0.9 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ golang.org/x/mod v0.33.0 // indirect
+ golang.org/x/tools v0.42.0 // indirect
)
diff --git a/go.sum b/go.sum
index 05877e46..372bc271 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/dave/dst v0.27.4 h1:d+EVnOZmphH+lUEXq9rit4GjsFSKJ3AhfRWf7eobTps=
+github.com/dave/dst v0.27.4/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc=
+github.com/dave/jennifer v1.5.0 h1:HmgPN93bVDpkQyYbqhCHj5QlgvUkvEOzMyEvKLgCRrg=
+github.com/dave/jennifer v1.5.0/go.mod h1:4MnyiFIlZS3l5tSDn8VnzE6ffAhYBMB2SZntBsZGUok=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -42,6 +46,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -57,10 +63,14 @@ github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4Z
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/internal/cli/patch_cmd.go b/internal/cli/patch_cmd.go
new file mode 100644
index 00000000..55dc48b5
--- /dev/null
+++ b/internal/cli/patch_cmd.go
@@ -0,0 +1,109 @@
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+
+ "github.com/mvanhorn/cli-printing-press/internal/patch"
+ "github.com/spf13/cobra"
+)
+
+func newPatchCmd() *cobra.Command {
+ var (
+ dryRun bool
+ force bool
+ skipBuild bool
+ asJSON bool
+ )
+ cmd := &cobra.Command{
+ Use: "patch <cli-dir>",
+ Short: "Apply PR #218 features (profile/deliver/feedback) to an already-published CLI without reprint",
+ Long: `Patch AST-injects --profile / --deliver / feedback wiring into an
+already-generated CLI's root.go and drops in the three companion files.
+It does not read the source spec, does not touch per-endpoint command
+files, and does not change the CLI's module path — novel and synthetic
+commands are preserved.
+
+See docs/plans/2026-04-18-001-feat-patch-library-clis-v2-plan.md.`,
+ Example: ` # Dry-run against a library CLI
+ printing-press patch ~/Code/printing-press-library/library/productivity/cal-com --dry-run
+
+ # Apply (writes root.go + creates profile.go/deliver.go/feedback.go)
+ printing-press patch ~/Code/printing-press-library/library/productivity/cal-com
+
+ # Refuse to run if Pagliacci has a feedback resource collision
+ printing-press patch ~/Code/printing-press-library/library/food-and-dining/pagliacci-pizza
+
+ # Force past a resource-level collision (skips the colliding drop-in)
+ printing-press patch .../pagliacci-pizza --force
+
+ # JSON output for batch scripting
+ printing-press patch . --dry-run --json`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ report, err := patch.Patch(patch.Options{
+ Dir: args[0],
+ DryRun: dryRun,
+ Force: force,
+ SkipBuild: skipBuild,
+ })
+ if err != nil {
+ return err
+ }
+ if asJSON {
+ return json.NewEncoder(cmd.OutOrStdout()).Encode(report)
+ }
+ printPatchSummary(cmd.ErrOrStderr(), report)
+ if len(report.Collisions) > 0 && !force {
+ return fmt.Errorf("patch refused: %d resource-level collision(s) — resolve manually or re-run with --force", len(report.Collisions))
+ }
+ if !skipBuild && !dryRun && !report.BuildOK {
+ return fmt.Errorf("post-patch build failed; see build_output in the report")
+ }
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print the file list and collisions without writing anything")
+ cmd.Flags().BoolVar(&force, "force", false, "Proceed even when resource-level collisions are detected (skips colliding drop-ins)")
+ cmd.Flags().BoolVar(&skipBuild, "skip-build", false, "Skip the post-patch `go build ./...` gate")
+ cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full Report as JSON on stdout (for batch scripting)")
+ return cmd
+}
+
+func printPatchSummary(w io.Writer, r *patch.Report) {
+ fmt.Fprintf(w, "CLI: %s\n", r.Name)
+ fmt.Fprintf(w, "Dir: %s\n", r.Dir)
+ if r.DryRun {
+ fmt.Fprintf(w, "Mode: dry-run (no writes)\n")
+ }
+ if r.Idempotent {
+ fmt.Fprintf(w, "Status: already patched — no-op\n")
+ return
+ }
+ if len(r.FilesCreated) > 0 {
+ fmt.Fprintf(w, "Created: %d\n", len(r.FilesCreated))
+ for _, f := range r.FilesCreated {
+ fmt.Fprintf(w, " + %s\n", f)
+ }
+ }
+ if len(r.FilesModified) > 0 {
+ fmt.Fprintf(w, "Modified: %d\n", len(r.FilesModified))
+ for _, f := range r.FilesModified {
+ fmt.Fprintf(w, " ~ %s\n", f)
+ }
+ }
+ if len(r.Collisions) > 0 {
+ fmt.Fprintf(w, "Collisions: %d\n", len(r.Collisions))
+ for _, c := range r.Collisions {
+ fmt.Fprintf(w, " ! [%s] %s\n", c.Kind, c.Message)
+ }
+ }
+ if !r.DryRun {
+ if r.BuildOK {
+ fmt.Fprintf(w, "Build: PASS\n")
+ } else {
+ fmt.Fprintf(w, "Build: FAIL\n%s\n", r.BuildOutput)
+ }
+ }
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 5f10a5aa..667a921e 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -44,6 +44,7 @@ func Execute() error {
rootCmd.AddCommand(newVerifyCmd())
rootCmd.AddCommand(newVerifySkillCmd())
rootCmd.AddCommand(newEmbossCmd())
+ rootCmd.AddCommand(newPatchCmd())
rootCmd.AddCommand(newVisionCmd())
rootCmd.AddCommand(newVersionCmd())
rootCmd.AddCommand(newPrintCmd())
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 357f69c6..47aa608a 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -25,6 +25,11 @@ import (
//go:embed templates
var templateFS embed.FS
+// TemplateFS exposes the embedded template tree for callers outside the
+// generator package (e.g. the patch subcommand that renders a subset of
+// templates against already-published CLIs).
+var TemplateFS = templateFS
+
// ReadmeSource represents a credited ecosystem tool for the README.
type ReadmeSource struct {
Name string
diff --git a/internal/patch/ast_inject.go b/internal/patch/ast_inject.go
new file mode 100644
index 00000000..27646464
--- /dev/null
+++ b/internal/patch/ast_inject.go
@@ -0,0 +1,396 @@
+package patch
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+
+ "github.com/dave/dst"
+ "github.com/dave/dst/decorator"
+)
+
+// injectRootAST applies PR #218's root.go mutations to src and returns the
+// patched source + whether any mutation occurred. Mutations are idempotent:
+// a second call against already-patched source returns (src, false, nil).
+func injectRootAST(src []byte) ([]byte, bool, error) {
+ file, err := decorator.Parse(src)
+ if err != nil {
+ return nil, false, fmt.Errorf("parse root.go: %w", err)
+ }
+
+ changed := false
+ changed = addRootFlagsFields(file) || changed
+ changed = addImports(file, "bytes", "io", "os") || changed
+ changed = addPersistentFlags(file) || changed
+ changed = addPreRunBlocks(file) || changed
+ changed = addCommands(file) || changed
+ changed = addPostExecuteFlush(file) || changed
+
+ if !changed {
+ return src, false, nil
+ }
+
+ var buf bytes.Buffer
+ if err := decorator.Fprint(&buf, file); err != nil {
+ return nil, false, fmt.Errorf("format root.go: %w", err)
+ }
+ return buf.Bytes(), true, nil
+}
+
+// addRootFlagsFields appends profileName, deliverSpec, deliverBuf, deliverSink
+// to the rootFlags struct.
+func addRootFlagsFields(file *dst.File) bool {
+ changed := false
+ dst.Inspect(file, func(n dst.Node) bool {
+ ts, ok := n.(*dst.TypeSpec)
+ if !ok || ts.Name.Name != "rootFlags" {
+ return true
+ }
+ st, ok := ts.Type.(*dst.StructType)
+ if !ok {
+ return true
+ }
+ for _, name := range []string{"profileName", "deliverSpec"} {
+ if !structHasField(st, name) {
+ st.Fields.List = append(st.Fields.List, &dst.Field{
+ Names: []*dst.Ident{{Name: name}},
+ Type: &dst.Ident{Name: "string"},
+ })
+ changed = true
+ }
+ }
+ if !structHasField(st, "deliverBuf") {
+ st.Fields.List = append(st.Fields.List, &dst.Field{
+ Names: []*dst.Ident{{Name: "deliverBuf"}},
+ Type: &dst.StarExpr{X: &dst.SelectorExpr{
+ X: &dst.Ident{Name: "bytes"},
+ Sel: &dst.Ident{Name: "Buffer"},
+ }},
+ })
+ changed = true
+ }
+ if !structHasField(st, "deliverSink") {
+ st.Fields.List = append(st.Fields.List, &dst.Field{
+ Names: []*dst.Ident{{Name: "deliverSink"}},
+ Type: &dst.Ident{Name: "DeliverSink"},
+ })
+ changed = true
+ }
+ return false
+ })
+ return changed
+}
+
+func structHasField(st *dst.StructType, name string) bool {
+ for _, f := range st.Fields.List {
+ for _, n := range f.Names {
+ if n.Name == name {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// addImports adds packages to the file's import list if absent. Returns true
+// if any were added.
+func addImports(file *dst.File, pkgs ...string) bool {
+ existing := map[string]bool{}
+ for _, imp := range file.Imports {
+ existing[strings.Trim(imp.Path.Value, `"`)] = true
+ }
+ if len(file.Decls) == 0 {
+ return false
+ }
+ importDecl, ok := file.Decls[0].(*dst.GenDecl)
+ if !ok || importDecl.Tok.String() != "import" {
+ return false
+ }
+ changed := false
+ for _, pkg := range pkgs {
+ if existing[pkg] {
+ continue
+ }
+ importDecl.Specs = append(importDecl.Specs, &dst.ImportSpec{
+ Path: &dst.BasicLit{Value: fmt.Sprintf(`"%s"`, pkg)},
+ })
+ changed = true
+ }
+ return changed
+}
+
+// addPersistentFlags inserts --profile and --deliver after the last existing
+// PersistentFlags() registration inside Execute().
+func addPersistentFlags(file *dst.File) bool {
+ changed := false
+ dst.Inspect(file, func(n dst.Node) bool {
+ fn, ok := n.(*dst.FuncDecl)
+ if !ok || fn.Name.Name != "Execute" {
+ return true
+ }
+ // Idempotency: skip if --profile already registered.
+ for _, stmt := range fn.Body.List {
+ if persistentFlagsRegisters(stmt, "profile") {
+ return false
+ }
+ }
+ lastFlagIdx := -1
+ for i, stmt := range fn.Body.List {
+ if isPersistentFlagsCall(stmt) {
+ lastFlagIdx = i
+ }
+ }
+ if lastFlagIdx < 0 {
+ return false
+ }
+ newStmts := []dst.Stmt{
+ parseStmt(`rootCmd.PersistentFlags().StringVar(&flags.profileName, "profile", "", "Apply values from a saved profile")`),
+ parseStmt(`rootCmd.PersistentFlags().StringVar(&flags.deliverSpec, "deliver", "", "Route output to a sink: stdout (default), file:<path>, webhook:<url>")`),
+ }
+ fn.Body.List = append(fn.Body.List[:lastFlagIdx+1], append(newStmts, fn.Body.List[lastFlagIdx+1:]...)...)
+ changed = true
+ return false
+ })
+ return changed
+}
+
+// addPreRunBlocks inserts the deliver-setup and profile-lookup blocks at the
+// top of the PersistentPreRunE function body.
+func addPreRunBlocks(file *dst.File) bool {
+ changed := false
+ dst.Inspect(file, func(n dst.Node) bool {
+ assign, ok := n.(*dst.AssignStmt)
+ if !ok || len(assign.Lhs) != 1 {
+ return true
+ }
+ sel, ok := assign.Lhs[0].(*dst.SelectorExpr)
+ if !ok || sel.Sel.Name != "PersistentPreRunE" {
+ return true
+ }
+ if len(assign.Rhs) != 1 {
+ return false
+ }
+ fn, ok := assign.Rhs[0].(*dst.FuncLit)
+ if !ok {
+ return false
+ }
+ // Idempotency: skip if deliverSpec already referenced anywhere in body.
+ if nodeReferences(fn, "deliverSpec") {
+ return false
+ }
+ deliverBlock := parseStmt(`if flags.deliverSpec != "" {
+ sink, err := ParseDeliverSink(flags.deliverSpec)
+ if err != nil {
+ return err
+ }
+ flags.deliverSink = sink
+ if sink.Scheme != "stdout" && sink.Scheme != "" {
+ flags.deliverBuf = &bytes.Buffer{}
+ cmd.SetOut(io.MultiWriter(os.Stdout, flags.deliverBuf))
+ }
+ }`)
+ profileBlock := parseStmt(`if flags.profileName != "" {
+ profile, err := GetProfile(flags.profileName)
+ if err != nil {
+ return err
+ }
+ if profile == nil {
+ return fmt.Errorf("profile %q not found", flags.profileName)
+ }
+ if err := ApplyProfileToFlags(cmd, profile); err != nil {
+ return err
+ }
+ }`)
+ fn.Body.List = append([]dst.Stmt{deliverBlock, profileBlock}, fn.Body.List...)
+ changed = true
+ return false
+ })
+ return changed
+}
+
+// addCommands appends newProfileCmd and newFeedbackCmd AddCommand calls after
+// the last existing rootCmd.AddCommand entry.
+func addCommands(file *dst.File) bool {
+ changed := false
+ dst.Inspect(file, func(n dst.Node) bool {
+ fn, ok := n.(*dst.FuncDecl)
+ if !ok || fn.Name.Name != "Execute" {
+ return true
+ }
+ for _, stmt := range fn.Body.List {
+ if rootAddsCommand(stmt, "newProfileCmd") {
+ return false
+ }
+ }
+ lastAddIdx := -1
+ for i, stmt := range fn.Body.List {
+ if isRootAddCommand(stmt) {
+ lastAddIdx = i
+ }
+ }
+ if lastAddIdx < 0 {
+ return false
+ }
+ newStmts := []dst.Stmt{
+ parseStmt(`rootCmd.AddCommand(newProfileCmd(&flags))`),
+ parseStmt(`rootCmd.AddCommand(newFeedbackCmd(&flags))`),
+ }
+ fn.Body.List = append(fn.Body.List[:lastAddIdx+1], append(newStmts, fn.Body.List[lastAddIdx+1:]...)...)
+ changed = true
+ return false
+ })
+ return changed
+}
+
+// addPostExecuteFlush inserts the deliverBuf flush block between the last
+// rootCmd.Execute() result handling and the final `return err` of Execute().
+func addPostExecuteFlush(file *dst.File) bool {
+ changed := false
+ dst.Inspect(file, func(n dst.Node) bool {
+ fn, ok := n.(*dst.FuncDecl)
+ if !ok || fn.Name.Name != "Execute" {
+ return true
+ }
+ // Idempotency: skip if Deliver() already referenced.
+ if nodeReferences(fn, "Deliver") {
+ return false
+ }
+ // Find the final `return err` statement.
+ returnIdx := -1
+ for i, stmt := range fn.Body.List {
+ ret, ok := stmt.(*dst.ReturnStmt)
+ if !ok || len(ret.Results) != 1 {
+ continue
+ }
+ if id, ok := ret.Results[0].(*dst.Ident); ok && id.Name == "err" {
+ returnIdx = i
+ }
+ }
+ if returnIdx < 0 {
+ return false
+ }
+ flushBlock := parseStmt(`if err == nil && flags.deliverBuf != nil {
+ if derr := Deliver(flags.deliverSink, flags.deliverBuf.Bytes(), flags.compact); derr != nil {
+ fmt.Fprintf(os.Stderr, "warning: deliver to %s:%s failed: %v\n", flags.deliverSink.Scheme, flags.deliverSink.Target, derr)
+ return derr
+ }
+ }`)
+ fn.Body.List = append(fn.Body.List[:returnIdx], append([]dst.Stmt{flushBlock}, fn.Body.List[returnIdx:]...)...)
+ changed = true
+ return false
+ })
+ return changed
+}
+
+// --- AST helpers ---
+
+func isPersistentFlagsCall(stmt dst.Stmt) bool {
+ exprStmt, ok := stmt.(*dst.ExprStmt)
+ if !ok {
+ return false
+ }
+ call, ok := exprStmt.X.(*dst.CallExpr)
+ if !ok {
+ return false
+ }
+ outerSel, ok := call.Fun.(*dst.SelectorExpr)
+ if !ok {
+ return false
+ }
+ innerCall, ok := outerSel.X.(*dst.CallExpr)
+ if !ok {
+ return false
+ }
+ innerSel, ok := innerCall.Fun.(*dst.SelectorExpr)
+ if !ok {
+ return false
+ }
+ return innerSel.Sel.Name == "PersistentFlags"
+}
+
+func persistentFlagsRegisters(stmt dst.Stmt, flagName string) bool {
+ exprStmt, ok := stmt.(*dst.ExprStmt)
+ if !ok {
+ return false
+ }
+ call, ok := exprStmt.X.(*dst.CallExpr)
+ if !ok || len(call.Args) < 2 {
+ return false
+ }
+ lit, ok := call.Args[1].(*dst.BasicLit)
+ if !ok {
+ return false
+ }
+ return strings.Trim(lit.Value, `"`) == flagName
+}
+
+func isRootAddCommand(stmt dst.Stmt) bool {
+ exprStmt, ok := stmt.(*dst.ExprStmt)
+ if !ok {
+ return false
+ }
+ call, ok := exprStmt.X.(*dst.CallExpr)
+ if !ok {
+ return false
+ }
+ sel, ok := call.Fun.(*dst.SelectorExpr)
+ if !ok {
+ return false
+ }
+ id, ok := sel.X.(*dst.Ident)
+ return ok && id.Name == "rootCmd" && sel.Sel.Name == "AddCommand"
+}
+
+func rootAddsCommand(stmt dst.Stmt, ctorName string) bool {
+ exprStmt, ok := stmt.(*dst.ExprStmt)
+ if !ok {
+ return false
+ }
+ call, ok := exprStmt.X.(*dst.CallExpr)
+ if !ok || len(call.Args) != 1 {
+ return false
+ }
+ inner, ok := call.Args[0].(*dst.CallExpr)
+ if !ok {
+ return false
+ }
+ id, ok := inner.Fun.(*dst.Ident)
+ return ok && id.Name == ctorName
+}
+
+// nodeReferences walks a dst node and returns true if any Ident or
+// SelectorExpr has the given name. Used for idempotency checks.
+func nodeReferences(node dst.Node, name string) bool {
+ found := false
+ dst.Inspect(node, func(n dst.Node) bool {
+ if found {
+ return false
+ }
+ switch v := n.(type) {
+ case *dst.Ident:
+ if v.Name == name {
+ found = true
+ return false
+ }
+ case *dst.SelectorExpr:
+ if v.Sel.Name == name {
+ found = true
+ return false
+ }
+ }
+ return true
+ })
+ return found
+}
+
+// parseStmt parses a single Go statement and returns it as a dst.Stmt.
+// Wraps the snippet in a synthetic function so go/parser accepts it.
+func parseStmt(src string) dst.Stmt {
+ wrapper := "package _p\nfunc _() {\n" + src + "\n}\n"
+ file, err := decorator.Parse(wrapper)
+ if err != nil {
+ panic(fmt.Errorf("patch.parseStmt: %w (src=%q)", err, src))
+ }
+ fn := file.Decls[0].(*dst.FuncDecl)
+ return fn.Body.List[0]
+}
diff --git a/internal/patch/ast_inject_test.go b/internal/patch/ast_inject_test.go
new file mode 100644
index 00000000..22d44afd
--- /dev/null
+++ b/internal/patch/ast_inject_test.go
@@ -0,0 +1,148 @@
+package patch
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// baseRootGo is a minimal root.go that mirrors the shape of every published
+// printing-press CLI (rootFlags struct + Execute() with PersistentFlags,
+// PersistentPreRunE, AddCommand sequence, err := rootCmd.Execute()).
+const baseRootGo = `package cli
+
+import (
+ "strings"
+ "time"
+
+ "github.com/example/test/internal/client"
+ "github.com/spf13/cobra"
+)
+
+var version = "1.0.0"
+
+type rootFlags struct {
+ asJSON bool
+ compact bool
+ timeout time.Duration
+}
+
+func Execute() error {
+ var flags rootFlags
+
+ rootCmd := &cobra.Command{Use: "test-pp-cli"}
+
+ rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "Output as JSON")
+ rootCmd.PersistentFlags().BoolVar(&flags.compact, "compact", false, "Compact output")
+
+ rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
+ if flags.asJSON {
+ flags.compact = true
+ }
+ return nil
+ }
+
+ rootCmd.AddCommand(newFooCmd(&flags))
+ rootCmd.AddCommand(newBarCmd(&flags))
+ rootCmd.AddCommand(newNovelSQLCmd(&flags))
+ rootCmd.AddCommand(newDoctorCmd(&flags))
+
+ err := rootCmd.Execute()
+ if err != nil && strings.Contains(err.Error(), "unknown flag") {
+ return err
+ }
+ return err
+}
+`
+
+func TestInjectRootAST_MutatesAllSixTargets(t *testing.T) {
+ out, changed, err := injectRootAST([]byte(baseRootGo))
+ require.NoError(t, err)
+ require.True(t, changed, "expected mutation")
+ src := string(out)
+
+ assert.Contains(t, src, "profileName", "rootFlags should gain profileName field")
+ assert.Contains(t, src, "deliverSpec", "rootFlags should gain deliverSpec field")
+ assert.Contains(t, src, "deliverBuf", "rootFlags should gain deliverBuf field")
+ assert.Contains(t, src, "deliverSink", "rootFlags should gain deliverSink field")
+
+ assert.Contains(t, src, `"bytes"`, "should import bytes")
+ assert.Contains(t, src, `"io"`, "should import io")
+ assert.Contains(t, src, `"os"`, "should import os")
+
+ assert.Contains(t, src, `"profile"`, "should register --profile flag")
+ assert.Contains(t, src, `"deliver"`, "should register --deliver flag")
+
+ assert.Contains(t, src, "flags.deliverSpec != \"\"", "PersistentPreRunE should have deliver-setup block")
+ assert.Contains(t, src, "ParseDeliverSink", "PersistentPreRunE should call ParseDeliverSink")
+ assert.Contains(t, src, "flags.profileName != \"\"", "PersistentPreRunE should have profile-lookup block")
+ assert.Contains(t, src, "GetProfile", "PersistentPreRunE should call GetProfile")
+
+ assert.Contains(t, src, "newProfileCmd(&flags)", "Execute should register newProfileCmd")
+ assert.Contains(t, src, "newFeedbackCmd(&flags)", "Execute should register newFeedbackCmd")
+
+ assert.Contains(t, src, "Deliver(flags.deliverSink", "Execute should flush deliverBuf after rootCmd.Execute")
+}
+
+func TestInjectRootAST_PreservesNovelCommands(t *testing.T) {
+ out, _, err := injectRootAST([]byte(baseRootGo))
+ require.NoError(t, err)
+ src := string(out)
+
+ // Every original AddCommand must still be present, in the same order,
+ // before the new profile/feedback registrations.
+ fooIdx := strings.Index(src, "newFooCmd(&flags)")
+ barIdx := strings.Index(src, "newBarCmd(&flags)")
+ sqlIdx := strings.Index(src, "newNovelSQLCmd(&flags)")
+ doctorIdx := strings.Index(src, "newDoctorCmd(&flags)")
+ profileIdx := strings.Index(src, "newProfileCmd(&flags)")
+ feedbackIdx := strings.Index(src, "newFeedbackCmd(&flags)")
+
+ require.Greater(t, fooIdx, 0, "newFooCmd must survive")
+ require.Greater(t, barIdx, fooIdx, "newBarCmd order preserved")
+ require.Greater(t, sqlIdx, barIdx, "newNovelSQLCmd order preserved")
+ require.Greater(t, doctorIdx, sqlIdx, "newDoctorCmd order preserved")
+ require.Greater(t, profileIdx, doctorIdx, "newProfileCmd inserted after last original")
+ require.Greater(t, feedbackIdx, profileIdx, "newFeedbackCmd inserted after profile")
+}
+
+func TestInjectRootAST_Idempotent(t *testing.T) {
+ first, changed, err := injectRootAST([]byte(baseRootGo))
+ require.NoError(t, err)
+ require.True(t, changed)
+
+ second, changedAgain, err := injectRootAST(first)
+ require.NoError(t, err)
+ assert.False(t, changedAgain, "second run should be a no-op")
+ assert.Equal(t, string(first), string(second), "second run must not alter bytes")
+}
+
+func TestInjectRootAST_PreservesVersionAndImports(t *testing.T) {
+ out, _, err := injectRootAST([]byte(baseRootGo))
+ require.NoError(t, err)
+ src := string(out)
+
+ assert.Contains(t, src, `var version = "1.0.0"`, "version var must survive")
+ assert.Contains(t, src, `"github.com/example/test/internal/client"`, "module path import must survive unchanged")
+ assert.Contains(t, src, `"github.com/spf13/cobra"`, "cobra import must survive")
+}
+
+// TestInjectRootAST_NoPersistentFlagsBlock exercises the "refuse silently"
+// path: if the target root.go doesn't have the expected shape, no mutation.
+func TestInjectRootAST_NoPersistentFlagsBlock(t *testing.T) {
+ src := `package cli
+
+func Execute() error {
+ return nil
+}
+`
+ out, changed, err := injectRootAST([]byte(src))
+ require.NoError(t, err)
+ // rootFlags struct is missing so no field mutation; no PersistentFlags
+ // call so no flag mutation; no AddCommand so no command mutation.
+ // No mutation should occur — returns unchanged.
+ assert.False(t, changed, "no changes when target shape missing")
+ assert.Equal(t, src, string(out))
+}
diff --git a/internal/patch/collisions.go b/internal/patch/collisions.go
new file mode 100644
index 00000000..2e9e5632
--- /dev/null
+++ b/internal/patch/collisions.go
@@ -0,0 +1,68 @@
+package patch
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// detectCollisions scans the CLI directory and root.go source for conflicts
+// with the symbols PR #218 introduces. Returns all collisions; the caller
+// decides which are fatal.
+//
+// A resource-level collision (existing profile.go / deliver.go / feedback.go
+// whose content doesn't match the PR #218 drop-in) is fatal — patching would
+// overwrite a working spec-derived capability. An already-patched CLI
+// (matching drop-in content) is not a collision, it's idempotency.
+func detectCollisions(dir string, rootSrc []byte) ([]Collision, error) {
+ var collisions []Collision
+
+ cliDir := filepath.Join(dir, "internal", "cli")
+ for _, name := range []string{"profile.go", "deliver.go", "feedback.go"} {
+ path := filepath.Join(cliDir, name)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return nil, fmt.Errorf("reading %s: %w", path, err)
+ }
+ // A file bearing the printing-press generator header is known to be
+ // from a prior patch run or a PR #218 regenerate — not a collision.
+ if bytes.Contains(data, []byte("Generated by CLI Printing Press")) &&
+ bytes.Contains(data, []byte("DO NOT EDIT")) {
+ // Additionally check that it references the expected PR #218
+ // symbols so an unrelated generated file doesn't get a free pass.
+ if looksLikePR218Dropin(name, data) {
+ continue
+ }
+ }
+ collisions = append(collisions, Collision{
+ Kind: "resource",
+ Symbol: name[:len(name)-3], // strip ".go"
+ File: path,
+ Message: fmt.Sprintf("%s already exists and is not a PR #218 drop-in — refusing to overwrite", path),
+ })
+ }
+
+ return collisions, nil
+}
+
+// looksLikePR218Dropin returns true when the file content matches the
+// signature of the template output for profile/deliver/feedback. Cheap
+// heuristic: check for the canonical function name each template defines.
+func looksLikePR218Dropin(filename string, data []byte) bool {
+ switch filename {
+ case "profile.go":
+ return bytes.Contains(data, []byte("func newProfileCmd(")) &&
+ bytes.Contains(data, []byte("func GetProfile("))
+ case "deliver.go":
+ return bytes.Contains(data, []byte("type DeliverSink")) &&
+ bytes.Contains(data, []byte("func ParseDeliverSink("))
+ case "feedback.go":
+ return bytes.Contains(data, []byte("func newFeedbackCmd(")) &&
+ bytes.Contains(data, []byte("type FeedbackEntry"))
+ }
+ return false
+}
diff --git a/internal/patch/dropins.go b/internal/patch/dropins.go
new file mode 100644
index 00000000..c4a7aff6
--- /dev/null
+++ b/internal/patch/dropins.go
@@ -0,0 +1,108 @@
+package patch
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "text/template"
+ "time"
+
+ "github.com/mvanhorn/cli-printing-press/internal/generator"
+)
+
+// dropin describes one file the patcher creates from a template.
+type dropin struct {
+ templateName string // e.g. "profile.go.tmpl"
+ path string // absolute destination path
+}
+
+// planDropins returns the list of drop-in files that should be created for a
+// CLI, skipping any whose resource-level collision marked them as existing
+// (the patcher refuses rather than overwrites — collision handling is done
+// before this is called unless Force is set).
+func planDropins(dir string, skipCollisions []Collision) []dropin {
+ skip := map[string]bool{}
+ for _, c := range skipCollisions {
+ if c.Kind == "resource" {
+ skip[c.Symbol+".go"] = true
+ }
+ }
+ cliDir := filepath.Join(dir, "internal", "cli")
+ var out []dropin
+ for _, name := range []string{"profile.go.tmpl", "deliver.go.tmpl", "feedback.go.tmpl"} {
+ destName := strings.TrimSuffix(name, ".tmpl")
+ if skip[destName] {
+ continue
+ }
+ destPath := filepath.Join(cliDir, destName)
+ if _, err := os.Stat(destPath); err == nil {
+ // File exists; detectCollisions already decided whether this is
+ // idempotent (matching PR #218 content) or fatal.
+ continue
+ }
+ out = append(out, dropin{templateName: name, path: destPath})
+ }
+ return out
+}
+
+// renderDropin executes a template with a minimal data payload and writes
+// the result. The three drop-in templates use {{.Name}}, {{.Owner}},
+// {{envName .Name}}, and {{currentYear}} — no spec or module path needed.
+func renderDropin(d dropin, name, owner string) error {
+ src, err := fs.ReadFile(generator.TemplateFS, "templates/"+d.templateName)
+ if err != nil {
+ return fmt.Errorf("reading template %s: %w", d.templateName, err)
+ }
+ funcs := template.FuncMap{
+ "envName": envName,
+ "currentYear": func() string { return fmt.Sprintf("%d", time.Now().Year()) },
+ // modulePath is unused by these three templates, but include it as a
+ // defensive stub in case a future template revision references it.
+ "modulePath": func() string { return name + "-pp-cli" },
+ }
+ tmpl, err := template.New(d.templateName).Funcs(funcs).Parse(string(src))
+ if err != nil {
+ return fmt.Errorf("parse %s: %w", d.templateName, err)
+ }
+ f, err := os.Create(d.path)
+ if err != nil {
+ return fmt.Errorf("create %s: %w", d.path, err)
+ }
+ if err := tmpl.Execute(f, struct {
+ Name string
+ Owner string
+ }{Name: name, Owner: owner}); err != nil {
+ _ = f.Close()
+ return err
+ }
+ return f.Close()
+}
+
+// ownerRegexp matches the copyright line the generator writes to every
+// printed CLI file, e.g. `// Copyright 2026 trevin-chow. Licensed under ...`
+var ownerRegexp = regexp.MustCompile(`//\s*Copyright\s+\d+\s+(.+?)\.\s+Licensed`)
+
+// extractOwner reads an existing generated file from the CLI dir and returns
+// the copyright owner from its header, so re-rendered files match the
+// original owner rather than the machine's git-config fallback.
+func extractOwner(cliDir string) string {
+ for _, rel := range []string{"internal/cli/doctor.go", "internal/cli/helpers.go"} {
+ data, err := os.ReadFile(filepath.Join(cliDir, rel))
+ if err != nil {
+ continue
+ }
+ if m := ownerRegexp.FindSubmatch(data); len(m) == 2 {
+ return strings.TrimSpace(string(m[1]))
+ }
+ }
+ return ""
+}
+
+// envName matches the generator's convention for deriving environment
+// variable prefixes from a CLI name: upper-case, dashes → underscores.
+func envName(name string) string {
+ return strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
+}
diff --git a/internal/patch/patch.go b/internal/patch/patch.go
new file mode 100644
index 00000000..15b5e52f
--- /dev/null
+++ b/internal/patch/patch.go
@@ -0,0 +1,188 @@
+// Package patch applies PR #218's profile/deliver/feedback features to an
+// already-published printing-press CLI without re-rendering its templates.
+//
+// The patcher works by AST-injecting the cobra wiring into internal/cli/root.go
+// (preserving every existing AddCommand, including novel/synthetic commands)
+// and dropping in three self-contained companion files rendered from templates.
+// It never reads the source spec, never touches per-endpoint command files,
+// and never changes the CLI's module path.
+//
+// See docs/plans/2026-04-18-001-feat-patch-library-clis-v2-plan.md for the
+// architecture rationale.
+package patch
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+)
+
+// Options controls a single Patch run.
+type Options struct {
+ // Dir is the CLI root, e.g. ~/Code/printing-press-library/library/productivity/cal-com
+ Dir string
+ // DryRun prints the file list without writing anything.
+ DryRun bool
+ // Force overrides a resource-level collision.
+ Force bool
+ // SkipBuild skips the post-patch `go build` gate.
+ SkipBuild bool
+}
+
+// Report captures the outcome of a Patch run.
+type Report struct {
+ Dir string `json:"dir"`
+ Name string `json:"name"`
+ FilesCreated []string `json:"files_created"`
+ FilesModified []string `json:"files_modified"`
+ Collisions []Collision `json:"collisions,omitempty"`
+ Idempotent bool `json:"idempotent"`
+ BuildOK bool `json:"build_ok"`
+ BuildOutput string `json:"build_output,omitempty"`
+ DryRun bool `json:"dry_run"`
+}
+
+// Collision is a conflict between the patcher's output and an existing
+// symbol or file in the target CLI.
+type Collision struct {
+ Kind string `json:"kind"` // "resource", "flag", "command"
+ Symbol string `json:"symbol"` // e.g. "feedback", "newProfileCmd"
+ File string `json:"file"` // existing file path that collides, if any
+ Message string `json:"message"` // human-readable explanation
+}
+
+// cliProvenance is the subset of .printing-press.json the patcher needs.
+type cliProvenance struct {
+ APIName string `json:"api_name"`
+ CLIName string `json:"cli_name"`
+}
+
+// Patch applies PR #218's features to the CLI at opts.Dir and returns a Report.
+// The CLI's own files are only modified when DryRun is false and there are no
+// fatal collisions (unless Force is set).
+func Patch(opts Options) (*Report, error) {
+ prov, err := readProvenance(opts.Dir)
+ if err != nil {
+ return nil, err
+ }
+ report := &Report{
+ Dir: opts.Dir,
+ Name: prov.APIName,
+ DryRun: opts.DryRun,
+ }
+
+ rootPath := filepath.Join(opts.Dir, "internal", "cli", "root.go")
+ rootSrc, err := os.ReadFile(rootPath)
+ if err != nil {
+ return nil, fmt.Errorf("reading %s: %w", rootPath, err)
+ }
+
+ collisions, err := detectCollisions(opts.Dir, rootSrc)
+ if err != nil {
+ return nil, fmt.Errorf("collision detection: %w", err)
+ }
+ fatal := filterFatalCollisions(collisions)
+ if len(fatal) > 0 && !opts.Force {
+ report.Collisions = fatal
+ return report, nil
+ }
+ report.Collisions = collisions
+
+ patchedRoot, rootChanged, err := injectRootAST(rootSrc)
+ if err != nil {
+ return nil, fmt.Errorf("AST injection: %w", err)
+ }
+
+ dropins := planDropins(opts.Dir, fatal)
+
+ if !rootChanged && len(dropins) == 0 {
+ report.Idempotent = true
+ report.BuildOK = true
+ return report, nil
+ }
+
+ if opts.DryRun {
+ if rootChanged {
+ report.FilesModified = append(report.FilesModified, rootPath)
+ }
+ for _, d := range dropins {
+ report.FilesCreated = append(report.FilesCreated, d.path)
+ }
+ return report, nil
+ }
+
+ if rootChanged {
+ if err := os.WriteFile(rootPath, patchedRoot, 0o644); err != nil {
+ return nil, fmt.Errorf("writing %s: %w", rootPath, err)
+ }
+ report.FilesModified = append(report.FilesModified, rootPath)
+ }
+
+ owner := extractOwner(opts.Dir)
+ for _, d := range dropins {
+ if err := renderDropin(d, prov.APIName, owner); err != nil {
+ return nil, fmt.Errorf("rendering %s: %w", d.path, err)
+ }
+ report.FilesCreated = append(report.FilesCreated, d.path)
+ }
+
+ if err := goimportsDir(filepath.Join(opts.Dir, "internal", "cli")); err != nil {
+ return nil, fmt.Errorf("goimports: %w", err)
+ }
+
+ if !opts.SkipBuild {
+ out, buildErr := exec.Command("go", "build", "./...").CombinedOutput()
+ report.BuildOK = buildErr == nil
+ if buildErr != nil {
+ report.BuildOutput = string(out)
+ }
+ } else {
+ report.BuildOK = true
+ }
+
+ return report, nil
+}
+
+func readProvenance(dir string) (*cliProvenance, error) {
+ data, err := os.ReadFile(filepath.Join(dir, ".printing-press.json"))
+ if err != nil {
+ return nil, fmt.Errorf("reading .printing-press.json: %w", err)
+ }
+ var prov cliProvenance
+ if err := json.Unmarshal(data, &prov); err != nil {
+ return nil, fmt.Errorf("parsing .printing-press.json: %w", err)
+ }
+ if prov.APIName == "" {
+ return nil, fmt.Errorf(".printing-press.json: api_name is required")
+ }
+ return &prov, nil
+}
+
+func filterFatalCollisions(all []Collision) []Collision {
+ var fatal []Collision
+ for _, c := range all {
+ if c.Kind == "resource" {
+ fatal = append(fatal, c)
+ }
+ }
+ return fatal
+}
+
+// goimportsDir runs `goimports -w` over the given directory. Missing binary
+// falls back to `gofmt -w` since goimports is a superset.
+func goimportsDir(dir string) error {
+ cmd := exec.Command("goimports", "-w", dir)
+ if err := cmd.Run(); err == nil {
+ return nil
+ }
+ // Fallback: gofmt only sorts imports within a group, won't add/remove
+ // groups, but it's better than nothing and is always available.
+ out, err := exec.Command("gofmt", "-w", dir).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("gofmt: %v: %s", err, bytes.TrimSpace(out))
+ }
+ return nil
+}
← 1d4e642c fix(cli): authenticate mega MCP library fetches with GITHUB_
·
back to Cli Printing Press
·
fix(cli): publish manifest must read spec.yaml alongside spe c5ed4369 →