← back to Cli Printing Press
feat(cli): add name collision detection and resolution to publish workflow (#128)
0dac62322a973ed494255425de56f7de5c3bb73d · 2026-04-04 23:06:22 -0700 · Trevin Chow
When publishing a CLI whose name already exists — merged into main or in
another user's open PR — the publish flow now detects the collision and
offers three resolution paths: replace the existing CLI, rename yours
with a qualifier (e.g. notion-alt-pp-cli), or bail out.
Go side: RenameCLI function + publish rename subcommand handle the
mechanical rename of 18+ file locations. Skill side: Step 7 merged
collision detection replaces the old existing-PR check with
ownership-aware resolution.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/brainstorms/2026-03-30-publish-name-collision-requirements.mdA docs/plans/2026-03-30-001-feat-publish-name-collision-plan.mdM internal/cli/publish.goM internal/cli/publish_test.goA internal/pipeline/renamecli.goA internal/pipeline/renamecli_test.goM skills/printing-press-publish/SKILL.md
Diff
commit 0dac62322a973ed494255425de56f7de5c3bb73d
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat Apr 4 23:06:22 2026 -0700
feat(cli): add name collision detection and resolution to publish workflow (#128)
When publishing a CLI whose name already exists — merged into main or in
another user's open PR — the publish flow now detects the collision and
offers three resolution paths: replace the existing CLI, rename yours
with a qualifier (e.g. notion-alt-pp-cli), or bail out.
Go side: RenameCLI function + publish rename subcommand handle the
mechanical rename of 18+ file locations. Skill side: Step 7 merged
collision detection replaces the old existing-PR check with
ownership-aware resolution.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
...26-03-30-publish-name-collision-requirements.md | 79 +++++
...6-03-30-001-feat-publish-name-collision-plan.md | 320 +++++++++++++++++++
internal/cli/publish.go | 81 +++++
internal/cli/publish_test.go | 106 +++++++
internal/pipeline/renamecli.go | 158 ++++++++++
internal/pipeline/renamecli_test.go | 341 +++++++++++++++++++++
skills/printing-press-publish/SKILL.md | 156 +++++++++-
7 files changed, 1230 insertions(+), 11 deletions(-)
diff --git a/docs/brainstorms/2026-03-30-publish-name-collision-requirements.md b/docs/brainstorms/2026-03-30-publish-name-collision-requirements.md
new file mode 100644
index 00000000..13ee90d1
--- /dev/null
+++ b/docs/brainstorms/2026-03-30-publish-name-collision-requirements.md
@@ -0,0 +1,79 @@
+---
+date: 2026-03-30
+topic: publish-name-collision
+---
+
+# Publish Name Collision Handling
+
+## Problem Frame
+
+When a user runs `printing-press-publish`, there is no check for whether a CLI with the same name already exists in the library repo — either merged into main or in another user's open PR. This can result in accidental overwrites or duplicate submissions that create noise for reviewers.
+
+Users need to know about collisions before they submit, and they need clear paths forward: intentionally replace an existing CLI, publish alongside it under a different name, or bail out.
+
+## Requirements
+
+**Detection**
+
+- R1. Before creating a branch or PR, check the managed clone (`~/.printing-press/.publish-repo`) for an existing directory matching the CLI name in `library/`
+- R2. Before creating a branch or PR, check `gh pr list` on the library repo (without `--author @me` filter) for open PRs whose head branch matches `feat/<cli-name>`
+- R3. If a collision is detected, show the user what exists: CLI name, source (merged vs open PR), author if available, and PR link if applicable
+
+**Collision Resolution Paths**
+
+- R4. When a collision is detected, offer three paths:
+ - **Replace** — intentionally overwrite the existing CLI (opens a PR that replaces it)
+ - **Alongside** — rename yours with a qualifier and publish next to the existing one
+ - **Bail** — cancel the publish and optionally view the existing CLI or PR
+- R5. The replace path must produce a PR description that clearly states the intent to replace an existing CLI, distinguishing it from an accidental collision
+
+**Rename (Alongside) Flow**
+
+- R6. The renamed CLI must follow the format `<api-slug>-<qualifier>-pp-cli` — the user picks only the qualifier portion; prefix and suffix are locked
+- R7. Present the user with suggestions: 1 numeric fallback (e.g., `<api-slug>-2-pp-cli`) and 1 non-numeric fallback (e.g., `<api-slug>-alt-pp-cli`), plus a custom qualifier option (same format lock applies — user enters only the qualifier word)
+- R8. All suggestions must be verified non-colliding against both merged CLIs and open PRs before being presented
+- R9. A rename must propagate to all places the CLI name appears: directory name, Go module path, `.printing-press.json` manifest (`cli_name`), binary name, `cmd/<cli-name>/` directory, `.goreleaser.yaml` build targets, branch name, and PR metadata. (This list is known to be non-exhaustive — planning must trace the full set.)
+- R10. A renamed CLI's `.printing-press.json` manifest must preserve the **original** API slug in the `api_name` field (e.g., `notion`, not `notion-alt`). The `cli_name` field updates to the new name; `api_name` stays canonical.
+
+**Ownership-Aware Resolution**
+
+- R11. Collision resolution must distinguish between the current user's own PRs and other users' PRs. Replacing your own previous PR is routine (update flow). Replacing another user's PR requires explicit stronger confirmation acknowledging the other author.
+
+## Success Criteria
+
+- A user who publishes a CLI that already exists is never surprised — they always see the collision and make an explicit choice
+- The replace path produces PRs that reviewers can clearly identify as intentional replacements
+- The alongside path produces a validly-named, non-colliding CLI that passes all quality gates under its new name
+
+## Scope Boundaries
+
+- Per-CLI versioning is out of scope — deferred to a future brainstorm
+- Collision detection is scoped to the publish flow only; the generate command does not check the library repo
+- Auto-suggesting semantically meaningful qualifiers (e.g., based on spec content analysis) is out of scope — generic fallbacks + custom input only
+
+## Key Decisions
+
+- **Three-path resolution over block-or-rename:** Users should be able to intentionally replace an existing CLI, not just rename. The PR review process protects against bad replacements.
+- **Locked name format for renames:** Users pick only the qualifier, not the full name. This prevents inconsistent naming conventions and keeps the `<api-slug>-...-pp-cli` pattern intact.
+- **Generic fallback suggestions:** Auto-suggesting semantically meaningful qualifiers (from spec content) is impractical and low-value. One numeric + one non-numeric fallback covers the common cases.
+- **Same rules for merged and open-PR collisions:** Both trigger the same detection and resolution flow, but with ownership-aware confirmation for other users' PRs.
+- **Manifest preserves original API slug:** Renamed CLIs keep the canonical `api_name` in the manifest. Code that needs the true API slug reads the manifest rather than reverse-engineering it from the CLI name.
+
+## Dependencies / Assumptions
+
+- The managed clone at `~/.printing-press/.publish-repo` must be freshened (git fetch/reset) **before** collision detection runs — collision check must happen after the existing Step 6 clone management, not before
+- `gh` CLI is authenticated and can list PRs on the library repo
+- The rename propagation touches the same files that the normal publish flow already writes — no net-new file types
+
+## Outstanding Questions
+
+### Deferred to Planning
+
+- [Affects R9][Technical] What is the full list of files/paths that need updating during rename propagation? Need to trace through the publish skill's packaging step.
+- [Affects R1][Technical] Should collision detection live in Go code (a new subcommand or flag) or purely in the skill instructions? Go code would be more reliable; skill instructions would be faster to iterate.
+- [Affects R7][Needs research] Should numeric fallback start at 2 (like `postman-explore-2-pp-cli`) and scan upward, or use a fixed suggestion? Need to check what `ClaimOutputDir` already does for local collisions.
+- [Affects R6, R10][Technical] `TrimCLISuffix` returns `notion-alt` for `notion-alt-pp-cli` instead of `notion`. Since the manifest carries the canonical `api_name`, code paths that need the true API slug should prefer the manifest. Planning should audit all `TrimCLISuffix` call sites and determine if any need updating for renamed CLI support.
+
+## Next Steps
+
+→ `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-03-30-001-feat-publish-name-collision-plan.md b/docs/plans/2026-03-30-001-feat-publish-name-collision-plan.md
new file mode 100644
index 00000000..14479bcb
--- /dev/null
+++ b/docs/plans/2026-03-30-001-feat-publish-name-collision-plan.md
@@ -0,0 +1,320 @@
+---
+title: "feat: Add name collision detection and resolution to publish workflow"
+type: feat
+status: active
+date: 2026-03-30
+origin: docs/brainstorms/2026-03-30-publish-name-collision-requirements.md
+---
+
+# feat: Add name collision detection and resolution to publish workflow
+
+## Overview
+
+Add collision detection to the publish skill so users are never surprised when a CLI name already exists in the library repo. When a collision is detected, offer three resolution paths: intentionally replace, publish alongside with a different name, or bail out.
+
+## Problem Frame
+
+When a user runs `printing-press-publish`, there is no check for whether a CLI name already exists — merged into main or in another user's open PR. This can result in accidental overwrites or duplicate submissions. Users need to see collisions before they submit and make an explicit choice about how to proceed. (see origin: docs/brainstorms/2026-03-30-publish-name-collision-requirements.md)
+
+## Requirements Trace
+
+- R1. Check managed clone `library/` for existing CLI directory before PR creation
+- R2. Check `gh pr list` on library repo (no `--author @me` filter) for open PRs with matching branch
+- R3. Show collision info: CLI name, source (merged/open PR), author, PR link
+- R4. Offer three paths: Replace, Alongside (rename), Bail
+- R5. Replace path PR description clearly states intent to replace
+- R6. Rename format locked to `<api-slug>-<qualifier>-pp-cli`
+- R7. Suggest 1 numeric + 1 non-numeric fallback + custom qualifier option
+- R8. Verify all suggestions are non-colliding
+- R9. Rename propagates to all name-bearing locations
+- R10. Manifest preserves original `api_name` for renamed CLIs
+- R11. Ownership-aware resolution — stronger confirmation for replacing other users' PRs
+
+## Scope Boundaries
+
+- Per-CLI versioning is out of scope (see origin)
+- Collision detection scoped to publish flow only; `generate` does not check the library repo
+- No semantically meaningful qualifier suggestions — generic fallbacks + custom only
+- `TrimCLISuffix` compatibility for renamed CLIs is out of scope for this plan — noted as technical debt (affects `emboss.go:236`, `runtime.go:97`, and `runtime.go:198` in `findCLICommandDir`; note that `runtime.go:198` is likely safe because it reconstructs the CLI name via `naming.CLI(apiName)` which roundtrips correctly). Additionally, `publish.go:230` and `publish.go:379` use `TrimCLISuffix` as a fallback when the manifest lacks `api_name` — these ARE in the publish path but are guarded by the manifest check, so they are safe as long as the manifest is present
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- **Publish skill:** `skills/printing-press-publish/SKILL.md` — 8-step workflow; collision detection inserts between Steps 6 and 7
+- **Naming module:** `internal/naming/naming.go` — `CLI()`, `TrimCLISuffix()`, `IsCLIDirName()`, `trimNumericRunSuffix()`
+- **Publish commands:** `internal/cli/publish.go` — `publish validate` and `publish package` subcommands
+- **Module path rewriting:** `internal/pipeline/modulepath.go` — `RewriteModulePath()` handles go.mod, import paths, install paths, GitHub URLs. Does NOT handle: Use strings, version template, User-Agent, goreleaser project/binary/brew names, Makefile, README title
+- **CLI manifest:** `internal/pipeline/climanifest.go` — `CLIManifest` struct with `APIName` and `CLIName` fields
+- **Local collision handling:** `internal/pipeline/pipeline.go` — `ClaimOutputDir()` uses `-2` through `-99` suffixes
+- **Module path tests:** `internal/pipeline/modulepath_test.go` — table-driven with explicit "does not corrupt non-import strings" assertions
+
+### Institutional Learnings
+
+- **Path traversal protection:** User-supplied qualifiers in `filepath.Join` need belt-and-suspenders validation: reject traversal chars AND verify resolved path is contained within expected root (from `docs/solutions/security-issues/filepath-join-traversal-with-user-input-2026-03-29.md`)
+- **Validation immutability:** Validation must not mutate the source directory; use snapshot-compare-restore pattern (from `docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md`)
+- **Test collision-suffixed dirs:** Tests should cover both canonical (`notion-pp-cli`) and collision-suffixed (`notion-pp-cli-2`) directories (from `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`)
+
+## Key Technical Decisions
+
+- **Hybrid Go + skill approach:** Collision detection UX lives in the skill (already orchestrates `gh` commands and user interaction). Rename propagation lives in Go code (a new `RenameCLI` function + `publish rename` subcommand) for reliability across 18+ file locations. Rationale: the skill can't reliably do targeted string replacements in Go source files, but it's great at presenting choices and calling CLI commands.
+
+- **Rename operates on the staging copy:** The publish flow packages to a staging directory first (`publish package`), then copies to the managed clone. The rename step operates on the staging copy between packaging and clone-copy, avoiding mutation of the user's library directory. Rationale: follows the immutability principle from institutional learnings.
+
+- **`RenameCLI` is separate from `RewriteModulePath`:** Module path rewriting handles import paths and Go module declarations. CLI name renaming handles user-visible strings (Use, version, User-Agent, goreleaser, Makefile, README). These are distinct operations with different replacement rules. Rationale: `RewriteModulePath` deliberately avoids touching bare CLI name references — the rename function handles exactly what `RewriteModulePath` intentionally skips.
+
+- **Collision detection merges with Step 7:** Instead of a separate step, collision detection replaces the existing Step 7 ("Check for Existing PR"). The current `--author @me` check becomes one branch of the broader collision check. Rationale: avoids duplicate `gh pr list` calls and keeps the decision tree unified.
+
+- **Numeric fallback starts at 2:** Matches `ClaimOutputDir` convention. The existing `trimNumericRunSuffix` already handles this pattern.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Full rename propagation list:** 18 locations identified via research — 7 handled by `RewriteModulePath`, 7 need `RenameCLI`, 4 are metadata/workflow handled by the skill. See Unit 1 approach for details.
+- **Go code vs skill for collision detection:** Hybrid — detection UX in skill, rename propagation in Go. The Go binary provides `publish rename` subcommand; the skill orchestrates the flow.
+- **Numeric fallback convention:** Start at 2, matching `ClaimOutputDir`. `trimNumericRunSuffix` already strips these.
+- **TrimCLISuffix impact:** 2 unsafe call sites (`emboss.go:236`, `runtime.go:97`) are outside the publish flow. Noted as tech debt, not blocking.
+
+### Deferred to Implementation
+
+- **Exact replacement regex for CLI name in goreleaser:** The goreleaser template embeds the CLI name in ~8 positions. Implementation should verify the string replacement doesn't create false positives. The `modulepath_test.go` "does not corrupt" pattern should be followed.
+- **Registry.json schema for renamed CLIs:** The registry entry needs the new `cli_name` but should the `api_name` field also be present? Implementation should check the current schema.
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+```
+User runs printing-press-publish
+ │
+ ├─ Steps 1-6: resolve name, validate, package (runs RewriteModulePath), freshen clone (unchanged)
+ │ Note: RewriteModulePath normalizes import paths and module declarations during packaging.
+ │ RenameCLI operates on a pre-rewritten staging copy and handles only user-visible CLI name references.
+ │
+ ▼
+Step 7 (NEW): Collision Detection & Resolution
+ │
+ ├─ Check managed clone: ls library/*/<cli-name>
+ │ └─ Found? → merged_collision = true, record category path
+ │
+ ├─ Check gh pr list (no --author @me): feat/<cli-name>
+ │ └─ Found? → pr_collision = true, record author + URL
+ │
+ ├─ Check gh pr list (--author @me): feat/<cli-name>
+ │ └─ Found? → own_pr = true, record PR number
+ │
+ ├─ No collision? → proceed to Step 8 normally
+ │
+ └─ Collision detected → present info (R3) → offer paths (R4):
+ │
+ ├─ Replace:
+ │ ├─ Own PR → update existing (current behavior)
+ │ ├─ Other user's PR → strong confirmation (R11)
+ │ └─ Merged only → standard confirmation
+ │ → PR description states "Replaces existing <cli-name>" (R5)
+ │ → proceed to Step 8
+ │
+ ├─ Alongside:
+ │ ├─ Present qualifier options (R7)
+ │ ├─ Verify non-colliding (R8)
+ │ ├─ Call: printing-press publish rename --dir <staging> \
+ │ │ --old-name <old> --new-name <new> --json
+ │ ├─ Use new name for branch, PR, registry
+ │ └─ proceed to Step 8 with new name
+ │
+ └─ Bail: exit with link to existing CLI/PR
+```
+
+## Implementation Units
+
+- [ ] **Unit 1: Add `RenameCLI` function**
+
+**Goal:** Create a function that renames all non-module-path CLI name references in a staged CLI directory, plus renames the filesystem directories.
+
+**Requirements:** R6, R9, R10
+
+**Dependencies:** None
+
+**Files:**
+- Create: `internal/pipeline/renamecli.go`
+- Test: `internal/pipeline/renamecli_test.go`
+
+**Approach:**
+- `RenameCLI(dir, oldCLIName, newCLIName, originalAPIName string) error`
+- **Filesystem renames:**
+ - Rename outer directory from `oldCLIName` to `newCLIName`
+ - Rename `cmd/oldCLIName/` to `cmd/newCLIName/`
+- **File content replacements:** Walk `.go`, `.yaml`, `.yml`, `.md` files, plus files named `Makefile` (no extension — walk filter must check `filepath.Base(path) == "Makefile"` in addition to extension matching; do not reuse `hasRewriteExtension` from modulepath.go without this check). Replace occurrences of `oldCLIName` with `newCLIName`. This covers: `Use:` string, version template, version printf, User-Agent, goreleaser project_name/binary/brew/install, Makefile targets, README title/usage. **Skip the `.manuscripts/` subtree** — these are archival provenance records that should preserve original names
+- **Manifest update:** Read `.printing-press.json`, set `CLIName` to `newCLIName`, preserve `APIName` as `originalAPIName`, write back
+- **Input validation:** Both names must match `naming.IsCLIDirName()`. Apply path traversal protection per institutional learning (reject `/`, `\`, `..` in qualifier; verify resolved path within expected root)
+- **Does NOT call `RewriteModulePath`** — that's already done during packaging. This function handles exactly what `RewriteModulePath` intentionally skips
+
+**Patterns to follow:**
+- `RewriteModulePath` in `internal/pipeline/modulepath.go` — same walk/replace pattern but different target strings
+- `modulepath_test.go` — table-driven with explicit "does not corrupt" assertions
+- Path traversal protection from `internal/cli/publish.go` category validation
+
+**Test scenarios:**
+- Happy path: Rename `notion-pp-cli` to `notion-alt-pp-cli` — verify all 7 non-module name references updated, both directories renamed, manifest has new cli_name + original api_name
+- Happy path: Rename `notion-pp-cli` to `notion-2-pp-cli` (numeric qualifier) — same verifications
+- Edge case: CLI name appears as substring in unrelated strings (e.g., comment "see notion-pp-cli docs") — should still be replaced (it's a CLI name reference)
+- Edge case: API name without suffix (e.g., `notion` in a comment) should NOT be replaced — only full CLI name matches
+- Edge case: `cmd/` directory doesn't exist (some CLIs may have different structures) — graceful handling
+- Error path: Path traversal in new name (`../evil-pp-cli`) — rejected with error
+- Error path: Invalid CLI name format (missing `-pp-cli` suffix) — rejected with error
+- Error path: Old name not found in directory — clear error message
+- Integration: After rename, verify `go build ./cmd/<new-name>` would find the right entrypoint (directory exists with correct name)
+
+**Verification:**
+- All tests pass
+- `go vet ./internal/pipeline/...` clean
+
+---
+
+- [ ] **Unit 2: Add `publish rename` subcommand**
+
+**Goal:** Expose `RenameCLI` as a CLI subcommand the skill can call.
+
+**Requirements:** R9
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/cli/publish.go`
+- Test: `internal/cli/publish_test.go`
+
+**Approach:**
+- New subcommand: `printing-press publish rename --dir <path> --old-name <old> --new-name <new> [--api-name <original>] --json`
+- `--api-name` defaults to `naming.TrimCLISuffix(oldName)` if not provided — ensures manifest gets the right canonical API name even without explicit flag
+- JSON output: `{ "success": true, "old_name": "...", "new_name": "...", "new_dir": "<path>", "files_modified": N }` on success, or `{ "success": false, "error": "..." }` on failure. The `new_dir` field returns the renamed directory path so the skill doesn't need to reconstruct it (matches the `staged_dir` pattern from `PackageResult`)
+- Follow existing subcommand patterns: `newPublishValidateCmd()`, `newPublishPackageCmd()` style
+
+**Patterns to follow:**
+- `newPublishValidateCmd()` and `newPublishPackageCmd()` in `internal/cli/publish.go` for command structure and JSON output pattern
+- Existing flag patterns: `--dir`, `--json`
+
+**Test scenarios:**
+- Happy path: Call `publish rename` with valid args — verify JSON output reports success and correct names
+- Error path: Missing required flags — clear error message
+- Error path: Invalid CLI name in `--new-name` — error before any filesystem changes
+- Edge case: `--api-name` flag omitted — falls back to `TrimCLISuffix(oldName)` correctly
+
+**Verification:**
+- `printing-press publish rename --help` shows the expected flags
+- JSON output is parseable by the skill
+
+---
+
+- [ ] **Unit 3: Add collision detection to publish skill**
+
+**Goal:** Detect name collisions after the managed clone is freshened (between current Steps 6 and 7) and display collision information to the user.
+
+**Requirements:** R1, R2, R3, R11
+
+**Dependencies:** None (skill-only change, independent of Go code)
+
+**Files:**
+- Modify: `skills/printing-press-publish/SKILL.md`
+
+**Approach:**
+- Replace current Step 7 ("Check for Existing PR") with a merged collision detection + resolution step
+- **Detection sequence:**
+ 1. Check managed clone: `ls "$PUBLISH_REPO_DIR/library"/*/"<cli-name>" 2>/dev/null` — if found, record as merged collision
+ 2. Check all open PRs: `gh pr list --repo <lib-repo> --head "feat/<cli-name>" --state open --json number,title,url,author` — if found, record as PR collision
+ 3. Check own PRs: filter the above result by `--author @me` to distinguish ownership
+- **Display (R3):** Show CLI name, collision source (merged/open PR/both), author name for PR collisions, and PR URL
+- **Ownership distinction (R11):** Tag each collision as "yours" or "other user's" based on the `--author @me` filter result
+
+**Patterns to follow:**
+- Current Step 7 in SKILL.md for `gh pr list` command structure
+- Existing skill error handling patterns (check exit codes, fallback behavior)
+
+**Test scenarios:**
+- Not applicable (skill markdown, not executable code). Verification is via manual testing during a publish run.
+
+**Verification:**
+- The skill instructions are clear enough that a Claude Code agent can execute them correctly
+- The collision info display covers all combinations: merged only, PR only (own), PR only (other user), merged + PR
+
+---
+
+- [ ] **Unit 4: Add resolution paths to publish skill**
+
+**Goal:** When a collision is detected, present three resolution paths and execute the chosen path.
+
+**Requirements:** R4, R5, R6, R7, R8, R10, R11
+
+**Dependencies:** Units 1-2 (for Alongside path), Unit 3 (collision detection must exist)
+
+**Files:**
+- Modify: `skills/printing-press-publish/SKILL.md`
+
+**Approach:**
+
+**Three-path choice (R4):**
+- If user's own PR exists: default to "Update your existing PR" (preserves current behavior), also offer Alongside and Bail
+- If other user's PR only: offer Replace (with strong confirmation), Alongside, Bail
+- If merged only: offer Replace, Alongside, Bail
+
+**Replace path (R5, R11):**
+- PR description template includes: "⚠️ Replaces existing `<cli-name>` — [reason: newer spec / improved coverage / etc.]"
+- For other user's CLIs: require explicit confirmation naming the other author ("This will replace <author>'s `<cli-name>`. Are you sure?")
+- Proceed to Step 8 with normal flow (the existing `rm -rf "$PUBLISH_REPO_DIR/library"/*/"<cli-name>"` already handles the overwrite)
+
+**Alongside path (R6, R7, R8, R10):**
+- Extract `<api-slug>` from manifest `api_name` field
+- Generate suggestions:
+ - Numeric: `<api-slug>-2-pp-cli` (scan upward if 2 collides)
+ - Non-numeric: `<api-slug>-alt-pp-cli`
+ - Custom: prompt for qualifier word
+- Verify each suggestion against merged CLIs (`ls library/*/`) and open PRs (`gh pr list --head "feat/<suggestion>"`)
+- Call: `printing-press publish rename --dir <staging-dir>/<category>/<old-cli-name> --old-name <old> --new-name <new> --api-name <api-slug> --json`
+- Update all downstream references in the skill: branch name → `feat/<new-name>`, PR title, registry.json entry, copy commands
+
+**Bail path:**
+- Show link to existing PR if applicable
+- Show path to existing CLI in managed clone if merged
+- Exit publish flow
+
+**Patterns to follow:**
+- Current Step 7/8 flow for PR creation commands
+- Existing skill choice patterns (numbered options presented to user)
+
+**Test scenarios:**
+- Not applicable (skill markdown). Verification via manual testing.
+
+**Verification:**
+- The skill instructions cover all collision scenarios: merged only, own PR, other user's PR, merged + PR
+- The Alongside flow correctly calls `publish rename` with the right flags
+- The Replace flow produces a PR description that reviewers can identify as intentional
+- All downstream references (branch, PR title, registry) use the new name after Alongside
+
+## System-Wide Impact
+
+- **Interaction graph:** The publish skill calls `publish rename` as a new binary invocation. No callbacks, middleware, or observers affected. The existing `publish validate` and `publish package` commands are unchanged.
+- **Error propagation:** If `publish rename` fails, the skill should show the error and offer to retry with a different qualifier or bail. The staging directory may be in a partial state — the skill should re-run `publish package` to get a fresh staging copy.
+- **State lifecycle risks:** Rename operates on the staging copy, not the user's library. If the publish fails after rename, the staging copy is disposable. No persistent state is corrupted.
+- **API surface parity:** The `publish rename` subcommand is a new CLI surface. It follows existing `publish validate`/`publish package` conventions.
+- **Unchanged invariants:** `printing-press generate`, `printing-press library`, `printing-press emboss` are all unaffected. The `RewriteModulePath` function is not modified. The `naming` module functions are not modified.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| String replacement in `RenameCLI` could false-positive on substrings | The old CLI name is highly specific (e.g., `notion-pp-cli`) — false positives on arbitrary text are unlikely. Test with explicit "does not corrupt" assertions following `modulepath_test.go` pattern. |
+| `gh pr list` without `--author @me` may hit rate limits with many PRs | The library repo is small-to-medium. One additional API call per publish is negligible. |
+| User-supplied qualifier could be empty or invalid | Go code validates: must be non-empty, kebab-case, no path traversal chars. Skill also validates before calling. |
+| Managed clone could be stale if `git fetch` fails | Existing skill behavior: if clone management fails, the skill falls back gracefully. Collision detection should follow the same pattern — if the clone isn't fresh, warn but don't block. |
+
+## Documentation / Operational Notes
+
+- The `publish rename` subcommand will appear in `printing-press publish --help`
+- No AGENTS.md changes needed — the skill is the user-facing interface
+- TrimCLISuffix technical debt (`emboss.go:236`, `runtime.go:97`) should be tracked for a future cleanup when renamed CLIs start appearing in local libraries
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-03-30-publish-name-collision-requirements.md](docs/brainstorms/2026-03-30-publish-name-collision-requirements.md)
+- Related code: `internal/pipeline/modulepath.go`, `internal/naming/naming.go`, `internal/cli/publish.go`
+- Related learnings: `docs/solutions/security-issues/filepath-join-traversal-with-user-input-2026-03-29.md`, `docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md`
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 6dee784f..1274a6f1 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -35,6 +35,7 @@ func newPublishCmd() *cobra.Command {
cmd.AddCommand(newPublishValidateCmd())
cmd.AddCommand(newPublishPackageCmd())
+ cmd.AddCommand(newPublishRenameCmd())
return cmd
}
@@ -67,6 +68,86 @@ type PackageResult struct {
RunID string `json:"run_id,omitempty"`
}
+// RenameResult is the JSON output of publish rename.
+type RenameResult struct {
+ Success bool `json:"success"`
+ OldName string `json:"old_name"`
+ NewName string `json:"new_name"`
+ NewDir string `json:"new_dir"`
+ FilesModified int `json:"files_modified"`
+ Error string `json:"error,omitempty"`
+}
+
+func newPublishRenameCmd() *cobra.Command {
+ var dir string
+ var oldName string
+ var newName string
+ var apiName string
+ var asJSON bool
+
+ cmd := &cobra.Command{
+ Use: "rename",
+ Short: "Rename a staged CLI (for name collision resolution)",
+ Example: ` printing-press publish rename --dir /tmp/staging/library/ai/notion-pp-cli --old-name notion-pp-cli --new-name notion-alt-pp-cli --json
+ printing-press publish rename --dir /tmp/staging/library/ai/notion-pp-cli --old-name notion-pp-cli --new-name notion-2-pp-cli --api-name notion --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dir == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
+ }
+ if oldName == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--old-name is required")}
+ }
+ if newName == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--new-name is required")}
+ }
+ if apiName == "" {
+ apiName = naming.TrimCLISuffix(oldName)
+ }
+
+ filesModified, err := pipeline.RenameCLI(dir, oldName, newName, apiName)
+
+ if asJSON {
+ result := RenameResult{
+ OldName: oldName,
+ NewName: newName,
+ FilesModified: filesModified,
+ }
+ if err != nil {
+ result.Error = err.Error()
+ } else {
+ result.Success = true
+ result.NewDir = filepath.Join(filepath.Dir(dir), newName)
+ }
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ if encErr := enc.Encode(result); encErr != nil {
+ return encErr
+ }
+ if err != nil {
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("rename failed"), Silent: true}
+ }
+ return nil
+ }
+
+ if err != nil {
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("rename failed: %w", err)}
+ }
+ newDir := filepath.Join(filepath.Dir(dir), newName)
+ fmt.Fprintf(os.Stderr, "Renamed %s → %s (%d files modified)\n", oldName, newName, filesModified)
+ fmt.Fprintf(os.Stderr, " New directory: %s\n", newDir)
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dir, "dir", "", "Staged CLI directory to rename (required)")
+ cmd.Flags().StringVar(&oldName, "old-name", "", "Current CLI name (required)")
+ cmd.Flags().StringVar(&newName, "new-name", "", "New CLI name (required)")
+ cmd.Flags().StringVar(&apiName, "api-name", "", "Original API name (defaults to TrimCLISuffix of old-name)")
+ cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+
+ return cmd
+}
+
func newPublishValidateCmd() *cobra.Command {
var dir string
var asJSON bool
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index d140aebf..f843bc1f 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -530,6 +530,112 @@ func TestPublishPackageDestNonexistent(t *testing.T) {
assert.Contains(t, err.Error(), "does not exist")
}
+func TestPublishRenameMissingFlags(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ wantErr string
+ }{
+ {"missing dir", []string{"rename", "--old-name", "a-pp-cli", "--new-name", "b-pp-cli", "--json"}, "--dir is required"},
+ {"missing old-name", []string{"rename", "--dir", "/tmp/x", "--new-name", "b-pp-cli", "--json"}, "--old-name is required"},
+ {"missing new-name", []string{"rename", "--dir", "/tmp/x", "--old-name", "a-pp-cli", "--json"}, "--new-name is required"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := newPublishCmd()
+ cmd.SetArgs(tt.args)
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
+func TestPublishRenameJSONSuccess(t *testing.T) {
+ root := t.TempDir()
+ oldName := "test-pp-cli"
+ newName := "test-alt-pp-cli"
+ cliDir := filepath.Join(root, oldName)
+ require.NoError(t, os.MkdirAll(filepath.Join(cliDir, "cmd", oldName), 0o755))
+
+ require.NoError(t, os.WriteFile(filepath.Join(cliDir, "cmd", oldName, "main.go"), []byte(`package main
+func main() {}
+`), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(cliDir, "README.md"), []byte("# "+oldName+"\n"), 0o644))
+
+ writeTestManifest(t, cliDir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "test",
+ CLIName: oldName,
+ })
+
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"rename", "--dir", cliDir, "--old-name", oldName, "--new-name", newName, "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var result RenameResult
+ require.NoError(t, json.Unmarshal([]byte(output), &result))
+ assert.True(t, result.Success)
+ assert.Equal(t, oldName, result.OldName)
+ assert.Equal(t, newName, result.NewName)
+ assert.Equal(t, filepath.Join(root, newName), result.NewDir)
+ assert.Greater(t, result.FilesModified, 0)
+}
+
+func TestPublishRenameAPINameFallback(t *testing.T) {
+ root := t.TempDir()
+ oldName := "test-pp-cli"
+ newName := "test-alt-pp-cli"
+ cliDir := filepath.Join(root, oldName)
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ writeTestManifest(t, cliDir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "test",
+ CLIName: oldName,
+ })
+
+ cmd := newPublishCmd()
+ // No --api-name flag — should fall back to TrimCLISuffix("test-pp-cli") = "test"
+ cmd.SetArgs([]string{"rename", "--dir", cliDir, "--old-name", oldName, "--new-name", newName, "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var result RenameResult
+ require.NoError(t, json.Unmarshal([]byte(output), &result))
+ assert.True(t, result.Success)
+
+ // Verify manifest has correct api_name from fallback
+ newDir := filepath.Join(root, newName)
+ mData, err := os.ReadFile(filepath.Join(newDir, pipeline.CLIManifestFilename))
+ require.NoError(t, err)
+ var m pipeline.CLIManifest
+ require.NoError(t, json.Unmarshal(mData, &m))
+ assert.Equal(t, "test", m.APIName, "api_name should come from TrimCLISuffix fallback")
+ assert.Equal(t, newName, m.CLIName)
+}
+
+func TestPublishRenameJSONError(t *testing.T) {
+ root := t.TempDir()
+ cliDir := filepath.Join(root, "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ cmd := newPublishCmd()
+ // Invalid new name — will fail validation
+ cmd.SetArgs([]string{"rename", "--dir", cliDir, "--old-name", "test-pp-cli", "--new-name", "bad-name", "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.Error(t, err)
+
+ var result RenameResult
+ require.NoError(t, json.Unmarshal([]byte(output), &result))
+ assert.False(t, result.Success)
+ assert.NotEmpty(t, result.Error)
+}
+
func writePublishableTestCLI(t *testing.T, dir string) {
t.Helper()
diff --git a/internal/pipeline/renamecli.go b/internal/pipeline/renamecli.go
new file mode 100644
index 00000000..6e152e22
--- /dev/null
+++ b/internal/pipeline/renamecli.go
@@ -0,0 +1,158 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/mvanhorn/cli-printing-press/internal/naming"
+)
+
+// renameExtensions lists file extensions walked during CLI rename.
+// Makefile is handled separately by base-name check in shouldRenameFile.
+var renameExtensions = []string{".go", ".yaml", ".yml", ".md"}
+
+// RenameCLI renames all user-visible CLI name references in a staged CLI
+// directory. It handles:
+// - Filesystem: outer directory rename (oldCLIName → newCLIName) and
+// cmd/oldCLIName/ → cmd/newCLIName/
+// - File content: replaces occurrences of oldCLIName with newCLIName in
+// .go, .yaml, .yml, .md files and Makefiles (skips .manuscripts/)
+// - Manifest: updates cli_name to newCLIName, preserves api_name as
+// originalAPIName
+//
+// This function does NOT call RewriteModulePath — that handles import
+// paths and is run separately during packaging. RenameCLI handles exactly
+// the user-visible references that RewriteModulePath intentionally skips.
+func RenameCLI(dir, oldCLIName, newCLIName, originalAPIName string) (int, error) {
+ if err := validateRenameInputs(oldCLIName, newCLIName); err != nil {
+ return 0, err
+ }
+
+ // Path traversal protection: verify the directory and new name resolve
+ // within the expected parent.
+ absDir, err := filepath.Abs(dir)
+ if err != nil {
+ return 0, fmt.Errorf("resolving directory: %w", err)
+ }
+ parent := filepath.Dir(absDir)
+ newDir := filepath.Join(parent, newCLIName)
+ absNew, err := filepath.Abs(newDir)
+ if err != nil {
+ return 0, fmt.Errorf("resolving new directory: %w", err)
+ }
+ if !strings.HasPrefix(absNew, parent+string(filepath.Separator)) {
+ return 0, fmt.Errorf("new CLI name resolves outside parent directory: %s", absNew)
+ }
+
+ // Verify old directory exists and base matches old name.
+ if filepath.Base(absDir) != oldCLIName {
+ return 0, fmt.Errorf("directory base %q does not match old CLI name %q", filepath.Base(absDir), oldCLIName)
+ }
+
+ filesModified := 0
+
+ // 1. Replace file contents (walk before directory renames so paths are stable).
+ err = filepath.WalkDir(absDir, func(path string, d os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if d.IsDir() {
+ // Skip .manuscripts subtree — archival provenance records
+ // should preserve original names.
+ if d.Name() == ".manuscripts" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ if !shouldRenameFile(path) {
+ return nil
+ }
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("reading %s: %w", path, err)
+ }
+
+ result := strings.ReplaceAll(string(content), oldCLIName, newCLIName)
+ if result == string(content) {
+ return nil
+ }
+
+ if err := os.WriteFile(path, []byte(result), 0o644); err != nil {
+ return fmt.Errorf("writing %s: %w", path, err)
+ }
+ filesModified++
+ return nil
+ })
+ if err != nil {
+ return filesModified, fmt.Errorf("walking directory: %w", err)
+ }
+
+ // 2. Update manifest: set cli_name, preserve api_name.
+ manifestPath := filepath.Join(absDir, CLIManifestFilename)
+ if manifestData, readErr := os.ReadFile(manifestPath); readErr == nil {
+ var m CLIManifest
+ if jsonErr := json.Unmarshal(manifestData, &m); jsonErr == nil {
+ m.CLIName = newCLIName
+ m.APIName = originalAPIName
+ if writeErr := WriteCLIManifest(absDir, m); writeErr != nil {
+ return filesModified, fmt.Errorf("updating manifest: %w", writeErr)
+ }
+ filesModified++
+ }
+ }
+
+ // 3. Rename cmd/ subdirectory if it exists.
+ oldCmdDir := filepath.Join(absDir, "cmd", oldCLIName)
+ newCmdDir := filepath.Join(absDir, "cmd", newCLIName)
+ if _, err := os.Stat(oldCmdDir); err == nil {
+ if err := os.Rename(oldCmdDir, newCmdDir); err != nil {
+ return filesModified, fmt.Errorf("renaming cmd directory: %w", err)
+ }
+ }
+
+ // 4. Rename outer directory last (changes the path for the caller).
+ if err := os.Rename(absDir, absNew); err != nil {
+ return filesModified, fmt.Errorf("renaming CLI directory: %w", err)
+ }
+
+ return filesModified, nil
+}
+
+// shouldRenameFile returns true if a file should be processed during rename.
+// Checks extension (.go, .yaml, .yml, .md) and base name (Makefile).
+func shouldRenameFile(path string) bool {
+ base := filepath.Base(path)
+ if base == "Makefile" {
+ return true
+ }
+ for _, ext := range renameExtensions {
+ if strings.HasSuffix(path, ext) {
+ return true
+ }
+ }
+ return false
+}
+
+// validateRenameInputs checks that both CLI names are valid and safe.
+func validateRenameInputs(oldName, newName string) error {
+ if oldName == newName {
+ return fmt.Errorf("old and new CLI names are identical: %q", oldName)
+ }
+
+ for _, name := range []string{oldName, newName} {
+ if !naming.IsCLIDirName(name) {
+ return fmt.Errorf("invalid CLI name (must end with %s): %q", naming.CurrentCLISuffix, name)
+ }
+ // Path traversal protection: reject dangerous characters.
+ if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
+ return fmt.Errorf("CLI name contains path traversal characters: %q", name)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/pipeline/renamecli_test.go b/internal/pipeline/renamecli_test.go
new file mode 100644
index 00000000..17adf3d9
--- /dev/null
+++ b/internal/pipeline/renamecli_test.go
@@ -0,0 +1,341 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// writeTestCLITree creates a minimal CLI directory tree for rename testing.
+// It includes files that should be renamed and files that should survive unchanged.
+func writeTestCLITree(t *testing.T, dir string, cliName, apiName string) {
+ t.Helper()
+
+ // cmd/<cli-name>/main.go
+ cmdDir := filepath.Join(dir, "cmd", cliName)
+ require.NoError(t, os.MkdirAll(cmdDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(cmdDir, "main.go"), []byte(`package main
+
+import (
+ "`+cliName+`/internal/cli"
+)
+
+func main() {
+ cli.Execute()
+}
+`), 0o644))
+
+ // internal/cli/root.go — contains both import paths and runtime literals
+ cliDir := filepath.Join(dir, "internal", "cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(cliDir, "root.go"), []byte(`package cli
+
+import (
+ "`+cliName+`/internal/client"
+)
+
+var version = "0.1.0"
+
+func Execute() {
+ rootCmd := &cobra.Command{
+ Use: "`+cliName+`",
+ Short: "CLI for `+apiName+` API",
+ }
+ rootCmd.SetVersionTemplate("`+cliName+` {{ .Version }}\n")
+}
+`), 0o644))
+
+ // internal/client/client.go — User-Agent
+ clientDir := filepath.Join(dir, "internal", "client")
+ require.NoError(t, os.MkdirAll(clientDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(clientDir, "client.go"), []byte(`package client
+
+func (c *Client) do() {
+ req.Header.Set("User-Agent", "`+cliName+`/0.1.0")
+}
+`), 0o644))
+
+ // .goreleaser.yaml
+ require.NoError(t, os.WriteFile(filepath.Join(dir, ".goreleaser.yaml"), []byte(`version: 2
+project_name: `+cliName+`
+builds:
+ - binary: `+cliName+`
+ ldflags:
+ - -s -w -X `+cliName+`/internal/cli.version={{ .Version }}
+brews:
+ - name: `+cliName+`
+ install: bin.install "`+cliName+`"
+`), 0o644))
+
+ // Makefile
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "Makefile"), []byte(`build:
+ go build -o `+cliName+` ./cmd/`+cliName+`
+`), 0o644))
+
+ // README.md
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte(`# `+cliName+`
+
+CLI for the `+apiName+` API.
+
+## Usage
+
+`+"```"+`
+`+cliName+` doctor
+`+cliName+` users list
+`+"```"+`
+`), 0o644))
+
+ // go.mod (module path uses bare CLI name, as generated CLIs do)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(`module `+cliName+`
+
+go 1.24
+`), 0o644))
+
+ // .manuscripts/ — should NOT be modified
+ msDir := filepath.Join(dir, ".manuscripts", "20260329-100000", "research")
+ require.NoError(t, os.MkdirAll(msDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(msDir, "brief.md"),
+ []byte("# Research Brief for "+cliName+"\n\nGenerated from "+cliName+" spec.\n"), 0o644))
+
+ // .printing-press.json manifest
+ m := CLIManifest{
+ SchemaVersion: 1,
+ APIName: apiName,
+ CLIName: cliName,
+ }
+ data, _ := json.MarshalIndent(m, "", " ")
+ require.NoError(t, os.WriteFile(filepath.Join(dir, CLIManifestFilename), data, 0o644))
+}
+
+func TestRenameCLI(t *testing.T) {
+ t.Parallel()
+
+ t.Run("happy path renames all references", func(t *testing.T) {
+ root := t.TempDir()
+ oldName := "notion-pp-cli"
+ newName := "notion-alt-pp-cli"
+ apiName := "notion"
+
+ cliDir := filepath.Join(root, oldName)
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+ writeTestCLITree(t, cliDir, oldName, apiName)
+
+ filesModified, err := RenameCLI(cliDir, oldName, newName, apiName)
+ require.NoError(t, err)
+ assert.Greater(t, filesModified, 0, "should modify at least one file")
+
+ // Outer directory should be renamed
+ newDir := filepath.Join(root, newName)
+ _, err = os.Stat(newDir)
+ assert.NoError(t, err, "new directory should exist")
+ _, err = os.Stat(cliDir)
+ assert.ErrorIs(t, err, os.ErrNotExist, "old directory should not exist")
+
+ // cmd/ directory should be renamed
+ _, err = os.Stat(filepath.Join(newDir, "cmd", newName))
+ assert.NoError(t, err, "new cmd directory should exist")
+ _, err = os.Stat(filepath.Join(newDir, "cmd", oldName))
+ assert.ErrorIs(t, err, os.ErrNotExist, "old cmd directory should not exist")
+
+ // root.go should have new name in Use and version template
+ rootGo, err := os.ReadFile(filepath.Join(newDir, "internal", "cli", "root.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(rootGo), `Use: "`+newName+`"`)
+ assert.Contains(t, string(rootGo), newName+` {{ .Version }}`)
+ assert.NotContains(t, string(rootGo), oldName)
+
+ // client.go should have new User-Agent
+ clientGo, err := os.ReadFile(filepath.Join(newDir, "internal", "client", "client.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(clientGo), newName+`/0.1.0`)
+ assert.NotContains(t, string(clientGo), oldName)
+
+ // .goreleaser.yaml should have new project_name, binary, brew
+ goreleaser, err := os.ReadFile(filepath.Join(newDir, ".goreleaser.yaml"))
+ require.NoError(t, err)
+ grContent := string(goreleaser)
+ assert.Contains(t, grContent, "project_name: "+newName)
+ assert.Contains(t, grContent, "binary: "+newName)
+ assert.Contains(t, grContent, `install "`+newName+`"`)
+ assert.NotContains(t, grContent, oldName)
+
+ // Makefile should have new name
+ makefile, err := os.ReadFile(filepath.Join(newDir, "Makefile"))
+ require.NoError(t, err)
+ assert.Contains(t, string(makefile), newName)
+ assert.NotContains(t, string(makefile), oldName)
+
+ // README should have new name
+ readme, err := os.ReadFile(filepath.Join(newDir, "README.md"))
+ require.NoError(t, err)
+ assert.Contains(t, string(readme), "# "+newName)
+ assert.NotContains(t, string(readme), oldName)
+
+ // Manifest should have new cli_name, original api_name
+ mData, err := os.ReadFile(filepath.Join(newDir, CLIManifestFilename))
+ require.NoError(t, err)
+ var m CLIManifest
+ require.NoError(t, json.Unmarshal(mData, &m))
+ assert.Equal(t, newName, m.CLIName)
+ assert.Equal(t, apiName, m.APIName)
+ })
+
+ t.Run("numeric qualifier renames correctly", func(t *testing.T) {
+ root := t.TempDir()
+ oldName := "notion-pp-cli"
+ newName := "notion-2-pp-cli"
+ apiName := "notion"
+
+ cliDir := filepath.Join(root, oldName)
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+ writeTestCLITree(t, cliDir, oldName, apiName)
+
+ filesModified, err := RenameCLI(cliDir, oldName, newName, apiName)
+ require.NoError(t, err)
+ assert.Greater(t, filesModified, 0)
+
+ newDir := filepath.Join(root, newName)
+ rootGo, err := os.ReadFile(filepath.Join(newDir, "internal", "cli", "root.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(rootGo), `Use: "`+newName+`"`)
+ assert.NotContains(t, string(rootGo), oldName)
+ })
+
+ t.Run("does not modify manuscripts", func(t *testing.T) {
+ root := t.TempDir()
+ oldName := "notion-pp-cli"
+ newName := "notion-alt-pp-cli"
+ apiName := "notion"
+
+ cliDir := filepath.Join(root, oldName)
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+ writeTestCLITree(t, cliDir, oldName, apiName)
+
+ _, err := RenameCLI(cliDir, oldName, newName, apiName)
+ require.NoError(t, err)
+
+ newDir := filepath.Join(root, newName)
+ briefPath := filepath.Join(newDir, ".manuscripts", "20260329-100000", "research", "brief.md")
+ brief, err := os.ReadFile(briefPath)
+ require.NoError(t, err)
+ // Manuscripts should still reference the OLD name
+ assert.Contains(t, string(brief), oldName, "manuscripts should preserve original CLI name")
+ assert.NotContains(t, string(brief), newName, "manuscripts should not contain new CLI name")
+ })
+
+ t.Run("does not replace bare API name", func(t *testing.T) {
+ root := t.TempDir()
+ oldName := "notion-pp-cli"
+ newName := "notion-alt-pp-cli"
+ apiName := "notion"
+
+ cliDir := filepath.Join(root, oldName)
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+ writeTestCLITree(t, cliDir, oldName, apiName)
+
+ _, err := RenameCLI(cliDir, oldName, newName, apiName)
+ require.NoError(t, err)
+
+ newDir := filepath.Join(root, newName)
+ // root.go has "CLI for notion API" — the bare "notion" should survive
+ rootGo, err := os.ReadFile(filepath.Join(newDir, "internal", "cli", "root.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(rootGo), apiName+" API", "bare API name should not be replaced")
+ })
+
+ t.Run("gracefully handles missing cmd directory", func(t *testing.T) {
+ root := t.TempDir()
+ oldName := "simple-pp-cli"
+ newName := "simple-alt-pp-cli"
+ apiName := "simple"
+
+ cliDir := filepath.Join(root, oldName)
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ // Create a minimal tree without cmd/
+ require.NoError(t, os.WriteFile(filepath.Join(cliDir, "main.go"), []byte(`package main
+func main() {}
+`), 0o644))
+
+ m := CLIManifest{SchemaVersion: 1, APIName: apiName, CLIName: oldName}
+ data, _ := json.MarshalIndent(m, "", " ")
+ require.NoError(t, os.WriteFile(filepath.Join(cliDir, CLIManifestFilename), data, 0o644))
+
+ _, err := RenameCLI(cliDir, oldName, newName, apiName)
+ require.NoError(t, err)
+
+ newDir := filepath.Join(root, newName)
+ _, err = os.Stat(newDir)
+ assert.NoError(t, err, "directory should still be renamed")
+ })
+
+ t.Run("rejects path traversal in new name", func(t *testing.T) {
+ root := t.TempDir()
+ cliDir := filepath.Join(root, "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ _, err := RenameCLI(cliDir, "test-pp-cli", "../evil-pp-cli", "test")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "path traversal")
+ })
+
+ t.Run("rejects invalid CLI name format", func(t *testing.T) {
+ root := t.TempDir()
+ cliDir := filepath.Join(root, "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ _, err := RenameCLI(cliDir, "test-pp-cli", "not-a-valid-name", "test")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid CLI name")
+ })
+
+ t.Run("rejects identical names", func(t *testing.T) {
+ root := t.TempDir()
+ cliDir := filepath.Join(root, "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ _, err := RenameCLI(cliDir, "test-pp-cli", "test-pp-cli", "test")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "identical")
+ })
+
+ t.Run("rejects directory base mismatch", func(t *testing.T) {
+ root := t.TempDir()
+ cliDir := filepath.Join(root, "other-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ _, err := RenameCLI(cliDir, "test-pp-cli", "test-alt-pp-cli", "test")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "does not match")
+ })
+
+ t.Run("skips non-target file extensions", func(t *testing.T) {
+ root := t.TempDir()
+ oldName := "test-pp-cli"
+ newName := "test-alt-pp-cli"
+
+ cliDir := filepath.Join(root, oldName)
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ // A .json file that isn't the manifest should NOT be touched
+ otherJSON := filepath.Join(cliDir, "config.json")
+ require.NoError(t, os.WriteFile(otherJSON, []byte(`{"name": "`+oldName+`"}`), 0o644))
+
+ m := CLIManifest{SchemaVersion: 1, APIName: "test", CLIName: oldName}
+ data, _ := json.MarshalIndent(m, "", " ")
+ require.NoError(t, os.WriteFile(filepath.Join(cliDir, CLIManifestFilename), data, 0o644))
+
+ _, err := RenameCLI(cliDir, oldName, newName, "test")
+ require.NoError(t, err)
+
+ newDir := filepath.Join(root, newName)
+ // config.json should still contain the old name (not walked for replacement)
+ configData, err := os.ReadFile(filepath.Join(newDir, "config.json"))
+ require.NoError(t, err)
+ assert.Contains(t, string(configData), oldName, "non-target files should not be modified")
+ })
+}
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index ad591d68..9974e422 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -330,9 +330,33 @@ This removes any existing version of the CLI (handling category changes), copies
Parse the JSON result. Note the `staged_dir`, `module_path`, `manuscripts_included`, and `run_id`. The `module_path` field confirms the Go module path that was set in the packaged CLI's `go.mod` and import paths.
-## Step 7: Check for Existing PR
+## Step 7: Collision Detection & Resolution
-Before creating a branch, check whether you have an open PR for this CLI. The `--author @me` filter ensures we only match PRs owned by the current user — if someone else published the same CLI name, we won't stomp their PR.
+After the managed clone is freshened, check for name collisions before creating a branch or PR. This replaces the previous "Check for Existing PR" step.
+
+### Detection
+
+Run these checks in sequence:
+
+**1. Check merged CLIs in managed clone:**
+
+```bash
+ls "$PUBLISH_REPO_DIR/library"/*/"<cli-name>" 2>/dev/null
+```
+
+If found, record `MERGED_COLLISION=true` and note the category path.
+
+**2. Check all open PRs (any author):**
+
+```bash
+gh pr list --repo mvanhorn/printing-press-library --head "feat/<cli-name>" --state open --json number,title,url,author
+```
+
+If the list is non-empty, record `PR_COLLISION=true`. For each PR, note the PR number, URL, and author login.
+
+**3. Identify own PRs:**
+
+Filter the PR list from step 2 by `--author @me`:
For fork-based PRs, the head includes the username prefix:
@@ -349,9 +373,7 @@ fi
gh pr list --repo mvanhorn/printing-press-library --head "$HEAD_REF" --state open --author @me --json number,title,url
```
-Parse the result:
-- If the list is non-empty, store `EXISTING_PR_NUMBER` and `EXISTING_PR_URL` from the first entry
-- If the list is empty or the command fails (network, auth), set `EXISTING_PR_NUMBER=""` — proceed as if no PR exists
+If found, record `OWN_PR=true`, store `EXISTING_PR_NUMBER` and `EXISTING_PR_URL`.
**If no open PR was found**, also check for a previously merged PR on the same branch:
@@ -361,13 +383,122 @@ MERGED_PR=$(gh pr list --repo mvanhorn/printing-press-library --head "$HEAD_REF"
If `MERGED_PR` is non-empty, the branch name was already used and merged. Set `BRANCH_MERGED=true` so Step 8 creates a new branch name (e.g., `feat/<cli-name>-YYYYMMDD`) instead of reusing the merged branch. Do NOT force-push onto a merged branch — `gh pr edit` would silently update a closed PR nobody is watching.
-If an existing open PR was found, inform the user:
+### No collision
+
+If no merged CLI exists and no open PRs match (other than your own), set `EXISTING_PR_NUMBER` from the own-PR check (or empty if none) and proceed to Step 8 normally.
+
+If an existing open PR of yours was found, inform the user:
> "Found your open PR #N for `<cli-name>`. Will update it with the new version."
-This determines the flow in Step 8:
-- **Existing open PR:** Overwrite the branch automatically, force-push, update the PR description
-- **No open PR, branch not merged:** Standard flow — ask about branch conflicts if any, create a new PR
-- **No open PR, branch was merged (`BRANCH_MERGED=true`):** Auto-create a timestamped branch `feat/<cli-name>-YYYYMMDD` and create a new PR
+### Collision detected — display info
+
+Show the user what was found:
+
+```
+⚠️ Name collision detected for <cli-name>
+
+ Merged: <category>/<cli-name> exists in the library
+ Open PR: #<number> by <author> — <url>
+```
+
+Show all applicable lines. If `OWN_PR=true`, tag the PR as "(yours)".
+
+### Resolution paths
+
+Present three options via AskUserQuestion:
+
+**If `OWN_PR=true` (your own open PR exists):**
+- **Update** — Update your existing PR with the new version (default, preserves current behavior)
+- **Alongside** — Rename yours with a qualifier and publish next to the existing one
+- **Bail** — Cancel the publish
+
+**If PR collision exists but is another user's, or merged collision only:**
+- **Replace** — Intentionally overwrite the existing CLI
+- **Alongside** — Rename yours with a qualifier and publish next to the existing one
+- **Bail** — Cancel the publish and view the existing CLI/PR
+
+#### Update path (own PR)
+
+This is the existing update flow. Set `EXISTING_PR_NUMBER` from the detection step and proceed to Step 8, which handles force-push and PR description update.
+
+#### Replace path
+
+**For merged CLIs or your own PR:** Standard confirmation:
+> "This will replace the existing `<cli-name>`. Continue?"
+
+**For another user's PR:** Stronger confirmation naming the other author:
+> "⚠️ This will replace `<author>`'s `<cli-name>` (PR #N). Are you sure?"
+
+If confirmed:
+- The PR description must include: `⚠️ **Replaces existing \`<cli-name>\`** — <reason provided by user or "newer version">`
+- Set `EXISTING_PR_NUMBER=""` (create a new PR, don't update theirs)
+- Proceed to Step 8 normally
+
+#### Alongside path (rename)
+
+**1. Extract API slug** from the manifest's `api_name` field (not from the CLI name):
+
+```bash
+# Read from .printing-press.json in the publish repo's staged CLI
+API_SLUG=$(cat "$PUBLISH_REPO_DIR/library/<category>/<cli-name>/.printing-press.json" | jq -r '.api_name')
+```
+
+**2. Generate rename suggestions:**
+
+- Numeric: `<api-slug>-2-pp-cli` (if that collides, try `-3`, `-4`, etc.)
+- Non-numeric: `<api-slug>-alt-pp-cli`
+- Custom: prompt the user for a qualifier word
+
+Present the format to the user:
+> "Rename format: `<api-slug>-<qualifier>-pp-cli`. Pick a qualifier:"
+>
+> 1. `2` → `<api-slug>-2-pp-cli`
+> 2. `alt` → `<api-slug>-alt-pp-cli`
+> 3. Enter custom qualifier
+
+**3. Verify each suggestion is non-colliding** before presenting:
+
+```bash
+# Check merged
+ls "$PUBLISH_REPO_DIR/library"/*/"<suggestion>" 2>/dev/null
+# Check open PRs
+gh pr list --repo mvanhorn/printing-press-library --head "feat/<suggestion>" --state open --json number
+```
+
+If a suggestion collides, skip it or increment the numeric suffix.
+
+**4. Rename the CLI in the publish repo:**
+
+Since Step 6 wrote the CLI directly into `$PUBLISH_REPO_DIR` via `--dest`, the rename operates on that directory:
+
+```bash
+printing-press publish rename \
+ --dir "$PUBLISH_REPO_DIR/library/<category>/<old-cli-name>" \
+ --old-name <old-cli-name> \
+ --new-name <new-cli-name> \
+ --api-name "$API_SLUG" \
+ --json
+```
+
+Parse the JSON result. Verify `"success": true`. Note the `new_dir` path.
+
+**5. Update all downstream references for Step 8:**
+
+- Branch name: `feat/<new-cli-name>` (not the old name)
+- PR title: `feat(<api-slug>): add <new-cli-name>`
+- Commit message: `feat(<api-slug>): add <new-cli-name>`
+- Registry.json entry: `cli_name` → `<new-cli-name>`
+- Set `EXISTING_PR_NUMBER=""` (always a new PR for a renamed CLI)
+
+Proceed to Step 8 with the new name.
+
+#### Bail path
+
+Show links to what exists:
+- If merged: "Existing CLI at `library/<category>/<cli-name>/`"
+- If open PR: "Open PR: <url>"
+
+Exit the publish flow. If Step 6 already wrote files into `$PUBLISH_REPO_DIR`, clean up with `git checkout -- . && git clean -fd` in the managed clone.
## Step 8: Branch, Commit, and PR
@@ -500,6 +631,8 @@ Build the PR description from:
```markdown
## <cli-name>
+<If this is a Replace path, add: "⚠️ **Replaces existing `<cli-name>`** — <reason from user>">
+
<description from manifest, or "No description available">
**API:** <api_name> | **Category:** <category> | **Press version:** <printing_press_version>
@@ -627,7 +760,8 @@ flagged (e.g., a test fixture with a fake key). Don't block silently.
- **Validation fails:** Show per-check results in Step 4, stop
- **Repo unreachable:** Report clearly in Step 5
- **Fork creation fails:** `gh repo fork` may fail if the user already has a fork with a different name, or if the org restricts forking. Report the error and suggest the user fork manually via the GitHub web UI.
-- **Existing PR check fails:** Fall back to standard branch-conflict flow (treat as no existing PR)
+- **Collision check fails:** If `gh pr list` or `ls` commands fail (network, auth), warn but don't block — proceed as if no collision exists
+- **Rename fails:** Show the error from `publish rename --json`. Offer to retry with a different qualifier or bail. If the publish repo is in a partial state, reset with `git checkout -- . && git clean -fd` before retrying
- **Branch conflict (no existing PR):** Ask user in Step 8 (overwrite or timestamp)
- **Push fails:** For fork users, ensure they're pushing to their fork (origin), not upstream. Report the error, suggest checking `gh auth status` and `git remote -v`
- **Cross-repo PR creation fails:** If `gh pr create --head user:branch` fails with "head not found", the branch wasn't pushed to the fork. Verify with `git ls-remote origin feat/<cli-name>`
← 81716728 fix(cli): auto-award OAuth2 auth points until generator supp
·
back to Cli Printing Press
·
fix(cli): replace MarkFlagRequired with RunE validation, rem b5c91158 →