[object Object]

← 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

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 →