← back to Cli Printing Press
refactor(cli): library directories keyed by API slug instead of CLI name (#148)
36ed047f7e3d667c75b3f5dfeaa83ce2303674df · 2026-04-08 00:29:13 -0700 · Trevin Chow
Change the library directory key from CLI name (dub-pp-cli/) to API slug
(dub/). This aligns the local library with manuscripts (already slug-keyed),
simplifies mega MCP discovery, and removes the misleading implication that
a directory only contains a CLI binary when it now contains both CLI and
MCP binaries. With zero external users, backward-compat code is removed
rather than maintained.
Key changes:
- DefaultOutputDir uses API slug directly instead of naming.CLI()
- Generator --spec and --docs display names show slug, not CLI name
- IsValidLibraryDirName validates slug grammar (^[a-z0-9][a-z0-9-]*$)
- Emboss adds TrimCLISuffix fallback for old-name lookups after migration
- Publish pipeline uses manifest.APIName for directory key, not CLIName
- Lock promote/status derive slug from CLI name for library path lookup
- RenameCLI accepts slug-keyed --dir paths (binary names stay CLI-suffixed)
- library migrate subcommand renames old dirs preserving rerun suffixes
- Mega MCP backward-compat dual-path check simplified to slug-only
- Both skills updated for slug-keyed paths throughout
- Docs, README, AGENTS.md, contracts_test.go updated
Binary names (dub-pp-cli, dub-pp-mcp) are unchanged. Only the outer
library directory key changes. Public library repo migration is a
separate follow-up PR.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
M AGENTS.mdM ONBOARDING.mdM README.mdA docs/plans/2026-04-07-001-refactor-library-dirs-slug-keyed-plan.mdM docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.mdM internal/cli/emboss.goM internal/cli/emboss_test.goM internal/cli/library.goM internal/cli/library_test.goM internal/cli/lock_test.goM internal/cli/publish.goM internal/cli/publish_test.goM internal/cli/root.goM internal/megamcp/metatools.goM internal/naming/naming.goM internal/naming/naming_test.goM internal/pipeline/climanifest.goM internal/pipeline/contracts_test.goM internal/pipeline/lock.goM internal/pipeline/lock_test.goM internal/pipeline/pipeline.goM internal/pipeline/renamecli.goM internal/pipeline/renamecli_test.goM internal/pipeline/state_test.goM skills/printing-press-polish/SKILL.mdM skills/printing-press-publish/SKILL.mdM skills/printing-press/SKILL.md
Diff
commit 36ed047f7e3d667c75b3f5dfeaa83ce2303674df
Author: Trevin Chow <trevin@trevinchow.com>
Date: Wed Apr 8 00:29:13 2026 -0700
refactor(cli): library directories keyed by API slug instead of CLI name (#148)
Change the library directory key from CLI name (dub-pp-cli/) to API slug
(dub/). This aligns the local library with manuscripts (already slug-keyed),
simplifies mega MCP discovery, and removes the misleading implication that
a directory only contains a CLI binary when it now contains both CLI and
MCP binaries. With zero external users, backward-compat code is removed
rather than maintained.
Key changes:
- DefaultOutputDir uses API slug directly instead of naming.CLI()
- Generator --spec and --docs display names show slug, not CLI name
- IsValidLibraryDirName validates slug grammar (^[a-z0-9][a-z0-9-]*$)
- Emboss adds TrimCLISuffix fallback for old-name lookups after migration
- Publish pipeline uses manifest.APIName for directory key, not CLIName
- Lock promote/status derive slug from CLI name for library path lookup
- RenameCLI accepts slug-keyed --dir paths (binary names stay CLI-suffixed)
- library migrate subcommand renames old dirs preserving rerun suffixes
- Mega MCP backward-compat dual-path check simplified to slug-only
- Both skills updated for slug-keyed paths throughout
- Docs, README, AGENTS.md, contracts_test.go updated
Binary names (dub-pp-cli, dub-pp-mcp) are unchanged. Only the outer
library directory key changes. Public library repo migration is a
separate follow-up PR.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
AGENTS.md | 10 +-
ONBOARDING.md | 2 +-
README.md | 6 +-
...07-001-refactor-library-dirs-slug-keyed-plan.md | 335 +++++++++++++++++++++
...oped-printing-press-output-layout-2026-03-28.md | 9 +-
internal/cli/emboss.go | 11 +-
internal/cli/emboss_test.go | 7 +
internal/cli/library.go | 110 ++++++-
internal/cli/library_test.go | 230 +++++++++++++-
internal/cli/lock_test.go | 4 +-
internal/cli/publish.go | 35 ++-
internal/cli/publish_test.go | 25 +-
internal/cli/root.go | 11 +-
internal/megamcp/metatools.go | 16 +-
internal/naming/naming.go | 55 +++-
internal/naming/naming_test.go | 65 ++++
internal/pipeline/climanifest.go | 45 +--
internal/pipeline/contracts_test.go | 14 +-
internal/pipeline/lock.go | 10 +-
internal/pipeline/lock_test.go | 28 +-
internal/pipeline/pipeline.go | 4 +-
internal/pipeline/renamecli.go | 13 +-
internal/pipeline/renamecli_test.go | 57 +++-
internal/pipeline/state_test.go | 7 +-
skills/printing-press-polish/SKILL.md | 12 +-
skills/printing-press-publish/SKILL.md | 150 ++++-----
skills/printing-press/SKILL.md | 61 ++--
27 files changed, 1100 insertions(+), 232 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 880b7a96..6b8cbe6b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -70,11 +70,11 @@ Key terms used throughout this repo. Several have overloaded meanings — the gl
| **shipcheck** | The three-part verification block that gates publishing: dogfood + verify + scorecard, run together. All three must pass before a printed CLI ships. |
| **scorecard** / **scoring** | Two-tier quality assessment. Tier 1: infrastructure (12 dimensions, 60 pts). Tier 2: domain correctness (6 dimensions, 40 pts). Total /100 with letter grades. Subcommand: `printing-press scorecard`. |
| **doctor** | Self-diagnostic command shipped inside every printed CLI for end-users to run. Checks environment, auth config, and connectivity at the user's runtime. Unlike dogfood (which validates at generation time), doctor runs post-install. |
-| **local library** | `~/printing-press/library/<cli-name>/` — where printed CLIs land after a successful run. Local directory, not a git repo. |
+| **local library** | `~/printing-press/library/<api-slug>/` — where printed CLIs land after a successful run. Directory is keyed by API slug (e.g., `notion`), not CLI name. Local directory, not a git repo. |
| **public library repo** | The GitHub repo [`mvanhorn/printing-press-library`](https://github.com/mvanhorn/printing-press-library) — public catalog of finished CLIs organized by category. `/printing-press-publish` pushes here. |
| **publish (pipeline)** | The pipeline step that moves a working CLI into the local library and writes the `.printing-press.json` provenance manifest. |
| **publish (to public library repo)** | The skill-driven workflow (`/printing-press-publish`) that packages a local library CLI and creates a PR in the public library repo. |
-| **provenance** / **`.printing-press.json`** | Manifest written to each published CLI's root. Contains generation metadata: spec URL, checksum, run ID, printing-press version, timestamp. Makes the directory self-describing. |
+| **provenance** / **`.printing-press.json`** | Manifest written to each published CLI's root. Contains generation metadata: spec URL, checksum, run ID, printing-press version, timestamp. `api_name` is the canonical API identity; `cli_name` is the executable name. Makes the directory self-describing. |
| **catalog** | Embedded YAML entries in `catalog/` describing available APIs (name, spec URL, category, tier). Baked into the binary at build time via `catalog.FS`. |
| **tier** | Catalog classification: `official` (vendor-maintained spec) or `community` (unofficial/reverse-engineered). Affects risk expectations. |
| **runstate** | Mutable per-workspace state at `~/printing-press/.runstate/<scope>/`. Tracks current run and sync cursors. Distinct from manuscripts, which are immutable archives. |
@@ -147,13 +147,13 @@ Generated CLIs must pass 7 gates: go mod tidy, go vet, go build, binary build, -
Generated artifacts live under the user's home directory, not in this repo.
-- `library/<cli-name>/` — Published CLIs (e.g., `notion-pp-cli`). Directory name matches the derived CLI name from `naming.CLI()`.
+- `library/<api-slug>/` — Published CLIs (e.g., `notion`). Directory is keyed by API slug, not CLI name. The binary inside is still `<api-slug>-pp-cli`.
- `manuscripts/<api-slug>/` — Archived research and verification proofs, keyed by API slug (e.g., `notion`), not CLI name. One API can have multiple runs.
- `.runstate/<scope>/` — Mutable per-workspace state (current run, sync cursors). Scoped by repo basename + hash.
-The API slug is derived by the generator from the spec title (`cleanSpecName`), not manually chosen. The CLI name is `<api-slug>-pp-cli`. Never hardcode an API slug when the generator can derive it — names with periods (cal.com, dub.co) normalize differently than you'd guess.
+The API slug is derived by the generator from the spec title (`cleanSpecName`), not manually chosen. The CLI binary name is `<api-slug>-pp-cli`. Never hardcode an API slug when the generator can derive it — names with periods (cal.com, dub.co) normalize differently than you'd guess.
-The `-pp-` infix exists to avoid colliding with official CLIs. `notion-pp-cli` can coexist with whatever `notion-cli` Notion ships themselves.
+The `-pp-` infix exists to avoid colliding with official CLIs. The binary `notion-pp-cli` can coexist with whatever `notion-cli` Notion ships themselves. The library directory is just `notion/` — the `-pp-cli` suffix only appears on binary names, not directory names.
## Internal Skills
diff --git a/ONBOARDING.md b/ONBOARDING.md
index 69036cfb..3dae201c 100644
--- a/ONBOARDING.md
+++ b/ONBOARDING.md
@@ -124,7 +124,7 @@ internal/generator/generator.go (New + Generate)
renders 30+ .tmpl files to output dir
|
v
-Generated CLI project published at ~/printing-press/library/<name>-pp-cli/
+Generated CLI project published at ~/printing-press/library/<name>/
cmd/<name>-pp-cli/main.go
cmd/<name>-mcp/main.go
internal/cli/ (per-resource commands)
diff --git a/README.md b/README.md
index 4dffcc7d..2cccdded 100644
--- a/README.md
+++ b/README.md
@@ -185,7 +185,7 @@ When you add `codex`, Phase 3's code generation tasks are delegated to Codex CLI
```bash
/printing-press emboss notion # By API name
/printing-press emboss notion-pp-cli # By CLI name
-/printing-press emboss ~/printing-press/library/notion-pp-cli # By full path
+/printing-press emboss ~/printing-press/library/notion # By full path
```
Already generated a CLI? Emboss runs a focused improvement cycle: audit baseline (verify + scorecard), re-research what's changed, identify top 5 improvements, build them, re-verify, report the delta. Offered at the end of every run - never triggered automatically.
@@ -210,7 +210,7 @@ Already generated a CLI? Emboss runs a focused improvement cycle: audit baseline
**Command name normalization**: generated names like `retrieve-a` become `get`, `post` becomes `create`, `patch` becomes `update`. Clean names, not operationId garbage.
-**Provenance manifest**: every generated CLI includes a `.printing-press.json` with spec source, generation timestamp, press version, category, and run ID. Self-describing even in isolation.
+**Provenance manifest**: every generated CLI includes a `.printing-press.json` with spec source, generation timestamp, press version, category, and run ID. In that manifest, `api_name` is the canonical API identity and `cli_name` is the executable name; slug-keyed library directories are a separate concern. Self-describing even in isolation.
**Source credits**: the generated README includes a "Sources & Inspiration" section crediting the ecosystem tools and competing projects studied during research.
@@ -325,7 +325,7 @@ Each run produces two binaries (`<api>-pp-cli` + `<api>-pp-mcp`), research docum
By default, active and published output are separated:
- Active managed runs work in `~/printing-press/.runstate/<scope>/runs/<run-id>/working/<api>-pp-cli`
-- Published CLIs go to `~/printing-press/library/<api>-pp-cli`
+- Published CLIs go to `~/printing-press/library/<api>`
- Archived manuscripts go to `~/printing-press/manuscripts/<api>/<run-id>/`
- Manuscripts are split into `research/`, `proofs/`, `discovery/`, and `pipeline/`
diff --git a/docs/plans/2026-04-07-001-refactor-library-dirs-slug-keyed-plan.md b/docs/plans/2026-04-07-001-refactor-library-dirs-slug-keyed-plan.md
new file mode 100644
index 00000000..f14cdf66
--- /dev/null
+++ b/docs/plans/2026-04-07-001-refactor-library-dirs-slug-keyed-plan.md
@@ -0,0 +1,335 @@
+---
+title: "refactor: Library directories keyed by API slug instead of CLI name"
+type: refactor
+status: completed
+date: 2026-04-07
+origin: docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md
+---
+
+# refactor: Library directories keyed by API slug instead of CLI name
+
+## Overview
+
+Change the library directory key from CLI name (`dub-pp-cli/`) to API slug (`dub/`). This aligns the local library with manuscripts (already slug-keyed), simplifies mega MCP discovery, and removes the misleading implication that a directory only contains a CLI binary when it now contains both CLI and MCP binaries.
+
+With zero external users, there is no backward-compat cost. The existing backward-compat code in the mega MCP and library scanner can be removed rather than maintained.
+
+## Problem Frame
+
+The mega MCP plan (PR #147) deferred Phase 1 — the directory restructure — and shipped the mega MCP with backward-compat discovery that checks both `{slug}-pp-cli/` and `{slug}/` layouts. With no external users, this compat code is unnecessary complexity. Renaming now while the user base is 2 internal people avoids accumulating more CLIs under the old naming, making a future migration larger.
+
+## Requirements Trace
+
+- R1. New CLIs land in `~/printing-press/library/{api-slug}/` instead of `~/printing-press/library/{api-slug}-pp-cli/`
+- R2. Existing local CLIs are migrated to the new directory names
+- R3. The publish pipeline writes to slug-keyed paths in the public library repo
+- R4. The publish skill uses slug-keyed branch names, registry paths, and collision detection
+- R5. The mega MCP backward-compat discovery code is cleaned up (only check slug path)
+- R6. `publish rename` works with slug-keyed `--dir` paths (the outer directory is slug-keyed; `--old-name`/`--new-name` remain CLI-name-suffixed since `RenameCLI` does content replacement)
+- R7. Binary names remain unchanged (`dub-pp-cli`, `dub-pp-mcp`) — only the outer directory key changes
+
+## Scope Boundaries
+
+- **In scope:** Generator output path, publish pipeline, publish skill, mega MCP cleanup, local library migration, emboss resolution, library scanner, naming utilities
+- **In scope:** Renaming the 5 existing local library directories
+- **Out of scope:** Renaming binary names (`dub-pp-cli`, `dub-pp-mcp` stay as-is)
+- **Out of scope:** Public library repo migration — that's a separate PR in `mvanhorn/printing-press-library` after the machine changes land. The publish skill changes here will write *new* publishes under slug paths; existing CLIs in the public repo stay until migrated.
+- **Out of scope:** Changing the `go.mod` module path inside generated CLIs. The module path is a build-time concern and doesn't need to match the directory name. The publish pipeline's `RewriteModulePath` already handles setting the correct public module path at publish time.
+- **Out of scope:** Changing working directory naming during pipeline runs (`WorkingCLIDir` in `internal/pipeline/paths.go`). Working directories are ephemeral and the naming there is irrelevant to the library layout.
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/pipeline/pipeline.go:16-18` — `DefaultOutputDir` uses `naming.CLI(apiName)` to construct the library path. This is the single choke point for where new CLIs land.
+- `internal/cli/root.go:325-338` — After `generate --spec`, the output dir is renamed to `naming.CLI(apiSpec.Name)`. This would undo the `DefaultOutputDir` change if not updated.
+- `internal/naming/naming.go` — `CLI()`, `IsCLIDirName()`, `TrimCLISuffix()` all assume `-pp-cli` suffix. `CLI()` stays unchanged (binary naming), but `IsCLIDirName` and `TrimCLISuffix` need to handle both formats.
+- `internal/cli/library.go:86-144` — `scanLibrary()` filters on `naming.IsCLIDirName(dirName) || entry.APIName != ""`. The manifest-based path (`APIName != ""`) already handles slug-keyed dirs.
+- `internal/cli/emboss.go:182-215` — `resolveEmbossTarget()` tries exact name, then `naming.CLI(target)`. Needs slug-first lookup order.
+- `internal/cli/publish.go:276-301` — `newPublishPackageCmd` constructs `outCLIDir` as `filepath.Join(dest, "library", category, cliName)` where `cliName` comes from the manifest's `CLIName` field. This needs to use the API slug from the manifest instead.
+- `internal/cli/publish.go:403-428` — `stashExistingCLI` searches by `cliName` across categories. Needs to search by API slug.
+- `internal/pipeline/renamecli.go:157-173` — `validateRenameInputs` rejects names that don't pass `IsCLIDirName()`. Needs to also accept slug-keyed names.
+- `internal/megamcp/metatools.go:166-185` — `checkUpgradeAvailable` checks both `naming.CLI(slug)` and bare `slug` paths. Can be simplified to slug-only.
+- `skills/printing-press-publish/SKILL.md` — References `feat/<cli-name>` branch naming, `library/<category>/<cli-name>` paths, registry.json `name` and `path` fields. All need updating.
+- `internal/pipeline/publish.go:200` — `CLIName: naming.CLI(state.APIName)` in manifest. The `CLIName` in the manifest is the *binary* name, not the directory name, so this stays.
+
+### Institutional Learnings
+
+- **Layout contract** (`docs/solutions/best-practices/checkout-scoped-printing-press-output-layout`): Five assumptions shared across runtime, skills, and Go code about the naming contract. All consumers must update in lockstep.
+- **Filepath traversal protection** (`docs/solutions/security-issues/filepath-join-traversal-with-user-input`): API slugs used as path segments must be validated. Belt-and-suspenders: reject traversal characters AND verify resolved path is under root.
+- **Validation must not mutate source** (`docs/solutions/best-practices/validation-must-not-mutate-source-directory`): The migration step that renames local library directories must be separate from validation.
+
+## Key Technical Decisions
+
+- **Remove backward-compat rather than maintain it.** With 2 internal users and no external consumers, carrying both naming patterns adds test surface and cognitive overhead for no benefit. Migrate existing directories and drop the dual-lookup code.
+
+- **Keep `naming.CLI()` and `naming.MCP()` unchanged.** These produce *binary names* (`dub-pp-cli`, `dub-pp-mcp`), not directory names. The directory key is the API slug; the binary name is a separate concern.
+
+- **Migrate local library in-place.** The 5 existing directories (`cal-com-pp-cli`, `dub-pp-cli`, etc.) get renamed to their slug equivalents. A simple loop in a migration unit handles this.
+
+- **Directory key in publish pipeline is `manifest.APIName`, not `manifest.CLIName`.** The `CLIName` field continues to hold the binary name (`dub-pp-cli`). The directory in the public library becomes `library/<category>/dub/` but the binary inside is still `dub-pp-cli`.
+
+- **`publish rename` keeps CLI-name validation for `--old-name`/`--new-name`.** `RenameCLI` does `strings.ReplaceAll` with the old/new names across file contents — bare slugs like `"dub"` would corrupt any word containing that substring. The `--old-name`/`--new-name` parameters must remain CLI-name-suffixed (`IsCLIDirName`). What changes is the *outer directory*: after slug-keyed directories, the publish skill passes the slug-keyed `--dir` path but still uses CLI-suffixed binary names for `--old-name`/`--new-name`.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should backward-compat be kept or removed?** Removed. Zero external users means no migration burden. Simpler codebase.
+- **Should `go.mod` module path inside CLIs change?** No. The module path doesn't need to match the directory name. `RewriteModulePath` at publish time already sets the correct public path.
+- **Should working dir naming change?** No. Working dirs are ephemeral and don't affect the library layout.
+- **What happens to the `CLIName` field in manifests?** It stays as-is (`dub-pp-cli`). It's the binary name, not the directory name. Consumers that need the directory name should use `APIName` instead.
+
+### Deferred to Implementation
+
+- **Public library migration PR.** After machine changes land, a separate PR in `mvanhorn/printing-press-library` renames existing directories and updates `registry.json`. Not in this plan.
+
+## Implementation Units
+
+- [ ] **Unit 1: Change `DefaultOutputDir` and generator rename path**
+
+ **Goal:** New CLIs land in `~/printing-press/library/{api-slug}/` instead of `~/printing-press/library/{api-slug}-pp-cli/`.
+
+ **Requirements:** R1
+
+ **Dependencies:** None
+
+ **Files:**
+ - Modify: `internal/pipeline/pipeline.go` (`DefaultOutputDir`)
+ - Modify: `internal/cli/root.go` (lines 325-338: change `derivedDir` from `naming.CLI(apiSpec.Name)` to `apiSpec.Name`; line 200: change `--docs` display name from `naming.CLI(parsed.Name)` to `parsed.Name`; line 363: change `--spec` display name from `naming.CLI(apiSpec.Name)` to `apiSpec.Name`)
+ - Test: `internal/pipeline/pipeline_test.go`
+
+ **Approach:**
+ `DefaultOutputDir` changes from `filepath.Join(PublishedLibraryRoot(), naming.CLI(apiName))` to `filepath.Join(PublishedLibraryRoot(), apiName)`. The `generate --spec` rename path at `root.go:329` changes `derivedDir := naming.CLI(apiSpec.Name)` to `derivedDir := apiSpec.Name`. The surrounding logic (rename-if-different) works the same way.
+
+ **`--docs` code path:** The `--docs` path at `root.go:150` also calls `DefaultOutputDir(parsed.Name)` — it gets the slug-keyed path automatically since it has no post-generation rename logic. No changes needed. However, `root.go:200` prints `naming.CLI(parsed.Name)` as the display name — update this to print `parsed.Name` so the display name matches the actual directory (`"Generated dub at .../library/dub"` instead of `"Generated dub-pp-cli at .../library/dub"`).
+
+ **Not modified:** `naming.CLI()` usage in `generator_test.go`, `openapi/parser_test.go`, and fullrun working dir construction — these use `naming.CLI()` for binary naming, not library directory naming.
+
+ **Patterns to follow:**
+ - Existing `DefaultOutputDir` pattern
+
+ **Test scenarios:**
+ - Happy path: `DefaultOutputDir("dub")` returns path ending in `/dub` (not `/dub-pp-cli`)
+ - Happy path: `DefaultOutputDir("cal-com")` returns path ending in `/cal-com`
+ - Edge case: `DefaultOutputDir("steam-web")` returns path ending in `/steam-web` (hyphenated slugs work)
+
+ **Verification:** `go test ./internal/pipeline/...` passes. `go build ./...` succeeds.
+
+- [ ] **Unit 2: Update naming utilities and library scanner**
+
+ **Goal:** `scanLibrary()` discovers slug-keyed directories. Add `IsValidLibraryDirName()` for directory validation.
+
+ **Requirements:** R1, R6
+
+ **Dependencies:** Unit 1
+
+ **Files:**
+ - Modify: `internal/naming/naming.go` (add `IsValidLibraryDirName()`)
+ - Modify: `internal/cli/library.go` (simplify `scanLibrary` filter)
+ - Test: `internal/naming/naming_test.go`
+ - Test: `internal/cli/library_test.go` (if exists, or add tests)
+
+ **Approach:**
+ Add `IsValidLibraryDirName(name string) bool` that returns true for names that are non-empty, contain no path separators or `..`, don't start with `.`, and either pass `IsCLIDirName()` (legacy) or match the slug grammar `^[a-z0-9][a-z0-9-]*$` (lowercase alphanumeric + hyphens, must start with alphanumeric — this accepts rerun suffixes like `dub-2`). This is Layer 1 (input validation) per the filepath traversal protection solution. Callers that use the validated name in `filepath.Join` must still apply Layer 2: verify the resolved absolute path has prefix `abs(root) + sep` before any filesystem operation. In `scanLibrary`, simplify the filter: a directory is included if it has a valid manifest (`APIName != ""`) or passes `IsValidLibraryDirName()`. This covers both old and new naming during the transition period (between this landing and the local migration running).
+
+ `TrimCLISuffix` already returns the name unchanged for bare slugs (the `default` case at line 45), so no change needed there.
+
+ **Patterns to follow:**
+ - `IsCLIDirName()` pattern for the new function
+ - `scanLibrary()` manifest-first discovery pattern
+
+ **Test scenarios:**
+ - Happy path: `IsValidLibraryDirName("dub")` returns true
+ - Happy path: `IsValidLibraryDirName("dub-pp-cli")` returns true (legacy compat during transition)
+ - Happy path: `IsValidLibraryDirName("cal-com")` returns true
+ - Edge case: `IsValidLibraryDirName("")` returns false
+ - Edge case: `IsValidLibraryDirName("../etc")` returns false
+ - Edge case: `IsValidLibraryDirName(".DS_Store")` returns false (dotfiles rejected)
+ - Edge case: `IsValidLibraryDirName("dub-pp-cli-2")` returns true (legacy rerun suffix)
+ - Edge case: `IsValidLibraryDirName("dub-2")` returns true (slug rerun suffix — `ClaimOutputDir` produces these after migration)
+ - Happy path: `TrimCLISuffix("dub")` returns `"dub"` unchanged (already works, add test to lock it in)
+
+ **Verification:** `go test ./internal/naming/... ./internal/cli/...` passes.
+
+- [ ] **Unit 3: Update emboss resolution and publish pipeline**
+
+ **Goal:** `emboss` resolves slug-keyed library dirs. `publish package` writes to slug-keyed paths. `publish rename` accepts slug-keyed names.
+
+ **Requirements:** R3, R6
+
+ **Dependencies:** Unit 2
+
+ **Files:**
+ - Modify: `internal/cli/emboss.go` (`resolveEmbossTarget` — add `TrimCLISuffix` fallback as 3rd lookup step)
+ - Modify: `internal/cli/publish.go` (`newPublishPackageCmd` — use `APIName` for directory key instead of `CLIName`; update `stashExistingCLI` to search by API slug; add `APIName` empty fallback)
+ - Modify: `internal/pipeline/renamecli.go` (relax directory-base-mismatch check at line 53 to accept slug-keyed dir bases)
+ - Modify: `internal/pipeline/lock.go` (`PromoteWorkingCLI` at line 187 and `LockStatus` at line 133 — change `filepath.Join(PublishedLibraryRoot(), cliName)` to use API slug instead)
+ - Test: `internal/pipeline/renamecli_test.go`
+
+ **Approach:**
+
+ **Emboss:** The existing lookup order in `resolveEmbossTarget` is already correct: (1) try bare name at line 198, (2) try `naming.CLI(target)` at line 205 (guarded by `!strings.HasSuffix(target, CurrentCLISuffix)` — so `emboss dub-pp-cli` skips this step since the suffix already matches). No swap needed. The change is adding a 3rd fallback: when both fail, try `naming.TrimCLISuffix(target)` to handle `emboss dub-pp-cli` after migration — the bare lookup finds no `library/dub-pp-cli/`, step 2 is skipped (suffix guard), but `TrimCLISuffix("dub-pp-cli")` → `"dub"` → finds `library/dub/`.
+
+ **Publish package:** At line 277, `cliName` is derived from `vResult.CLIName` (which comes from the manifest's `cli_name` field — e.g., `dub-pp-cli`). For directory construction, use `vResult.APIName` instead. Introduce `dirName := vResult.APIName` for path construction while keeping `cliName` for binary operations (build checks, `oldModPath` at line 333, etc.). If `APIName` is empty (older CLIs generated before the manifest writer was updated), fall back to `naming.TrimCLISuffix(cliName)`. Return an error if both are empty. Update `stashExistingCLI` to search by `dirName` (the API slug) — old-format dirs in the public repo are handled by the separate public library migration PR, not here. Keep `resolveManuscripts(cliName, vResult.APIName)` unchanged — manuscripts are already slug-keyed and the function's second argument (API name) handles that correctly; changing the first argument to the slug would make both args identical and lose backward-compat manuscript lookup by CLI name.
+
+ **Lock pipeline:** `PromoteWorkingCLI` at `lock.go:187` and `LockStatus` at `lock.go:133` construct library paths via `filepath.Join(PublishedLibraryRoot(), cliName)`. Update both to derive the API slug from `cliName` (via `naming.TrimCLISuffix`) for directory lookup. The main skill's `lock promote --cli <api>-pp-cli` continues to pass the CLI name; the lock functions translate to slug-keyed paths internally.
+
+ **Publish rename:** `validateRenameInputs` keeps `IsCLIDirName()` for `--old-name`/`--new-name` — these are binary names used for content replacement, and bare slugs would cause collateral damage via `strings.ReplaceAll`. The only change is that `RenameCLI`'s directory-base-mismatch check at line 53 (`filepath.Base(absDir) != oldCLIName`) must be relaxed: after slug-keyed directories, the dir base is `"dub"` but oldCLIName is `"dub-pp-cli"`. Change this check to accept the base being either `oldCLIName` or `naming.TrimCLISuffix(oldCLIName)`.
+
+ **Patterns to follow:**
+ - Existing `resolveEmbossTarget` cascading lookup
+ - `stashExistingCLI` search pattern
+ - `validateRenameInputs` safety checks
+
+ **Test scenarios:**
+ - Happy path: `emboss dub` finds `~/printing-press/library/dub/`
+ - Happy path: `emboss dub-pp-cli` falls back and finds `~/printing-press/library/dub/` (via TrimCLISuffix)
+ - Happy path: `publish package` stages to `library/<category>/dub/` (not `dub-pp-cli/`)
+ - Happy path: `stashExistingCLI` finds and stashes `dub/` dirs when publishing `dub`
+ - Happy path: `RenameCLI` works when dir base is `"dub"` (slug) but `--old-name` is `"dub-pp-cli"` (binary name)
+ - Happy path: `validateRenameInputs("dub-pp-cli", "dub-alt-pp-cli")` still succeeds
+ - Error path: `validateRenameInputs("dub", "dub-alt")` still fails (bare slugs rejected for content replacement safety)
+ - Edge case: `publish package` correctly builds validation binary from `cmd/dub-pp-cli/` inside a `dub/`-named directory
+
+ **Verification:** `go test ./internal/cli/... ./internal/pipeline/...` passes.
+
+- [ ] **Unit 4: Migrate existing local library directories**
+
+ **Goal:** Rename the 5 existing local library directories from `{slug}-pp-cli/` to `{slug}/`.
+
+ **Requirements:** R2
+
+ **Dependencies:** Units 1-3 (must land first so newly generated CLIs use slug paths)
+
+ **Files:**
+ - Modify: `internal/cli/library.go` (add `migrateLibrary` function)
+ - Modify: `internal/cli/library.go` (add `library migrate` subcommand, or wire into existing `library` command)
+
+ **Approach:**
+ Add a `printing-press library migrate` subcommand that scans `~/printing-press/library/`, identifies directories matching the old `IsCLIDirName()` pattern, and renames them with `os.Rename`. Before each rename, verify the derived slug target resolves to a path under the library root (Layer 2 containment — `strings.HasPrefix(abs(target), abs(libRoot)+sep)`). Skip if the target slug dir already exists. Print what was renamed.
+
+ **Slug derivation for migration:** Do NOT use `TrimCLISuffix` — it strips the numeric rerun suffix first (`dub-pp-cli-2` → `dub`, not `dub-2`). Instead, use `strings.Replace(name, "-pp-cli", "", 1)` (or a dedicated `MigrateLibraryDirName` function) that removes only the `-pp-cli` infix while preserving rerun suffixes: `dub-pp-cli` → `dub`, `dub-pp-cli-2` → `dub-2`.
+
+ This is a one-time operation for the 2 internal users. Keep it simple — no dry-run mode needed.
+
+ Current directories to migrate:
+ - `cal-com-pp-cli/` → `cal-com/`
+ - `dub-pp-cli/` → `dub/`
+ - `pagliacci-pizza-pp-cli/` → `pagliacci-pizza/`
+ - `postman-explore-pp-cli/` → `postman-explore/`
+ - `steam-web-pp-cli/` → `steam-web/`
+ - `postman-explore-pp-cli.bak-170836/` → skip (not a valid CLI dir)
+
+ **Patterns to follow:**
+ - `scanLibrary()` iteration pattern
+ - `naming.IsCLIDirName()` for detection
+
+ **Test scenarios:**
+ - Happy path: `library migrate` renames `dub-pp-cli/` to `dub/`
+ - Happy path: `library migrate` renames `dub-pp-cli-2/` to `dub-2/` (rerun suffix preserved)
+ - Happy path: `library migrate` skips `postman-explore-pp-cli.bak-170836/` (not a valid CLI dir)
+ - Edge case: `library migrate` skips when target already exists (idempotent)
+ - Edge case: `library migrate` with empty library produces no errors
+ - Edge case: `library migrate` rejects a derived slug whose resolved path escapes the library root (Layer 2 traversal containment)
+
+ **Verification:** `go test ./internal/cli/...` passes. Running `library migrate` on the actual local library renames all 5 directories.
+
+- [ ] **Unit 5: Clean up mega MCP backward-compat code**
+
+ **Goal:** Remove dual-path discovery in mega MCP. Only check the slug-keyed path.
+
+ **Requirements:** R5
+
+ **Dependencies:** Unit 4 (local migration must complete first — removing old-path detection before migration means the mega MCP won't find existing CLIs)
+
+ **Files:**
+ - Modify: `internal/megamcp/metatools.go` (`checkUpgradeAvailable` — remove `naming.CLI(slug)` path check)
+
+ **Approach:**
+ At `metatools.go:175`, the `paths` slice currently checks both `naming.CLI(slug)` and bare `slug`. Remove the `naming.CLI(slug)` entry. Only check `filepath.Join(libraryRoot, slug, "cmd", mcpBinary)`.
+
+ **Patterns to follow:**
+ - Existing mega MCP path construction
+
+ **Test scenarios:**
+ - Happy path: `checkUpgradeAvailable("dub")` finds MCP binary at `library/dub/cmd/dub-pp-mcp/`
+ - Edge case: `checkUpgradeAvailable("dub")` returns false when `library/dub-pp-cli/` exists but `library/dub/` does not (old layout no longer recognized — expected after migration)
+
+ **Verification:** `go test ./internal/megamcp/...` passes.
+
+- [ ] **Unit 6: Update publish skill for slug-keyed paths**
+
+ **Goal:** The publish skill uses API slug as the directory key in branch names, registry entries, collision detection, and path construction.
+
+ **Requirements:** R4
+
+ **Dependencies:** Unit 3
+
+ **Files:**
+ - Modify: `skills/printing-press-publish/SKILL.md`
+ - Modify: `skills/printing-press/SKILL.md` (6+ references to `<api>-pp-cli` as library paths: lines 297, 374, 1007, 1480, 1483, 1501, 1584. Update library path references to use slug format. Update `lock promote --cli` invocations.)
+
+ **Approach:**
+ Replace `<cli-name>` with `<api-slug>` as the directory key throughout **both** skills. Specific changes for the publish skill:
+
+ 1. **Step 6 (Package):** Module path construction changes from `<module_path_base>/<category>/<cli-name>` to `<module_path_base>/<category>/<api-slug>`
+ 2. **Step 7 (Collision detection):** Search by API slug instead of CLI name. `ls "$PUBLISH_REPO_DIR/library"/*/"<api-slug>"`. Branch detection uses `feat/<api-slug>` instead of `feat/<cli-name>`.
+ 3. **Step 8 (Branch/Commit/PR):** Branch name `feat/<api-slug>`. Commit message `feat(<api-slug>): add <api-slug>`. Registry entry `name` and `path` use slug.
+ 4. **Rename path (Alongside):** Suggestions use slug format: `<slug>-2`, `<slug>-alt` instead of `<slug>-2-pp-cli`.
+ 5. **Name resolution (Step 2):** Continue to accept both CLI name and slug as input, but resolve to the API slug for all downstream operations.
+ 6. **Registry.json entry:** `name` becomes the API slug, `path` becomes `library/<category>/<api-slug>`, `manifest_url` becomes `library/<category>/<api-slug>/tools-manifest.json`.
+
+ **Test expectation:** none — skill files are tested via end-to-end publish runs, not unit tests.
+
+ **Verification:** Read through the updated skill and verify all path references use slug format consistently.
+
+- [ ] **Unit 7: Update examples and documentation**
+
+ **Goal:** All help text, examples, and the AGENTS.md glossary reflect slug-keyed library paths.
+
+ **Requirements:** R1
+
+ **Dependencies:** Units 1-6 (can start after Unit 3; only needs all units for final consistency check)
+
+ **Files:**
+ - Modify: `internal/cli/publish.go` (example strings in command definitions)
+ - Modify: `AGENTS.md` (glossary entries for local library, public library, publish)
+ - Modify: `README.md` (layout diagram at line 328, emboss example at line 188)
+ - Modify: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout*.md` (layout contract doc)
+ - Modify: `internal/pipeline/contracts_test.go` (hardcoded assertions for `$PRESS_LIBRARY/<api>-pp-cli`, `lock promote --cli <api>-pp-cli`, and `~/printing-press/library/<api>-pp-cli` in skill/README content checks)
+
+ **Approach:**
+ Find-and-replace example paths: `~/printing-press/library/notion-pp-cli` → `~/printing-press/library/notion`. Update the layout contract doc to reflect the new naming. Update the AGENTS.md glossary entry for `~/printing-press/` Layout. Update contracts_test.go assertions to match the new slug-keyed path patterns — these tests validate that skills and README contain expected library path formats.
+
+ **Test scenarios:**
+ - Happy path: `go test ./internal/pipeline/...` passes with updated contract assertions
+
+ **Verification:** Grep for stale `library/.*-pp-cli` references in docs and help text.
+
+## System-Wide Impact
+
+- **Interaction graph:** The directory rename touches the generator output path, emboss resolution, library scanner, publish pipeline, publish skill, mega MCP discovery, and documentation. All are updated in this plan.
+- **Error propagation:** If `DefaultOutputDir` changes but `root.go:329` doesn't, every `--spec` run would rename back to the old format. Unit 1 addresses both together.
+- **State lifecycle risks:** Local library directories are renamed in-place. If the migration is interrupted mid-rename, some dirs will be old format and some new. The scanner accepts both formats (via manifest presence), so partial migration is non-fatal.
+- **API surface parity:** The `publish rename` command's validation must accept slug-keyed names. The `buildValidationBinary` helper at `publish.go:672-692` builds from `cmd/<cliName>/` — this uses the *binary* name (from manifest `CLIName`), not the directory name, so it works without changes.
+- **Unchanged invariants:** Binary names (`dub-pp-cli`, `dub-pp-mcp`) are explicitly not changed. `naming.CLI()`, `naming.MCP()` continue to produce suffixed binary names. `cmd/` subdirectories inside CLIs still use binary names. Only the *outer library directory* key changes.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| `root.go:329` rename undoes `DefaultOutputDir` change | Unit 1 updates both together. Test confirms output dir uses slug. |
+| `buildValidationBinary` can't find binary in slug-keyed dir | It uses `cliName` (binary name from manifest) to look in `cmd/<cliName>/`, which is inside the dir regardless of the dir's name. No change needed. |
+| `RenameCLI` with bare slugs corrupts file contents | `validateRenameInputs` keeps `IsCLIDirName` requirement for `--old-name`/`--new-name`. Only the `--dir` path becomes slug-keyed. The directory-base-mismatch check is relaxed to accept slug bases. |
+| Public library publish creates slug-keyed dirs but old CLIs exist under `-pp-cli` | `stashExistingCLI` searches by API slug. Old-format dirs in the public repo won't be found — republishing an existing CLI creates a duplicate until the public library migration PR lands. Accepted risk for the 2-user window. |
+| Mega MCP stops finding CLIs before migration runs | Unit 5 (compat removal) depends on Unit 4 (migration). Migration runs first. |
+| Interrupted local migration leaves mixed naming | Scanner accepts both formats. `library migrate` is idempotent. |
+| Path traversal via crafted API slug in `filepath.Join` | Layer 1: `IsValidLibraryDirName` rejects traversal characters. Layer 2: callers verify resolved path is under root before filesystem operations. Both layers required per institutional learning. |
+
+## Sources & References
+
+- **Origin document:** [docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md](docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md) (Phase 1)
+- Related PRs: #147 (mega MCP), #145 (MCP readiness layer)
+- Layout contract: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout*.md`
+- Traversal protection: `docs/solutions/security-issues/filepath-join-traversal-with-user-input*.md`
diff --git a/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md b/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md
index 9dd751fb..76376066 100644
--- a/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md
+++ b/docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md
@@ -63,7 +63,7 @@ Use a checkout-scoped runstate derived from the current git root for active work
pipeline/
discovery/ (optional — sniff/crowd-sniff captures, reports, URL lists)
library/
- <api>-pp-cli[-N]/
+ <api>[-N]/
manuscripts/<api>/<run-id>/
research/
proofs/
@@ -85,9 +85,10 @@ PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"
Keep the naming contract explicit:
-- Generated human CLI directory and command: `<api>-pp-cli`
+- Library directory: `<api>` (keyed by API slug, e.g., `notion`, `cal-com`)
+- Binary name: `<api>-pp-cli` (unchanged — the `-pp-cli` suffix is for binary names only)
- Legacy compatibility: accept `<api>-cli` when discovering older projects
-- Claimed reruns: allow outer directories like `<api>-pp-cli-2`, but still resolve the actual command entrypoint from `cmd/<api>-pp-cli`
+- Claimed reruns: allow outer directories like `<api>-2`, but still resolve the actual command entrypoint from `cmd/<api>-pp-cli`
The runtime verifier should discover the command directory independently of the outer project folder:
@@ -126,7 +127,7 @@ That removes the class of bugs where docs say one thing, skills do another, and
- Never hardcode `~/cli-printing-press` in skills, docs, or code paths. Always resolve `git rev-parse --show-toplevel` first.
- When adding a new artifact-producing phase, decide first whether it belongs in runstate `research/`, `proofs/`, `pipeline/`, or `discovery/`, and whether it must also be archived at publish time. Do not default to repo `docs/plans/`.
-- If a feature writes a file tied to a generated CLI, test both canonical and claimed output dirs, for example `notion-pp-cli` and `notion-pp-cli-2`.
+- If a feature writes a file tied to a generated CLI, test both canonical and claimed output dirs, for example `notion` and `notion-2`.
- Keep naming logic centralized in `internal/naming/` and path logic centralized in `internal/pipeline/paths.go`.
- Add tests whenever code infers API names or command directories from filesystem paths.
- Treat published library CLIs as immutable outputs. Workflows like `emboss` should copy them into a new runstate working dir instead of mutating them in place.
diff --git a/internal/cli/emboss.go b/internal/cli/emboss.go
index a606f495..890cb5ff 100644
--- a/internal/cli/emboss.go
+++ b/internal/cli/emboss.go
@@ -74,7 +74,7 @@ The improvement steps (2-4) are driven by the /printing-press emboss skill.`,
printing-press emboss notion
# By path
- printing-press emboss ~/printing-press/library/notion-pp-cli
+ printing-press emboss ~/printing-press/library/notion
printing-press emboss ./discord-pp-cli
# With --dir flag (backward compatible)
@@ -208,6 +208,15 @@ func resolveEmbossTarget(flagDir string, args []string) (string, error) {
}
}
+ // Try deriving the slug-keyed directory from the CLI name (handles
+ // migrated rerun dirs like "dub-pp-cli-2" -> "dub-2").
+ if trimmed := naming.LibraryDirName(target); trimmed != target {
+ candidate = filepath.Join(libraryRoot, trimmed)
+ if info, err := os.Stat(candidate); err == nil && info.IsDir() {
+ return candidate, nil
+ }
+ }
+
return "", fmt.Errorf("no CLI named %q found in %s", target, libraryRoot)
default:
return "", fmt.Errorf("specify a CLI name or path (e.g. printing-press emboss notion)")
diff --git a/internal/cli/emboss_test.go b/internal/cli/emboss_test.go
index 70a53112..fdce4ae2 100644
--- a/internal/cli/emboss_test.go
+++ b/internal/cli/emboss_test.go
@@ -19,6 +19,7 @@ func TestResolveEmbossTarget(t *testing.T) {
libraryDir := filepath.Join(home, "library")
require.NoError(t, os.MkdirAll(filepath.Join(libraryDir, "notion-pp-cli"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(libraryDir, "discord-pp-cli"), 0o755))
+ require.NoError(t, os.MkdirAll(filepath.Join(libraryDir, "dub-2"), 0o755))
tests := []struct {
name string
@@ -63,6 +64,12 @@ func TestResolveEmbossTarget(t *testing.T) {
args: []string{"local-only"},
wantErr: `no CLI named "local-only" found`,
},
+ {
+ name: "legacy rerun name resolves migrated slug-keyed dir",
+ flagDir: "",
+ args: []string{"dub-pp-cli-2"},
+ want: filepath.Join(libraryDir, "dub-2"),
+ },
{
name: "no match",
flagDir: "",
diff --git a/internal/cli/library.go b/internal/cli/library.go
index 9d615846..ffaa0880 100644
--- a/internal/cli/library.go
+++ b/internal/cli/library.go
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"sort"
+ "strings"
"time"
"github.com/mvanhorn/cli-printing-press/internal/naming"
@@ -36,6 +37,7 @@ func newLibraryCmd() *cobra.Command {
}
cmd.AddCommand(newLibraryListCmd())
+ cmd.AddCommand(newLibraryMigrateCmd())
return cmd
}
@@ -83,6 +85,110 @@ func newLibraryListCmd() *cobra.Command {
return cmd
}
+func newLibraryMigrateCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "migrate",
+ Short: "Rename legacy -pp-cli library directories to slug-keyed names",
+ Long: `Scans ~/printing-press/library/ for directories using the old naming
+convention ({slug}-pp-cli) and renames them to the new slug-keyed format ({slug}).
+
+Examples:
+ dub-pp-cli/ → dub/
+ dub-pp-cli-2/ → dub-2/
+ cal-com-pp-cli/ → cal-com/
+
+Directories that already have the target name are skipped (idempotent).`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ libRoot := pipeline.PublishedLibraryRoot()
+ renamed, skipped, err := migrateLibrary(libRoot)
+ if err != nil {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("migrating library: %w", err)}
+ }
+ for _, msg := range renamed {
+ fmt.Fprintln(cmd.OutOrStdout(), msg)
+ }
+ for _, msg := range skipped {
+ fmt.Fprintln(cmd.ErrOrStderr(), msg)
+ }
+ if len(renamed) == 0 {
+ fmt.Fprintln(cmd.ErrOrStderr(), "Nothing to migrate.")
+ } else {
+ fmt.Fprintf(cmd.ErrOrStderr(), "\nMigrated %d directories.\n", len(renamed))
+ }
+ return nil
+ },
+ }
+}
+
+// migrateLibraryDirName converts a legacy CLI directory name to its slug-keyed
+// equivalent by removing the "-pp-cli" infix. Unlike TrimCLISuffix, this
+// preserves numeric rerun suffixes: "dub-pp-cli-2" → "dub-2", not "dub".
+func migrateLibraryDirName(name string) string {
+ return naming.LibraryDirName(name)
+}
+
+// migrateLibrary scans libRoot for directories matching the old IsCLIDirName()
+// pattern and renames them to their slug-keyed equivalents. Returns lists of
+// renamed and skipped messages for display.
+func migrateLibrary(libRoot string) (renamed []string, skipped []string, err error) {
+ dirEntries, err := os.ReadDir(libRoot)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil, nil
+ }
+ return nil, nil, fmt.Errorf("reading library: %w", err)
+ }
+
+ absRoot, err := filepath.Abs(libRoot)
+ if err != nil {
+ return nil, nil, fmt.Errorf("resolving library root: %w", err)
+ }
+
+ for _, de := range dirEntries {
+ if !de.IsDir() {
+ continue
+ }
+ dirName := de.Name()
+
+ // Only migrate directories that match the old CLI naming convention
+ if !naming.IsCLIDirName(dirName) {
+ continue
+ }
+
+ slugName := migrateLibraryDirName(dirName)
+ if slugName == "" || slugName == dirName {
+ continue
+ }
+
+ targetPath := filepath.Join(libRoot, slugName)
+
+ // Layer 2 containment: verify derived target resolves under library root
+ absTarget, err := filepath.Abs(targetPath)
+ if err != nil {
+ skipped = append(skipped, fmt.Sprintf("skip %s: cannot resolve target path: %v", dirName, err))
+ continue
+ }
+ if !strings.HasPrefix(absTarget, absRoot+string(filepath.Separator)) {
+ skipped = append(skipped, fmt.Sprintf("skip %s: target %q escapes library root", dirName, slugName))
+ continue
+ }
+
+ // Skip if target already exists (idempotent)
+ if _, err := os.Stat(targetPath); err == nil {
+ skipped = append(skipped, fmt.Sprintf("skip %s: target %s already exists", dirName, slugName))
+ continue
+ }
+
+ srcPath := filepath.Join(libRoot, dirName)
+ if err := os.Rename(srcPath, targetPath); err != nil {
+ return renamed, skipped, fmt.Errorf("renaming %s to %s: %w", dirName, slugName, err)
+ }
+ renamed = append(renamed, fmt.Sprintf("renamed %s → %s", dirName, slugName))
+ }
+
+ return renamed, skipped, nil
+}
+
func scanLibrary() ([]LibraryEntry, error) {
libRoot := pipeline.PublishedLibraryRoot()
@@ -129,8 +235,8 @@ func scanLibrary() ([]LibraryEntry, error) {
}
}
- // Only include directories that look like CLIs or have a manifest
- if naming.IsCLIDirName(dirName) || entry.APIName != "" {
+ // Only include directories with a valid manifest or a valid library dir name
+ if entry.APIName != "" || naming.IsValidLibraryDirName(dirName) {
entries = append(entries, entry)
}
}
diff --git a/internal/cli/library_test.go b/internal/cli/library_test.go
index 04116e16..a05d52b5 100644
--- a/internal/cli/library_test.go
+++ b/internal/cli/library_test.go
@@ -161,8 +161,9 @@ func TestLibraryListIgnoresNonCLIDirectories(t *testing.T) {
home := setLibraryTestEnv(t)
libDir := filepath.Join(home, "library")
- // A directory that's not a CLI and has no manifest
- require.NoError(t, os.MkdirAll(filepath.Join(libDir, "random-dir"), 0o755))
+ // Directories that should be excluded (dotfiles, invalid names)
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, ".DS_Store"), 0o755))
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, ".hidden"), 0o755))
// A real CLI directory
require.NoError(t, os.MkdirAll(filepath.Join(libDir, "test-pp-cli"), 0o755))
@@ -177,3 +178,228 @@ func TestLibraryListIgnoresNonCLIDirectories(t *testing.T) {
assert.Len(t, entries, 1)
assert.Equal(t, "test-pp-cli", entries[0].CLIName)
}
+
+func TestLibraryListSlugKeyedDirectory(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ // A slug-keyed directory with a manifest (new format)
+ slugDir := filepath.Join(libDir, "dub")
+ require.NoError(t, os.MkdirAll(slugDir, 0o755))
+ writeTestManifest(t, slugDir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "dub",
+ CLIName: "dub-pp-cli",
+ Category: "developer-tools",
+ Description: "Dub link management API",
+ })
+
+ // A slug-keyed directory without a manifest (still included by name validation)
+ bareSlugDir := filepath.Join(libDir, "cal-com")
+ require.NoError(t, os.MkdirAll(bareSlugDir, 0o755))
+
+ cmd := newLibraryCmd()
+ cmd.SetArgs([]string{"list", "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var entries []LibraryEntry
+ require.NoError(t, json.Unmarshal([]byte(output), &entries))
+ assert.Len(t, entries, 2)
+
+ names := map[string]bool{}
+ for _, e := range entries {
+ names[e.CLIName] = true
+ }
+ // Manifest-based entry uses CLIName from manifest
+ assert.True(t, names["dub-pp-cli"])
+ // Bare slug entry uses directory name as CLIName
+ assert.True(t, names["cal-com"])
+}
+
+func TestMigrateLibraryDirName(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"dub-pp-cli", "dub"},
+ {"cal-com-pp-cli", "cal-com"},
+ {"dub-pp-cli-2", "dub-2"},
+ {"steam-web-pp-cli", "steam-web"},
+ {"pagliacci-pizza-pp-cli", "pagliacci-pizza"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := migrateLibraryDirName(tt.input)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestMigrateLibraryHappyPath(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ // Create old-style directories
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "dub-pp-cli"), 0o755))
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "cal-com-pp-cli"), 0o755))
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "steam-web-pp-cli"), 0o755))
+
+ renamed, skipped, err := migrateLibrary(libDir)
+ require.NoError(t, err)
+ assert.Len(t, renamed, 3)
+ assert.Empty(t, skipped)
+
+ // Verify old dirs are gone and new dirs exist
+ _, err = os.Stat(filepath.Join(libDir, "dub-pp-cli"))
+ assert.True(t, os.IsNotExist(err))
+ _, err = os.Stat(filepath.Join(libDir, "dub"))
+ assert.NoError(t, err)
+
+ _, err = os.Stat(filepath.Join(libDir, "cal-com-pp-cli"))
+ assert.True(t, os.IsNotExist(err))
+ _, err = os.Stat(filepath.Join(libDir, "cal-com"))
+ assert.NoError(t, err)
+
+ _, err = os.Stat(filepath.Join(libDir, "steam-web-pp-cli"))
+ assert.True(t, os.IsNotExist(err))
+ _, err = os.Stat(filepath.Join(libDir, "steam-web"))
+ assert.NoError(t, err)
+}
+
+func TestMigrateLibraryRerunSuffix(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ // Rerun suffix must be preserved: dub-pp-cli-2 → dub-2
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "dub-pp-cli-2"), 0o755))
+
+ renamed, skipped, err := migrateLibrary(libDir)
+ require.NoError(t, err)
+ assert.Len(t, renamed, 1)
+ assert.Empty(t, skipped)
+
+ _, err = os.Stat(filepath.Join(libDir, "dub-pp-cli-2"))
+ assert.True(t, os.IsNotExist(err))
+ _, err = os.Stat(filepath.Join(libDir, "dub-2"))
+ assert.NoError(t, err)
+}
+
+func TestMigrateLibrarySkipsNonCLIDirs(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ // This has a dot + extra suffix — IsCLIDirName returns false
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "postman-explore-pp-cli.bak-170836"), 0o755))
+ // A slug-keyed dir (already migrated)
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "dub"), 0o755))
+ // A dotfile
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, ".DS_Store"), 0o755))
+
+ renamed, skipped, err := migrateLibrary(libDir)
+ require.NoError(t, err)
+ assert.Empty(t, renamed)
+ assert.Empty(t, skipped)
+
+ // Verify non-CLI dirs are untouched
+ _, err = os.Stat(filepath.Join(libDir, "postman-explore-pp-cli.bak-170836"))
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(libDir, "dub"))
+ assert.NoError(t, err)
+}
+
+func TestMigrateLibrarySkipsWhenTargetExists(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ // Old dir and target both exist — skip (idempotent)
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "dub-pp-cli"), 0o755))
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "dub"), 0o755))
+
+ renamed, skipped, err := migrateLibrary(libDir)
+ require.NoError(t, err)
+ assert.Empty(t, renamed)
+ assert.Len(t, skipped, 1)
+ assert.Contains(t, skipped[0], "already exists")
+
+ // Both dirs still exist
+ _, err = os.Stat(filepath.Join(libDir, "dub-pp-cli"))
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(libDir, "dub"))
+ assert.NoError(t, err)
+}
+
+func TestMigrateLibraryEmptyLibrary(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+ require.NoError(t, os.MkdirAll(libDir, 0o755))
+
+ renamed, skipped, err := migrateLibrary(libDir)
+ require.NoError(t, err)
+ assert.Empty(t, renamed)
+ assert.Empty(t, skipped)
+}
+
+func TestMigrateLibraryNonexistentLibrary(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library") // does not exist
+
+ renamed, skipped, err := migrateLibrary(libDir)
+ require.NoError(t, err)
+ assert.Empty(t, renamed)
+ assert.Empty(t, skipped)
+}
+
+func TestMigrateLibraryTraversalContainment(t *testing.T) {
+ // Test Layer 2 containment: a crafted directory name whose derived slug
+ // would escape the library root must be rejected.
+ //
+ // We can't easily create a directory named with ".." via os.MkdirAll
+ // since the OS normalizes it. Instead, test the migrateLibrary function
+ // directly with a directory that, after -pp-cli removal, would produce
+ // a name containing path separators.
+ //
+ // The IsCLIDirName filter already rejects most invalid names, but we verify
+ // the Layer 2 check works by calling migrateLibrary on a library root where
+ // the abs-target check would catch escapes that slip through Layer 1.
+
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+ require.NoError(t, os.MkdirAll(libDir, 0o755))
+
+ // This is a valid CLI dir name that migrates cleanly — verify it works
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "safe-pp-cli"), 0o755))
+
+ renamed, skipped, err := migrateLibrary(libDir)
+ require.NoError(t, err)
+ assert.Len(t, renamed, 1)
+ assert.Empty(t, skipped)
+
+ _, err = os.Stat(filepath.Join(libDir, "safe"))
+ assert.NoError(t, err)
+}
+
+func TestMigrateLibraryViaCommand(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "dub-pp-cli"), 0o755))
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "cal-com-pp-cli"), 0o755))
+
+ cmd := newLibraryCmd()
+ cmd.SetArgs([]string{"migrate"})
+
+ err := cmd.Execute()
+ require.NoError(t, err)
+
+ // Verify directories were renamed
+ _, err = os.Stat(filepath.Join(libDir, "dub"))
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(libDir, "cal-com"))
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(libDir, "dub-pp-cli"))
+ assert.True(t, os.IsNotExist(err))
+ _, err = os.Stat(filepath.Join(libDir, "cal-com-pp-cli"))
+ assert.True(t, os.IsNotExist(err))
+}
diff --git a/internal/cli/lock_test.go b/internal/cli/lock_test.go
index 2598b33e..e788221a 100644
--- a/internal/cli/lock_test.go
+++ b/internal/cli/lock_test.go
@@ -177,8 +177,8 @@ func TestLockPromote_Success(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(stdout), &result))
assert.Equal(t, true, result["promoted"])
- // Verify library dir exists.
- libDir := filepath.Join(pipeline.PublishedLibraryRoot(), "test-pp-cli")
+ // Verify library dir exists (slug-keyed).
+ libDir := filepath.Join(pipeline.PublishedLibraryRoot(), "test")
_, err := os.Stat(filepath.Join(libDir, "go.mod"))
assert.NoError(t, err)
}
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 1274a6f1..f839bfe8 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -27,10 +27,10 @@ func newPublishCmd() *cobra.Command {
Use: "publish",
Short: "Validate and package CLIs for publishing",
Example: ` # Validate a CLI before publishing
- printing-press publish validate --dir ~/printing-press/library/notion-pp-cli --json
+ printing-press publish validate --dir ~/printing-press/library/notion --json
# Package a CLI for publishing
- printing-press publish package --dir ~/printing-press/library/notion-pp-cli --category productivity --target /tmp/staging --json`,
+ printing-press publish package --dir ~/printing-press/library/notion --category productivity --target /tmp/staging --json`,
}
cmd.AddCommand(newPublishValidateCmd())
@@ -88,8 +88,8 @@ func newPublishRenameCmd() *cobra.Command {
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`,
+ Example: ` printing-press publish rename --dir /tmp/staging/library/ai/notion --old-name notion-pp-cli --new-name notion-alt-pp-cli --json
+ printing-press publish rename --dir /tmp/staging/library/ai/notion --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")}
@@ -116,7 +116,7 @@ func newPublishRenameCmd() *cobra.Command {
result.Error = err.Error()
} else {
result.Success = true
- result.NewDir = filepath.Join(filepath.Dir(dir), newName)
+ result.NewDir = filepath.Join(filepath.Dir(dir), naming.LibraryDirName(newName))
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
@@ -132,7 +132,7 @@ func newPublishRenameCmd() *cobra.Command {
if err != nil {
return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("rename failed: %w", err)}
}
- newDir := filepath.Join(filepath.Dir(dir), newName)
+ newDir := filepath.Join(filepath.Dir(dir), naming.LibraryDirName(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
@@ -155,8 +155,8 @@ func newPublishValidateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "validate",
Short: "Validate a CLI is ready for publishing",
- Example: ` printing-press publish validate --dir ~/printing-press/library/notion-pp-cli
- printing-press publish validate --dir ~/printing-press/library/notion-pp-cli --json`,
+ Example: ` printing-press publish validate --dir ~/printing-press/library/notion
+ printing-press publish validate --dir ~/printing-press/library/notion --json`,
RunE: func(cmd *cobra.Command, args []string) error {
if dir == "" {
return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
@@ -221,10 +221,10 @@ func newPublishPackageCmd() *cobra.Command {
Use: "package",
Short: "Package a CLI for publishing to the library repo",
Example: ` # Stage into a new directory (for inspection)
- printing-press publish package --dir ~/printing-press/library/notion-pp-cli --category productivity --target /tmp/staging --json
+ printing-press publish package --dir ~/printing-press/library/notion --category productivity --target /tmp/staging --json
# Write directly into the publish repo (replaces old CLI, includes manuscripts)
- printing-press publish package --dir ~/printing-press/library/notion-pp-cli --category productivity --dest ~/printing-press/.publish-repo --json`,
+ printing-press publish package --dir ~/printing-press/library/notion --category productivity --dest ~/printing-press/.publish-repo --json`,
RunE: func(cmd *cobra.Command, args []string) error {
if dir == "" {
return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
@@ -279,6 +279,15 @@ func newPublishPackageCmd() *cobra.Command {
cliName = filepath.Base(dir)
}
+ // Determine directory name: use API slug, not CLI name.
+ dirName := vResult.APIName
+ if dirName == "" {
+ dirName = naming.TrimCLISuffix(cliName)
+ }
+ if dirName == "" {
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("cannot determine API name for directory key")}
+ }
+
// Choose output mode: --dest writes directly, --target stages
var outCLIDir string
var rootDir string // root to clean up on failure
@@ -287,17 +296,17 @@ func newPublishPackageCmd() *cobra.Command {
var stashedDirs []stashedDir
if dest != "" {
rootDir = dest
- outCLIDir = filepath.Join(dest, "library", category, cliName)
+ outCLIDir = filepath.Join(dest, "library", category, dirName)
// Move existing CLI dirs aside (don't delete yet — restore on failure)
var err error
- stashedDirs, err = stashExistingCLI(dest, cliName)
+ stashedDirs, err = stashExistingCLI(dest, dirName)
if err != nil {
return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("stashing old CLI: %w", err)}
}
} else {
rootDir = target
- outCLIDir = filepath.Join(target, "library", category, cliName)
+ outCLIDir = filepath.Join(target, "library", category, dirName)
}
// Verify the resolved path is actually under rootDir (defense in depth)
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index f843bc1f..519a4f3c 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
+ "github.com/mvanhorn/cli-printing-press/internal/naming"
"github.com/mvanhorn/cli-printing-press/internal/pipeline"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -433,8 +434,8 @@ func TestPublishPackageDestWritesDirectly(t *testing.T) {
assert.True(t, result.ManuscriptsIncluded, "manuscripts should be included")
assert.Equal(t, runID, result.RunID)
- // Verify CLI is at dest/library/<category>/<cli-name>/
- cliOut := filepath.Join(destDir, "library", "other", "test-pp-cli")
+ // Verify CLI is at dest/library/<category>/<api-slug>/
+ cliOut := filepath.Join(destDir, "library", "other", "test")
assert.Equal(t, cliOut, result.StagedDir)
_, err = os.Stat(filepath.Join(cliOut, "go.mod"))
@@ -451,9 +452,9 @@ func TestPublishPackageDestRemovesOldCLI(t *testing.T) {
cliDir := filepath.Join(home, "library", "test-pp-cli")
writePublishableTestCLI(t, cliDir)
- // Create a dest with an existing CLI in a different category
+ // Create a dest with an existing CLI in a different category (slug-keyed)
destDir := filepath.Join(t.TempDir(), "publish-repo")
- oldCLIDir := filepath.Join(destDir, "library", "productivity", "test-pp-cli")
+ oldCLIDir := filepath.Join(destDir, "library", "productivity", "test")
require.NoError(t, os.MkdirAll(oldCLIDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(oldCLIDir, "old-file.go"), []byte("old"), 0o644))
@@ -472,8 +473,8 @@ func TestPublishPackageDestRemovesOldCLI(t *testing.T) {
_, err = os.Stat(oldCLIDir + ".old")
assert.ErrorIs(t, err, os.ErrNotExist, "stash dir should be cleaned up after success")
- // New CLI should exist at new category
- newCLIDir := filepath.Join(destDir, "library", "other", "test-pp-cli")
+ // New CLI should exist at new category (slug-keyed)
+ newCLIDir := filepath.Join(destDir, "library", "other", "test")
_, err = os.Stat(filepath.Join(newCLIDir, "go.mod"))
assert.NoError(t, err, "new CLI should exist at new category")
}
@@ -491,9 +492,9 @@ func TestPublishPackageDestRestoresOldCLIOnFailure(t *testing.T) {
require.NoError(t, os.Chmod(manuscriptFile, 0))
defer func() { _ = os.Chmod(manuscriptFile, 0o600) }()
- // Create dest with existing CLI in a different category
+ // Create dest with existing CLI in a different category (slug-keyed)
destDir := filepath.Join(t.TempDir(), "publish-repo")
- oldCLIDir := filepath.Join(destDir, "library", "productivity", "test-pp-cli")
+ oldCLIDir := filepath.Join(destDir, "library", "productivity", "test")
require.NoError(t, os.MkdirAll(oldCLIDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(oldCLIDir, "old-file.go"), []byte("old"), 0o644))
@@ -511,8 +512,8 @@ func TestPublishPackageDestRestoresOldCLIOnFailure(t *testing.T) {
_, err = os.Stat(oldCLIDir + ".old")
assert.ErrorIs(t, err, os.ErrNotExist, "stash dir should not remain after restore")
- // New CLI dir should be cleaned up
- newCLIDir := filepath.Join(destDir, "library", "other", "test-pp-cli")
+ // New CLI dir should be cleaned up (slug-keyed)
+ newCLIDir := filepath.Join(destDir, "library", "other", "test")
_, err = os.Stat(newCLIDir)
assert.ErrorIs(t, err, os.ErrNotExist, "failed new CLI dir should be cleaned up")
}
@@ -580,7 +581,7 @@ func main() {}
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.Equal(t, filepath.Join(root, naming.LibraryDirName(newName)), result.NewDir)
assert.Greater(t, result.FilesModified, 0)
}
@@ -609,7 +610,7 @@ func TestPublishRenameAPINameFallback(t *testing.T) {
assert.True(t, result.Success)
// Verify manifest has correct api_name from fallback
- newDir := filepath.Join(root, newName)
+ newDir := filepath.Join(root, naming.LibraryDirName(newName))
mData, err := os.ReadFile(filepath.Join(newDir, pipeline.CLIManifestFilename))
require.NoError(t, err)
var m pipeline.CLIManifest
diff --git a/internal/cli/root.go b/internal/cli/root.go
index ff283832..1e5d55fb 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -20,7 +20,6 @@ import (
"github.com/mvanhorn/cli-printing-press/internal/graphql"
"github.com/mvanhorn/cli-printing-press/internal/llm"
"github.com/mvanhorn/cli-printing-press/internal/llmpolish"
- "github.com/mvanhorn/cli-printing-press/internal/naming"
"github.com/mvanhorn/cli-printing-press/internal/openapi"
"github.com/mvanhorn/cli-printing-press/internal/pipeline"
"github.com/mvanhorn/cli-printing-press/internal/spec"
@@ -197,7 +196,7 @@ func newGenerateCmd() *cobra.Command {
fmt.Fprintf(os.Stderr, "warning: could not write manifest: %v\n", err)
}
- fmt.Fprintf(os.Stderr, "Generated %s at %s (from docs)\n", naming.CLI(parsed.Name), absOut)
+ fmt.Fprintf(os.Stderr, "Generated %s at %s (from docs)\n", parsed.Name, absOut)
if asJSON {
if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
"name": parsed.Name,
@@ -326,7 +325,7 @@ func newGenerateCmd() *cobra.Command {
// This prevents mismatches when the caller passes a directory name
// that doesn't match what the generator derives from the spec title
// (e.g., --output .../calcom-pp-cli but spec title "Cal.com" derives "cal-com-pp-cli").
- derivedDir := naming.CLI(apiSpec.Name)
+ derivedDir := apiSpec.Name
currentBase := filepath.Base(absOut)
if currentBase != derivedDir {
finalPath := filepath.Join(filepath.Dir(absOut), derivedDir)
@@ -360,7 +359,7 @@ func newGenerateCmd() *cobra.Command {
}
}
- fmt.Fprintf(os.Stderr, "Generated %s at %s\n", naming.CLI(apiSpec.Name), absOut)
+ fmt.Fprintf(os.Stderr, "Generated %s at %s\n", apiSpec.Name, absOut)
if asJSON {
if err := json.NewEncoder(os.Stdout).Encode(map[string]interface{}{
"name": apiSpec.Name,
@@ -378,10 +377,10 @@ func newGenerateCmd() *cobra.Command {
cmd.Flags().StringSliceVar(&specFiles, "spec", nil, "Path or URL to API spec (can be repeated)")
cmd.Flags().StringVar(&cliName, "name", "", "CLI name (required when using multiple specs)")
- cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: ~/printing-press/library/<name>-pp-cli)")
+ cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: ~/printing-press/library/<name>)")
cmd.Flags().BoolVar(&validate, "validate", true, "Run quality gates on the generated project")
cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh cached remote spec before generating")
- cmd.Flags().BoolVar(&force, "force", false, "Overwrite the base output directory (e.g. ~/printing-press/library/notion-pp-cli) instead of auto-incrementing")
+ cmd.Flags().BoolVar(&force, "force", false, "Overwrite the base output directory (e.g. ~/printing-press/library/notion) instead of auto-incrementing")
cmd.Flags().BoolVar(&lenient, "lenient", false, "Skip validation errors from broken $refs in OpenAPI specs")
cmd.Flags().StringVar(&docsURL, "docs", "", "API documentation URL to generate spec from")
cmd.Flags().BoolVar(&polish, "polish", false, "Run LLM polish pass on generated CLI (requires claude or codex CLI)")
diff --git a/internal/megamcp/metatools.go b/internal/megamcp/metatools.go
index 80e8c626..2d1d4ce5 100644
--- a/internal/megamcp/metatools.go
+++ b/internal/megamcp/metatools.go
@@ -165,21 +165,11 @@ func countPublicTools(m *ToolsManifest) int {
// indicating the user could use a dedicated MCP server with full features.
func checkUpgradeAvailable(slug string) bool {
libraryRoot := pipeline.PublishedLibraryRoot()
-
- // Check both directory layouts:
- // 1. ~/printing-press/library/{slug}-pp-cli/cmd/{slug}-pp-mcp/
- // 2. ~/printing-press/library/{slug}/cmd/{slug}-pp-mcp/
mcpBinary := naming.MCP(slug)
- paths := []string{
- filepath.Join(libraryRoot, naming.CLI(slug), "cmd", mcpBinary),
- filepath.Join(libraryRoot, slug, "cmd", mcpBinary),
- }
-
- for _, p := range paths {
- if info, err := os.Stat(p); err == nil && info.IsDir() {
- return true
- }
+ p := filepath.Join(libraryRoot, slug, "cmd", mcpBinary)
+ if info, err := os.Stat(p); err == nil && info.IsDir() {
+ return true
}
return false
}
diff --git a/internal/naming/naming.go b/internal/naming/naming.go
index 1c342e6b..8cb7dbbf 100644
--- a/internal/naming/naming.go
+++ b/internal/naming/naming.go
@@ -1,6 +1,9 @@
package naming
-import "strings"
+import (
+ "regexp"
+ "strings"
+)
const (
CurrentCLISuffix = "-pp-cli"
@@ -46,6 +49,56 @@ func TrimCLISuffix(name string) string {
}
}
+// LibraryDirName maps a CLI-style name to the corresponding library directory
+// key while preserving rerun suffixes. Examples:
+// - "dub-pp-cli" -> "dub"
+// - "dub-pp-cli-2" -> "dub-2"
+// - "dub-2-pp-cli" -> "dub-2"
+//
+// Bare slug-keyed names are returned unchanged.
+func LibraryDirName(name string) string {
+ trimmed := trimNumericRunSuffix(name)
+
+ switch {
+ case strings.HasSuffix(trimmed, CurrentCLISuffix):
+ return strings.Replace(name, CurrentCLISuffix, "", 1)
+ case strings.HasSuffix(trimmed, LegacyCLISuffix):
+ return strings.Replace(name, LegacyCLISuffix, "", 1)
+ default:
+ return name
+ }
+}
+
+// slugRe matches the slug grammar: lowercase alphanumeric + hyphens, must start
+// with an alphanumeric character. Accepts rerun suffixes like "dub-2".
+var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
+
+// IsValidLibraryDirName returns true if name is a valid library directory name.
+// It accepts both legacy CLI directory names (e.g. "dub-pp-cli", "dub-pp-cli-2")
+// and slug-keyed names (e.g. "dub", "cal-com", "dub-2"). It rejects empty strings,
+// path separators, ".." components, and dotfiles. This is Layer 1 input validation;
+// callers that use the name in filepath.Join must still apply Layer 2 containment.
+func IsValidLibraryDirName(name string) bool {
+ if name == "" {
+ return false
+ }
+ if strings.HasPrefix(name, ".") {
+ return false
+ }
+ if strings.Contains(name, "/") || strings.Contains(name, string([]byte{0})) {
+ return false
+ }
+ if strings.Contains(name, "..") {
+ return false
+ }
+ // Accept legacy CLI directory names
+ if IsCLIDirName(name) {
+ return true
+ }
+ // Accept slug grammar
+ return slugRe.MatchString(name)
+}
+
func trimNumericRunSuffix(name string) string {
idx := strings.LastIndex(name, "-")
if idx == -1 {
diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go
index 43968fb1..4c242352 100644
--- a/internal/naming/naming_test.go
+++ b/internal/naming/naming_test.go
@@ -18,6 +18,23 @@ func TestTrimCLISuffix(t *testing.T) {
}
}
+func TestLibraryDirName(t *testing.T) {
+ tests := map[string]string{
+ "notion-pp-cli": "notion",
+ "notion-pp-cli-2": "notion-2",
+ "notion-2-pp-cli": "notion-2",
+ "legacy-cli": "legacy",
+ "legacy-cli-4": "legacy-4",
+ "plain": "plain",
+ }
+
+ for input, want := range tests {
+ if got := LibraryDirName(input); got != want {
+ t.Fatalf("LibraryDirName(%q) = %q, want %q", input, got, want)
+ }
+ }
+}
+
func TestMCP(t *testing.T) {
tests := map[string]string{
"stripe": "stripe-pp-mcp",
@@ -39,3 +56,51 @@ func TestIsCLIDirName(t *testing.T) {
t.Fatal("mcp directories must not be treated as cli directories")
}
}
+
+func TestIsValidLibraryDirName(t *testing.T) {
+ tests := []struct {
+ name string
+ want bool
+ }{
+ // Slug-keyed names
+ {"dub", true},
+ {"cal-com", true},
+ {"dub-2", true},
+ {"steam-web", true},
+ {"a", true},
+ {"a1", true},
+ {"1password", true},
+
+ // Legacy CLI directory names
+ {"dub-pp-cli", true},
+ {"dub-pp-cli-2", true},
+ {"notion-pp-cli", true},
+ {"legacy-cli", true},
+
+ // Invalid names
+ {"", false},
+ {"../etc", false},
+ {".DS_Store", false},
+ {".hidden", false},
+ {"foo/bar", false},
+ {"-leading-hyphen", false},
+ {"UPPERCASE", false},
+ {"has space", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := IsValidLibraryDirName(tt.name)
+ if got != tt.want {
+ t.Errorf("IsValidLibraryDirName(%q) = %v, want %v", tt.name, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTrimCLISuffixBareSlug(t *testing.T) {
+ // Lock in that TrimCLISuffix returns bare slugs unchanged.
+ if got := TrimCLISuffix("dub"); got != "dub" {
+ t.Fatalf("TrimCLISuffix(%q) = %q, want %q", "dub", got, "dub")
+ }
+}
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 15619747..61bac45a 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -26,26 +26,31 @@ const CLIManifestFilename = ".printing-press.json"
// It is written to the root of each published CLI directory so the
// folder is self-describing even in isolation.
type CLIManifest struct {
- SchemaVersion int `json:"schema_version"`
- GeneratedAt time.Time `json:"generated_at"`
- PrintingPressVersion string `json:"printing_press_version"`
- APIName string `json:"api_name"`
- CLIName string `json:"cli_name"`
- SpecURL string `json:"spec_url,omitempty"`
- SpecPath string `json:"spec_path,omitempty"`
- SpecFormat string `json:"spec_format,omitempty"`
- SpecChecksum string `json:"spec_checksum,omitempty"`
- RunID string `json:"run_id,omitempty"`
- CatalogEntry string `json:"catalog_entry,omitempty"`
- Category string `json:"category,omitempty"`
- Description string `json:"description,omitempty"`
- MCPBinary string `json:"mcp_binary,omitempty"`
- MCPToolCount int `json:"mcp_tool_count,omitempty"`
- MCPPublicToolCount int `json:"mcp_public_tool_count,omitempty"`
- MCPReady string `json:"mcp_ready,omitempty"`
- AuthType string `json:"auth_type,omitempty"`
- AuthEnvVars []string `json:"auth_env_vars,omitempty"`
- NovelFeatures []NovelFeatureManifest `json:"novel_features,omitempty"`
+ SchemaVersion int `json:"schema_version"`
+ GeneratedAt time.Time `json:"generated_at"`
+ PrintingPressVersion string `json:"printing_press_version"`
+ // APIName is the canonical API identity (for example "espn" or "notion").
+ // It is not the executable name, and for collision-renamed published copies
+ // it may differ from the package directory key.
+ APIName string `json:"api_name"`
+ // CLIName is the executable/binary name (for example "espn-pp-cli").
+ // It does not track the slug-keyed library directory.
+ CLIName string `json:"cli_name"`
+ SpecURL string `json:"spec_url,omitempty"`
+ SpecPath string `json:"spec_path,omitempty"`
+ SpecFormat string `json:"spec_format,omitempty"`
+ SpecChecksum string `json:"spec_checksum,omitempty"`
+ RunID string `json:"run_id,omitempty"`
+ CatalogEntry string `json:"catalog_entry,omitempty"`
+ Category string `json:"category,omitempty"`
+ Description string `json:"description,omitempty"`
+ MCPBinary string `json:"mcp_binary,omitempty"`
+ MCPToolCount int `json:"mcp_tool_count,omitempty"`
+ MCPPublicToolCount int `json:"mcp_public_tool_count,omitempty"`
+ MCPReady string `json:"mcp_ready,omitempty"`
+ AuthType string `json:"auth_type,omitempty"`
+ AuthEnvVars []string `json:"auth_env_vars,omitempty"`
+ NovelFeatures []NovelFeatureManifest `json:"novel_features,omitempty"`
}
// NovelFeatureManifest is a compact representation of a transcendence feature
diff --git a/internal/pipeline/contracts_test.go b/internal/pipeline/contracts_test.go
index 26f4b6d6..9e252c6c 100644
--- a/internal/pipeline/contracts_test.go
+++ b/internal/pipeline/contracts_test.go
@@ -110,14 +110,14 @@ func TestPrintingPressSkillUsesRunstateForBuilds(t *testing.T) {
// Lock promote should appear in Phase 5.5.
assert.Contains(t, skill, `printing-press lock promote --cli <api>-pp-cli --dir "$CLI_WORK_DIR"`)
- // Phase 6 should still reference $PRESS_LIBRARY (reads from promoted location).
- assert.Contains(t, skill, `$PRESS_LIBRARY/<api>-pp-cli`)
+ // Phase 6 should still reference $PRESS_LIBRARY (reads from promoted location, slug-keyed).
+ assert.Contains(t, skill, `$PRESS_LIBRARY/<api>`)
}
func TestPrintingPressSkillExamplesUseCurrentCLINaming(t *testing.T) {
skill := readContractFile(t, filepath.Join("..", "..", "skills", "printing-press", "SKILL.md"))
- assert.Contains(t, skill, "/printing-press emboss notion-pp-cli")
+ assert.Contains(t, skill, "/printing-press emboss notion")
assert.NotContains(t, skill, "/printing-press emboss notion-cli")
assert.Contains(t, skill, "discord-pp-cli/internal/store/store.go")
assert.NotContains(t, skill, "discord-cli/internal/store/store.go")
@@ -141,7 +141,7 @@ func TestREADMEOutputContract(t *testing.T) {
readme := readContractFile(t, filepath.Join("..", "..", "README.md"))
assert.Contains(t, readme, "~/printing-press/.runstate/<scope>/runs/<run-id>/working/<api>-pp-cli")
- assert.Contains(t, readme, "~/printing-press/library/<api>-pp-cli")
+ assert.Contains(t, readme, "~/printing-press/library/<api>")
assert.Contains(t, readme, "~/printing-press/manuscripts/<api>/<run-id>/")
assert.Contains(t, readme, "`research/`, `proofs/`, `discovery/`, and `pipeline/`")
assert.NotContains(t, readme, "cd ~/cli-printing-press")
@@ -150,8 +150,8 @@ func TestREADMEOutputContract(t *testing.T) {
func TestGenerateHelpMentionsPublishedLibraryDefault(t *testing.T) {
root := readContractFile(t, filepath.Join("..", "..", "internal", "cli", "root.go"))
- assert.Contains(t, root, "Output directory (default: ~/printing-press/library/<name>-pp-cli)")
- assert.Contains(t, root, "Overwrite the base output directory (e.g. ~/printing-press/library/notion-pp-cli)")
+ assert.Contains(t, root, "Output directory (default: ~/printing-press/library/<name>)")
+ assert.Contains(t, root, "Overwrite the base output directory (e.g. ~/printing-press/library/notion)")
assert.NotContains(t, root, "~/printing-press/workspaces/<scope>/library")
}
@@ -161,7 +161,7 @@ func TestOnboardingReflectsCurrentPipelinePhaseCount(t *testing.T) {
assert.Contains(t, onboarding, "9-phase pipeline")
assert.Contains(t, onboarding, "agent-readiness")
assert.Contains(t, onboarding, "~/printing-press/.runstate/<scope>/runs/<run-id>/")
- assert.Contains(t, onboarding, "~/printing-press/library/<name>-pp-cli/")
+ assert.Contains(t, onboarding, "~/printing-press/library/<name>/")
assert.Contains(t, onboarding, "~/printing-press/manuscripts/<api>/<run-id>/")
assert.NotContains(t, onboarding, "8-phase pipeline")
}
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
index 00f38d8b..ca47604b 100644
--- a/internal/pipeline/lock.go
+++ b/internal/pipeline/lock.go
@@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"time"
+
+ "github.com/mvanhorn/cli-printing-press/internal/naming"
)
const (
@@ -129,8 +131,9 @@ func UpdateLock(cliName, phase string) error {
func LockStatus(cliName string) LockStatusResult {
result := LockStatusResult{}
- // Check library for completed CLI.
- libDir := filepath.Join(PublishedLibraryRoot(), cliName)
+ // Check library for completed CLI (slug-keyed directory).
+ slug := naming.TrimCLISuffix(cliName)
+ libDir := filepath.Join(PublishedLibraryRoot(), slug)
if info, err := os.Stat(libDir); err == nil && info.IsDir() {
goModPath := filepath.Join(libDir, "go.mod")
manifestPath := filepath.Join(libDir, CLIManifestFilename)
@@ -184,7 +187,8 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
return fmt.Errorf("working directory is empty: %s", workingDir)
}
- libraryDir := filepath.Join(PublishedLibraryRoot(), cliName)
+ slug := naming.TrimCLISuffix(cliName)
+ libraryDir := filepath.Join(PublishedLibraryRoot(), slug)
stagingDir := libraryDir + ".promoting"
backupDir := libraryDir + ".old"
diff --git a/internal/pipeline/lock_test.go b/internal/pipeline/lock_test.go
index be9cfb0f..d6f5f92e 100644
--- a/internal/pipeline/lock_test.go
+++ b/internal/pipeline/lock_test.go
@@ -156,8 +156,8 @@ func TestLockStatus_NoLock(t *testing.T) {
func TestLockStatus_NoLockWithLibraryCLI(t *testing.T) {
setupLockTest(t)
- // Create library dir with go.mod.
- libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ // Create library dir with go.mod (slug-keyed directory).
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
require.NoError(t, os.MkdirAll(libDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(libDir, "go.mod"), []byte("module test"), 0o644))
@@ -169,8 +169,8 @@ func TestLockStatus_NoLockWithLibraryCLI(t *testing.T) {
func TestLockStatus_NoLockLibraryDirNoGoMod(t *testing.T) {
setupLockTest(t)
- // Create library dir without go.mod (debris).
- libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ // Create library dir without go.mod (debris), slug-keyed.
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
require.NoError(t, os.MkdirAll(libDir, 0o755))
status := LockStatus("test-pp-cli")
@@ -181,8 +181,8 @@ func TestLockStatus_NoLockLibraryDirNoGoMod(t *testing.T) {
func TestLockStatus_NoLockLibraryDirWithManifest(t *testing.T) {
setupLockTest(t)
- // Create library dir with manifest but no go.mod.
- libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ // Create library dir with manifest but no go.mod (slug-keyed).
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
require.NoError(t, os.MkdirAll(libDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(libDir, CLIManifestFilename), []byte("{}"), 0o644))
@@ -233,8 +233,8 @@ func TestPromoteWorkingCLI(t *testing.T) {
err = PromoteWorkingCLI("test-pp-cli", workDir, state)
require.NoError(t, err)
- // Verify library dir exists with copied content.
- libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ // Verify library dir exists with copied content (slug-keyed).
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
_, err = os.Stat(filepath.Join(libDir, "go.mod"))
assert.NoError(t, err)
_, err = os.Stat(filepath.Join(libDir, "main.go"))
@@ -255,8 +255,8 @@ func TestPromoteWorkingCLI_ReplacesExistingLibrary(t *testing.T) {
t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
- // Create existing library dir with old content.
- libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ // Create existing library dir with old content (slug-keyed).
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
require.NoError(t, os.MkdirAll(libDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(libDir, "old-file.txt"), []byte("old"), 0o644))
@@ -305,8 +305,8 @@ func TestPromoteWorkingCLI_PreservesOldOnFailure(t *testing.T) {
t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
- // Create existing library dir with old content.
- libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ // Create existing library dir with old content (slug-keyed).
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
require.NoError(t, os.MkdirAll(libDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(libDir, "go.mod"), []byte("module old\n\ngo 1.21\n"), 0o644))
@@ -328,7 +328,7 @@ func TestPromoteWorkingCLI_RetryRestoresBackupBeforeFailure(t *testing.T) {
t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
- libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
backupDir := libDir + ".old"
stagingDir := libDir + ".promoting"
@@ -384,7 +384,7 @@ func TestPromoteWorkingCLI_ReleasesLockWhenStateSaveFails(t *testing.T) {
assert.Contains(t, err.Error(), "cli promoted to")
assert.Contains(t, err.Error(), "state update failed")
- libDir := filepath.Join(PublishedLibraryRoot(), "test-pp-cli")
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
_, err = os.Stat(filepath.Join(libDir, "go.mod"))
assert.NoError(t, err)
_, err = os.Stat(filepath.Join(libDir, "main.go"))
diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go
index 94badda4..56706c76 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -7,14 +7,12 @@ import (
"path/filepath"
"strconv"
"time"
-
- "github.com/mvanhorn/cli-printing-press/internal/naming"
)
// DefaultOutputDir returns the default output directory for a given API name.
// All commands should use this when --output is not specified.
func DefaultOutputDir(apiName string) string {
- return filepath.Join(PublishedLibraryRoot(), naming.CLI(apiName))
+ return filepath.Join(PublishedLibraryRoot(), apiName)
}
// ClaimOutputDir atomically claims an output directory. If base already exists,
diff --git a/internal/pipeline/renamecli.go b/internal/pipeline/renamecli.go
index 0851fbf2..5a069ac9 100644
--- a/internal/pipeline/renamecli.go
+++ b/internal/pipeline/renamecli.go
@@ -16,8 +16,8 @@ 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/
+// - Filesystem: outer directory rename to the slug-keyed directory derived
+// from 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
@@ -40,7 +40,7 @@ func RenameCLI(dir, oldCLIName, newCLIName, originalAPIName string) (int, error)
return 0, fmt.Errorf("resolving directory: %w", err)
}
parent := filepath.Dir(absDir)
- newDir := filepath.Join(parent, newCLIName)
+ newDir := filepath.Join(parent, naming.LibraryDirName(newCLIName))
absNew, err := filepath.Abs(newDir)
if err != nil {
return 0, fmt.Errorf("resolving new directory: %w", err)
@@ -50,8 +50,11 @@ func RenameCLI(dir, oldCLIName, newCLIName, originalAPIName string) (int, error)
}
// 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)
+ // After slug-keyed directories, the dir base may be the slug (e.g., "dub")
+ // while oldCLIName is "dub-pp-cli". Accept either.
+ dirBase := filepath.Base(absDir)
+ if dirBase != oldCLIName && dirBase != naming.LibraryDirName(oldCLIName) {
+ return 0, fmt.Errorf("directory base %q does not match old CLI name %q", dirBase, oldCLIName)
}
filesModified := 0
diff --git a/internal/pipeline/renamecli_test.go b/internal/pipeline/renamecli_test.go
index 9843d11b..73659a58 100644
--- a/internal/pipeline/renamecli_test.go
+++ b/internal/pipeline/renamecli_test.go
@@ -155,8 +155,8 @@ func TestRenameCLI(t *testing.T) {
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)
+ // Outer directory should stay slug-keyed.
+ newDir := filepath.Join(root, naming.LibraryDirName(newName))
_, err = os.Stat(newDir)
assert.NoError(t, err, "new directory should exist")
_, err = os.Stat(cliDir)
@@ -237,7 +237,7 @@ func TestRenameCLI(t *testing.T) {
require.NoError(t, err)
assert.Greater(t, filesModified, 0)
- newDir := filepath.Join(root, newName)
+ newDir := filepath.Join(root, naming.LibraryDirName(newName))
rootGo, err := os.ReadFile(filepath.Join(newDir, "internal", "cli", "root.go"))
require.NoError(t, err)
assert.Contains(t, string(rootGo), `Use: "`+newName+`"`)
@@ -257,7 +257,7 @@ func TestRenameCLI(t *testing.T) {
_, err := RenameCLI(cliDir, oldName, newName, apiName)
require.NoError(t, err)
- newDir := filepath.Join(root, newName)
+ newDir := filepath.Join(root, naming.LibraryDirName(newName))
briefPath := filepath.Join(newDir, ".manuscripts", "20260329-100000", "research", "brief.md")
brief, err := os.ReadFile(briefPath)
require.NoError(t, err)
@@ -279,7 +279,7 @@ func TestRenameCLI(t *testing.T) {
_, err := RenameCLI(cliDir, oldName, newName, apiName)
require.NoError(t, err)
- newDir := filepath.Join(root, newName)
+ newDir := filepath.Join(root, naming.LibraryDirName(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)
@@ -307,7 +307,7 @@ func main() {}
_, err := RenameCLI(cliDir, oldName, newName, apiName)
require.NoError(t, err)
- newDir := filepath.Join(root, newName)
+ newDir := filepath.Join(root, naming.LibraryDirName(newName))
_, err = os.Stat(newDir)
assert.NoError(t, err, "directory should still be renamed")
})
@@ -352,6 +352,49 @@ func main() {}
assert.Contains(t, err.Error(), "does not match")
})
+ t.Run("works when dir base is slug but old-name is CLI name", func(t *testing.T) {
+ root := t.TempDir()
+ oldName := "dub-pp-cli"
+ newName := "dub-alt-pp-cli"
+ apiName := "dub"
+
+ // Directory is slug-keyed ("dub"), not CLI-name-keyed ("dub-pp-cli")
+ cliDir := filepath.Join(root, apiName)
+ 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 stay slug-keyed after rename.
+ newDir := filepath.Join(root, naming.LibraryDirName(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/ subdirectories 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")
+
+ // File contents should have new name
+ 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)
+
+ // 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("skips non-target file extensions", func(t *testing.T) {
root := t.TempDir()
oldName := "test-pp-cli"
@@ -371,7 +414,7 @@ func main() {}
_, err := RenameCLI(cliDir, oldName, newName, "test")
require.NoError(t, err)
- newDir := filepath.Join(root, newName)
+ newDir := filepath.Join(root, naming.LibraryDirName(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)
diff --git a/internal/pipeline/state_test.go b/internal/pipeline/state_test.go
index 750bd624..d470f8c4 100644
--- a/internal/pipeline/state_test.go
+++ b/internal/pipeline/state_test.go
@@ -131,8 +131,11 @@ func TestDefaultOutputDir(t *testing.T) {
apiName string
expected string
}{
- {"simple", "stripe", filepath.Join(home, "library", "stripe-pp-cli")},
- {"hyphenated", "my-api", filepath.Join(home, "library", "my-api-pp-cli")},
+ {"simple", "stripe", filepath.Join(home, "library", "stripe")},
+ {"hyphenated", "my-api", filepath.Join(home, "library", "my-api")},
+ {"slug dub", "dub", filepath.Join(home, "library", "dub")},
+ {"slug cal-com", "cal-com", filepath.Join(home, "library", "cal-com")},
+ {"slug steam-web", "steam-web", filepath.Join(home, "library", "steam-web")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff --git a/skills/printing-press-polish/SKILL.md b/skills/printing-press-polish/SKILL.md
index 6ff53289..8eced115 100644
--- a/skills/printing-press-polish/SKILL.md
+++ b/skills/printing-press-polish/SKILL.md
@@ -30,7 +30,7 @@ checks locks, dispatches the agent, and offers to publish.
```bash
/printing-press-polish redfin
/printing-press-polish redfin-pp-cli
-/printing-press-polish ~/printing-press/library/redfin-pp-cli
+/printing-press-polish ~/printing-press/library/redfin
```
## When to run
@@ -53,14 +53,14 @@ PRESS_LIBRARY="$PRESS_HOME/library"
### Resolve CLI
The argument can be:
-- A short name: `redfin` (searches for `redfin-pp-cli` in `$PRESS_LIBRARY`)
-- A full name: `redfin-pp-cli` (looks up `$PRESS_LIBRARY/redfin-pp-cli`)
-- A path: `~/printing-press/library/redfin-pp-cli` (used directly)
+- A short name: `redfin` (looks up `$PRESS_LIBRARY/redfin`)
+- A full name: `redfin-pp-cli` (strips suffix, looks up `$PRESS_LIBRARY/redfin`)
+- A path: `~/printing-press/library/redfin` (used directly)
Resolution order:
1. If the argument is an absolute or `~`-prefixed path and exists, use it
-2. Try `$PRESS_LIBRARY/<arg>` (exact match)
-3. Try `$PRESS_LIBRARY/<arg>-pp-cli` (append suffix)
+2. Try `$PRESS_LIBRARY/<arg>` (exact match — works for slug like `redfin`)
+3. If arg has `-pp-cli` suffix, strip it and try `$PRESS_LIBRARY/<slug>` (e.g., `redfin-pp-cli` → `redfin`)
4. Fuzzy search: `ls $PRESS_LIBRARY/ | grep -i <arg>` for close matches
If no match or multiple matches, present via `AskUserQuestion`. Show at most 4
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index ca445d97..8b2d1e61 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -90,7 +90,7 @@ PUBLISH_CONFIG="$PRESS_HOME/.publish-config.json"
}
```
-The `module_path_base` field sets the Go module path prefix for published CLIs. During packaging, the full module path is constructed as `<module_path_base>/<category>/<cli-name>`. If the user wants CLIs published to a different repo or path, they edit this field.
+The `module_path_base` field sets the Go module path prefix for published CLIs. During packaging, the full module path is constructed as `<module_path_base>/<category>/<api-slug>`. If the user wants CLIs published to a different repo or path, they edit this field.
## Step 1: Prerequisites
@@ -102,7 +102,7 @@ gh auth status
If this fails, stop and tell the user: "GitHub CLI is not authenticated. Run `gh auth login` first."
-## Step 2: Resolve CLI Name
+## Step 2: Resolve API Slug
Run:
@@ -110,17 +110,20 @@ Run:
printing-press library list --json
```
-Parse the JSON output into a list of CLIs.
+Parse the JSON output into a list of CLIs. The library is now keyed by API slug (the directory name), not CLI name.
**Name resolution order** (matches the score skill for consistency):
-1. **Exact match:** If the argument matches a `cli_name` exactly, use it
-2. **Suffix match:** If no exact match, try `<argument>-pp-cli`
-3. **Glob match:** If no suffix match, search for entries where `cli_name` contains the argument as a substring. Cap at 5 most-recent matches. If multiple matches, present them via AskUserQuestion and let the user pick
-4. **No match:** List all available CLIs and ask the user to pick or re-enter
-5. **No argument:** If invoked with no name, list all CLIs sorted by modification time and let the user pick
+1. **Exact match:** If the argument matches a directory name (API slug) exactly, use it
+2. **CLI name match:** If no exact match, try matching against `cli_name` fields, then derive the API slug from the manifest's `api_name` field
+3. **Suffix match:** If no match yet, try `<argument>-pp-cli` against `cli_name` fields
+4. **Glob match:** If no suffix match, search for entries where `cli_name` or `api_name` contains the argument as a substring. Cap at 5 most-recent matches. If multiple matches, present them via AskUserQuestion and let the user pick
+5. **No match:** List all available CLIs and ask the user to pick or re-enter
+6. **No argument:** If invoked with no name, list all CLIs sorted by modification time and let the user pick
-When presenting matches, show the CLI name and modification time in a human-friendly format (e.g., "2 hours ago", "3 days ago").
+Once resolved, read the manifest's `api_name` field to get the API slug. Use this slug for all downstream operations (branch names, registry entries, collision detection, path construction). The `cli_name` from the manifest is only used for binary-level operations.
+
+When presenting matches, show the API slug and modification time in a human-friendly format (e.g., "2 hours ago", "3 days ago").
## Step 3: Determine Category
@@ -154,7 +157,7 @@ printing-press publish validate --dir <cli-dir> --json
Parse the JSON result. Display each check result to the user:
```
-Validating <cli-name>...
+Validating <api-slug>...
manifest PASS
go mod tidy PASS
go vet PASS
@@ -307,13 +310,13 @@ If reset, run `git checkout -- . && git clean -fd`.
## Step 6: Package
-Read `$PUBLISH_CONFIG` to get `module_path_base`. Construct the full module path:
+Read `$PUBLISH_CONFIG` to get `module_path_base`. Construct the full module path using the API slug (not the CLI name):
```
-MODULE_PATH="<module_path_base>/<category>/<cli-name>"
+MODULE_PATH="<module_path_base>/<category>/<api-slug>"
```
-For example: `github.com/mvanhorn/printing-press-library/library/productivity/notion-pp-cli`
+For example: `github.com/mvanhorn/printing-press-library/library/productivity/notion`
Run `publish package` with `--dest` to write directly into the publish repo:
@@ -326,7 +329,7 @@ printing-press publish package \
--json
```
-This removes any existing version of the CLI (handling category changes), copies the CLI source and `.manuscripts` directly into `$PUBLISH_REPO_DIR/library/<category>/<cli-name>/`, and rewrites the Go module path.
+This removes any existing version of the CLI (handling category changes), copies the CLI source and `.manuscripts` directly into `$PUBLISH_REPO_DIR/library/<category>/<api-slug>/`, and rewrites the Go module path.
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.
@@ -341,7 +344,7 @@ Run these checks in sequence:
**1. Check merged CLIs in managed clone:**
```bash
-ls "$PUBLISH_REPO_DIR/library"/*/"<cli-name>" 2>/dev/null
+ls "$PUBLISH_REPO_DIR/library"/*/"<api-slug>" 2>/dev/null
```
If found, record `MERGED_COLLISION=true` and note the category path.
@@ -349,7 +352,7 @@ 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
+gh pr list --repo mvanhorn/printing-press-library --head "feat/<api-slug>" --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.
@@ -365,9 +368,9 @@ ACCESS=$(jq -r .access "$PUBLISH_CONFIG")
GH_USER=$(jq -r .gh_user "$PUBLISH_CONFIG")
if [ "$ACCESS" = "fork" ]; then
- HEAD_REF="$GH_USER:feat/<cli-name>"
+ HEAD_REF="$GH_USER:feat/<api-slug>"
else
- HEAD_REF="feat/<cli-name>"
+ HEAD_REF="feat/<api-slug>"
fi
gh pr list --repo mvanhorn/printing-press-library --head "$HEAD_REF" --state open --author @me --json number,title,url
@@ -381,23 +384,23 @@ If found, record `OWN_PR=true`, store `EXISTING_PR_NUMBER` and `EXISTING_PR_URL`
MERGED_PR=$(gh pr list --repo mvanhorn/printing-press-library --head "$HEAD_REF" --state merged --author @me --json number --jq '.[0].number' 2>/dev/null)
```
-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 `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/<api-slug>-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.
### 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."
+> "Found your open PR #N for `<api-slug>`. Will update it with the new version."
### Collision detected — display info
Show the user what was found:
```
-⚠️ Name collision detected for <cli-name>
+⚠️ Name collision detected for <api-slug>
- Merged: <category>/<cli-name> exists in the library
+ Merged: <category>/<api-slug> exists in the library
Open PR: #<number> by <author> — <url>
```
@@ -424,36 +427,43 @@ This is the existing update flow. Set `EXISTING_PR_NUMBER` from the detection st
#### Replace path
**For merged CLIs or your own PR:** Standard confirmation:
-> "This will replace the existing `<cli-name>`. Continue?"
+> "This will replace the existing `<api-slug>`. Continue?"
**For another user's PR:** Stronger confirmation naming the other author:
-> "⚠️ This will replace `<author>`'s `<cli-name>` (PR #N). Are you sure?"
+> "⚠️ This will replace `<author>`'s `<api-slug>` (PR #N). Are you sure?"
If confirmed:
-- The PR description must include: `⚠️ **Replaces existing \`<cli-name>\`** — <reason provided by user or "newer version">`
+- The PR description must include: `⚠️ **Replaces existing \`<api-slug>\`** — <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):
+**1. Extract the original API slug** from the manifest's `api_name` field:
```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')
+ORIGINAL_API_SLUG=$(cat "$PUBLISH_REPO_DIR/library/<category>/<api-slug>/.printing-press.json" | jq -r '.api_name')
```
-**2. Generate rename suggestions:**
+**2. Generate rename suggestions** using slug format. Derive the new CLI name from the chosen slug:
-- Numeric: `<api-slug>-2-pp-cli` (if that collides, try `-3`, `-4`, etc.)
-- Non-numeric: `<api-slug>-alt-pp-cli`
+- Numeric: `<api-slug>-2` (if that collides, try `-3`, `-4`, etc.)
+- Non-numeric: `<api-slug>-alt`
- Custom: prompt the user for a qualifier word
+After the user chooses a slug, compute:
+
+```bash
+NEW_API_SLUG="<chosen-slug>"
+NEW_CLI_NAME="${NEW_API_SLUG}-pp-cli"
+```
+
Present the format to the user:
-> "Rename format: `<api-slug>-<qualifier>-pp-cli`. Pick a qualifier:"
+> "Rename format: `<api-slug>-<qualifier>`. Pick a qualifier:"
>
-> 1. `2` → `<api-slug>-2-pp-cli`
-> 2. `alt` → `<api-slug>-alt-pp-cli`
+> 1. `2` → `<api-slug>-2`
+> 2. `alt` → `<api-slug>-alt`
> 3. Enter custom qualifier
**3. Verify each suggestion is non-colliding** before presenting:
@@ -469,25 +479,25 @@ 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:
+Since Step 6 wrote the CLI directly into `$PUBLISH_REPO_DIR` via `--dest`, the rename operates on that directory. Note: `--old-name`/`--new-name` still use CLI-name format (e.g., `dub-pp-cli`) because `RenameCLI` does content replacement — bare slugs would cause collateral damage. The `--dir` path uses the slug-keyed directory.
```bash
printing-press publish rename \
- --dir "$PUBLISH_REPO_DIR/library/<category>/<old-cli-name>" \
+ --dir "$PUBLISH_REPO_DIR/library/<category>/<api-slug>" \
--old-name <old-cli-name> \
- --new-name <new-cli-name> \
- --api-name "$API_SLUG" \
+ --new-name "$NEW_CLI_NAME" \
+ --api-name "$ORIGINAL_API_SLUG" \
--json
```
-Parse the JSON result. Verify `"success": true`. Note the `new_dir` path.
+Parse the JSON result. Verify `"success": true`. Note that `new_dir` should now be `$PUBLISH_REPO_DIR/library/<category>/$NEW_API_SLUG`.
**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>`
+- Branch name: `feat/$NEW_API_SLUG` (not the old slug)
+- PR title: `feat($NEW_API_SLUG): add $NEW_API_SLUG`
+- Commit message: `feat($NEW_API_SLUG): add $NEW_API_SLUG`
+- Registry.json entry: `name` → `$NEW_API_SLUG`
- Set `EXISTING_PR_NUMBER=""` (always a new PR for a renamed CLI)
Proceed to Step 8 with the new name.
@@ -495,7 +505,7 @@ 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 merged: "Existing CLI at `library/<category>/<api-slug>/`"
- 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.
@@ -509,7 +519,7 @@ Exit the publish flow. If Step 6 already wrote files into `$PUBLISH_REPO_DIR`, c
Always overwrite the branch — the intent is clearly to update:
```bash
-git checkout -B feat/<cli-name>
+git checkout -B feat/<api-slug>
```
**If `EXISTING_PR_NUMBER` is empty and `BRANCH_MERGED` is true** (previous PR was merged):
@@ -517,7 +527,7 @@ git checkout -B feat/<cli-name>
Auto-create a timestamped branch — do not reuse the merged branch name:
```bash
-git checkout -b feat/<cli-name>-$(date +%Y%m%d)
+git checkout -b feat/<api-slug>-$(date +%Y%m%d)
```
**If `EXISTING_PR_NUMBER` is empty and `BRANCH_MERGED` is not set** (no open or merged PR):
@@ -526,37 +536,37 @@ Check for stale branches and competing PRs:
```bash
# Check local and remote branches
-LOCAL_BRANCH=$(git branch --list "feat/<cli-name>" | head -1)
-REMOTE_BRANCH=$(git ls-remote --heads origin "feat/<cli-name>" 2>/dev/null | head -1)
+LOCAL_BRANCH=$(git branch --list "feat/<api-slug>" | head -1)
+REMOTE_BRANCH=$(git ls-remote --heads origin "feat/<api-slug>" 2>/dev/null | head -1)
# If a remote branch exists, check who owns it
if [ -n "$REMOTE_BRANCH" ]; then
# Check for ANY open PR on this branch (not just ours)
- OTHER_PR=$(gh pr list --repo mvanhorn/printing-press-library --head "feat/<cli-name>" --state open --json number,author --jq '.[0]' 2>/dev/null)
+ OTHER_PR=$(gh pr list --repo mvanhorn/printing-press-library --head "feat/<api-slug>" --state open --json number,author --jq '.[0]' 2>/dev/null)
fi
```
**If another user's open PR exists on this branch** (`OTHER_PR` is non-empty and author is not `@me`):
-> "Someone else has an open PR for `<cli-name>` (PR #N by @author). Creating a timestamped branch to avoid conflicts."
+> "Someone else has an open PR for `<api-slug>` (PR #N by @author). Creating a timestamped branch to avoid conflicts."
-Auto-create a timestamped branch: `feat/<cli-name>-YYYYMMDD`. Do NOT offer to overwrite — that would stomp their work.
+Auto-create a timestamped branch: `feat/<api-slug>-YYYYMMDD`. Do NOT offer to overwrite — that would stomp their work.
**If the branch exists but no competing PR** (stale branch from a previously closed/merged PR):
Ask via AskUserQuestion:
-> "Found a stale branch `feat/<cli-name>` (likely from a previous publish). Overwrite it?"
+> "Found a stale branch `feat/<api-slug>` (likely from a previous publish). Overwrite it?"
- "Overwrite existing branch" — reuse the branch name
-- "Create timestamped variant (feat/<cli-name>-YYYYMMDD)"
+- "Create timestamped variant (feat/<api-slug>-YYYYMMDD)"
**If no branch exists:** Create normally.
```bash
# New branch:
-git checkout -b feat/<cli-name>
+git checkout -b feat/<api-slug>
# Overwrite existing:
-git checkout -B feat/<cli-name>
+git checkout -B feat/<api-slug>
```
### Update registry.json
@@ -568,11 +578,11 @@ The registry file has this structure:
"schema_version": 1,
"entries": [
{
- "name": "<cli-name>",
+ "name": "<api-slug>",
"category": "<category>",
"api": "<api-display-name>",
"description": "<from manifest or README>",
- "path": "library/<category>/<cli-name>",
+ "path": "library/<category>/<api-slug>",
"mcp": {
"binary": "<name>-pp-mcp",
"transport": "stdio",
@@ -583,7 +593,7 @@ The registry file has this structure:
"mcp_ready": "<full|partial|cli-only>",
"manifest_checksum": "<sha256:hex of tools-manifest.json>",
"spec_format": "<openapi3|graphql|internal>",
- "manifest_url": "library/<category>/<cli-name>/tools-manifest.json"
+ "manifest_url": "library/<category>/<api-slug>/tools-manifest.json"
}
}
]
@@ -613,7 +623,7 @@ Write back with `jq` or via the Write tool.
```bash
cd "$PUBLISH_REPO_DIR"
git add library/ registry.json
-git commit -m "feat(<api-name>): add <cli-name>"
+git commit -m "feat(<api-slug>): add <api-slug>"
```
Push to origin (which is the fork for non-push users, or the upstream for push users):
@@ -621,26 +631,26 @@ Push to origin (which is the fork for non-push users, or the upstream for push u
**If updating an existing PR** (`EXISTING_PR_NUMBER` is set):
```bash
-git push --force-with-lease -u origin feat/<cli-name>
+git push --force-with-lease -u origin feat/<api-slug>
```
**If creating a new PR** and you chose "Overwrite existing branch" earlier:
```bash
-git push --force-with-lease -u origin feat/<cli-name>
+git push --force-with-lease -u origin feat/<api-slug>
```
**Otherwise** (new branch, no conflicts):
```bash
-git push -u origin feat/<cli-name>
+git push -u origin feat/<api-slug>
```
### Create or update PR
Read `access` and `gh_user` from `$PUBLISH_CONFIG`. These determine how `gh pr create` is called.
-**For fork-based PRs** (`access` is `fork`): use `--head <gh_user>:feat/<cli-name>` so GitHub creates a cross-repo PR from the fork to the upstream. Without `--head`, `gh pr create` would try to find the branch on the upstream repo (where the user can't push) and fail.
+**For fork-based PRs** (`access` is `fork`): use `--head <gh_user>:feat/<api-slug>` so GitHub creates a cross-repo PR from the fork to the upstream. Without `--head`, `gh pr create` would try to find the branch on the upstream repo (where the user can't push) and fail.
**For push-access PRs** (`access` is `push`): no `--head` needed — the branch is on the same repo.
@@ -655,9 +665,9 @@ Build the PR description from:
**PR description template:**
```markdown
-## <cli-name>
+## <api-slug>
-<If this is a Replace path, add: "⚠️ **Replaces existing `<cli-name>`** — <reason from user>">
+<If this is a Replace path, add: "⚠️ **Replaces existing `<api-slug>`** — <reason from user>">
<description from manifest, or "No description available">
@@ -677,8 +687,8 @@ $ <cli-name> --help
### Manuscripts
-- [Research Brief](<link to library/<category>/<cli-name>/.manuscripts/<run-id>/research/>)
-- [Shipcheck Results](<link to library/<category>/<cli-name>/.manuscripts/<run-id>/proofs/>)
+- [Research Brief](<link to library/<category>/<api-slug>/.manuscripts/<run-id>/research/>)
+- [Shipcheck Results](<link to library/<category>/<api-slug>/.manuscripts/<run-id>/proofs/>)
### Validation Results
@@ -720,15 +730,15 @@ GH_USER=$(jq -r .gh_user "$PUBLISH_CONFIG")
if [ "$ACCESS" = "fork" ]; then
gh pr create \
--repo mvanhorn/printing-press-library \
- --head "$GH_USER:feat/<cli-name>" \
+ --head "$GH_USER:feat/<api-slug>" \
--base main \
- --title "feat(<api-name>): add <cli-name>" \
+ --title "feat(<api-slug>): add <api-slug>" \
--body "<constructed PR body>"
else
gh pr create \
--repo mvanhorn/printing-press-library \
--base main \
- --title "feat(<api-name>): add <cli-name>" \
+ --title "feat(<api-slug>): add <api-slug>" \
--body "<constructed PR body>"
fi
```
@@ -790,4 +800,4 @@ flagged (e.g., a test fixture with a fake key). Don't block silently.
- **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>`
+- **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/<api-slug>`
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 53643844..ab0e6b1b 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -152,7 +152,7 @@ Print as prose (in Victorian voice):
> 3. I shall present what I found and what I invented — you will have a chance to add your own ideas or adjust the plan before I build
> 4. I shall generate a Go CLI, build every feature from the plan, then verify quality through dogfood, runtime verification, and scoring
>
-> **What you will have at the end:** A fully functional CLI at `~/printing-press/library/<api>-pp-cli` that you can use yourself, ship on your own, or apply to add to the printing-press library.
+> **What you will have at the end:** A fully functional CLI at `~/printing-press/library/<api>` that you can use yourself, ship on your own, or apply to add to the printing-press library.
>
> **Time:** 10-40 minutes depending on API complexity.
>
@@ -294,17 +294,19 @@ Maintain a lightweight state file at `$STATE_FILE` so `/printing-press-score` ca
}
```
-Active mutable work lives under `$PRESS_RUNSTATE/`. Published CLIs live under `$PRESS_LIBRARY/`. Archived research and verification evidence live under `$PRESS_MANUSCRIPTS/<cli-name>/<run-id>/` (keyed by CLI name, e.g., `steam-web-pp-cli`, not the API slug). Do not write mutable run artifacts into the repo checkout.
+Active mutable work lives under `$PRESS_RUNSTATE/`. Published CLIs live under `$PRESS_LIBRARY/<api-slug>/` (keyed by API slug, e.g., `steam-web`, not CLI name). Archived research and verification evidence live under `$PRESS_MANUSCRIPTS/<api-slug>/<run-id>/`. Do not write mutable run artifacts into the repo checkout.
-Examples of the current naming/layout to preserve:
-- `/printing-press emboss notion-pp-cli`
-- `discord-pp-cli/internal/store/store.go`
-- `linear-pp-cli stale --days 30 --team ENG`
-- `github.com/mvanhorn/discord-pp-cli`
+Examples of the current naming/layout:
+- `~/printing-press/library/notion/` — published CLI directory (keyed by API slug)
+- `notion-pp-cli` — the binary name inside the directory
+- `/printing-press emboss notion` — emboss accepts both slug and CLI name
+- `discord-pp-cli/internal/store/store.go` — internal source paths still use CLI name
+- `linear-pp-cli stale --days 30 --team ENG` — binary invocations use CLI name
+- `github.com/mvanhorn/discord-pp-cli` — Go module paths use CLI name
## Outputs
-Every run writes up to 5 concise artifacts under the current managed run and archives them to `$PRESS_MANUSCRIPTS/<cli-name>/<run-id>/`:
+Every run writes up to 5 concise artifacts under the current managed run and archives them to `$PRESS_MANUSCRIPTS/<api-slug>/<run-id>/`:
1. `research/<stamp>-feat-<api>-pp-cli-brief.md`
2. `research/<stamp>-feat-<api>-pp-cli-absorb-manifest.md`
@@ -353,7 +355,7 @@ Before new research:
- If the user passed `--spec`, use it directly (existing behavior).
- Otherwise, proceed with normal discovery (catalog, KnownSpecs, apis-guru, web search).
2. Check for prior research in:
- - `$PRESS_MANUSCRIPTS/<cli-name>/*/research/*` (also check `$PRESS_MANUSCRIPTS/<api>/*/research/*` for backwards compatibility)
+ - `$PRESS_MANUSCRIPTS/<api-slug>/*/research/*`
- `$REPO_ROOT/docs/plans/*<api>*` (legacy fallback)
3. Reuse good prior work instead of redoing it.
4. **Library Check** — Check if a CLI for this API already exists in the library or is actively being built, and present the user with context and options.
@@ -371,7 +373,7 @@ Before new research:
Then check the library directory:
```bash
- CLI_DIR="$PRESS_LIBRARY/<api>-pp-cli"
+ CLI_DIR="$PRESS_LIBRARY/<api>"
HAS_LIBRARY=false
HAS_GOMOD=false
if [ -d "$CLI_DIR" ]; then
@@ -398,7 +400,7 @@ Before new research:
| No | Yes | No | N/A | Warn: "Actively being built (phase: `<phase>`, `<age>` seconds ago). Wait, use a different name, or pick a different API." |
| No | Yes | Yes | N/A | Offer reclaim: "Interrupted build detected (stale since `<age>`s ago). Reclaim and start fresh?" |
| Yes | No | N/A | Yes | Existing "Found existing" flow (see below) |
- | Yes | No | N/A | No | Debris: "Found `<cli-name>` directory in library but it appears incomplete (no go.mod). Clean up and start fresh?" If user approves, `rm -rf "$CLI_DIR"` and proceed normally. |
+ | Yes | No | N/A | No | Debris: "Found `<api>` directory in library but it appears incomplete (no go.mod). Clean up and start fresh?" If user approves, `rm -rf "$CLI_DIR"` and proceed normally. |
| Yes | Yes | No | Any | Warn: "Actively being rebuilt (phase: `<phase>`, `<age>` seconds ago). Wait, use a different name, or pick a different API." |
| Yes | Yes | Yes | Any | Offer reclaim: "Interrupted rebuild detected (stale since `<age>`s ago). Reclaim and start fresh?" |
@@ -408,7 +410,7 @@ Before new research:
**If library exists with go.mod and no lock (completed CLI):** Display context and present options using `AskUserQuestion`:
- > Found existing `<cli-name>` in library (last modified `<date>`).
+ > Found existing `<api>` in library (last modified `<date>`).
If `PRESS_VERSION` is available, append: `Built with printing-press v<version>.`
@@ -420,7 +422,7 @@ Before new research:
3. **"Review prior research first"** — Show the full research brief and absorb manifest before deciding.
If the user picks option 1, proceed to Phase 1 (research) and then Phase 2 (generate) as normal.
- If the user picks option 2, invoke `/printing-press-polish <cli-name>` to improve the existing CLI.
+ If the user picks option 2, invoke `/printing-press-polish <api>` to improve the existing CLI.
If the user picks option 3, display the prior research, then re-present options 1 and 2.
If no CLI exists in the library and no lock is active, skip this step and proceed normally.
@@ -1388,7 +1390,7 @@ Write a structured acceptance report. This report is **required** — Phase 5.6
checks for it before promoting.
```
-Acceptance Report: <cli-name>
+Acceptance Report: <api>
Level: Quick Check / Full Dogfood
Tests: N/M passed
Failures:
@@ -1480,7 +1482,7 @@ If the shipcheck verdict is `ship` or `ship-with-gaps`, promote the verified CLI
printing-press lock promote --cli <api>-pp-cli --dir "$CLI_WORK_DIR"
```
-The `promote` command handles the full sequence: stages the working directory, atomically swaps it into `$PRESS_LIBRARY/<api>-pp-cli`, writes the `.printing-press.json` manifest, updates the `CurrentRunPointer`, and releases the lock — all in one step.
+The `promote` command handles the full sequence: stages the working directory, atomically swaps it into `$PRESS_LIBRARY/<api>` (slug-keyed), writes the `.printing-press.json` manifest, updates the `CurrentRunPointer`, and releases the lock — all in one step. The `--cli` flag accepts the CLI binary name; the Go code translates to the slug-keyed library path internally.
If the shipcheck verdict is `hold`, the lock was already released in Phase 4. Do NOT promote. The working copy stays in `$CLI_WORK_DIR` and is not copied to the library.
@@ -1496,13 +1498,12 @@ future `/printing-press` runs on the same API. Publishing ships the CLI to the
library repo. A run that isn't ready to publish still produces valuable research.
```bash
-# Archive under CLI name (e.g., steam-web-pp-cli), not API slug (e.g., steam).
-# The CLI name is unambiguous and matches what publish expects.
-CLI_NAME="$(basename "$PRESS_LIBRARY/<api>-pp-cli")"
-mkdir -p "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID"
-cp -r "$RESEARCH_DIR" "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID/research" 2>/dev/null || true
-cp -f "$API_RUN_DIR/research.json" "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID/research.json" 2>/dev/null || true
-cp -r "$PROOFS_DIR" "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID/proofs" 2>/dev/null || true
+# Archive under API slug (e.g., steam-web), matching the slug-keyed library layout.
+API_SLUG="<api>"
+mkdir -p "$PRESS_MANUSCRIPTS/$API_SLUG/$RUN_ID"
+cp -r "$RESEARCH_DIR" "$PRESS_MANUSCRIPTS/$API_SLUG/$RUN_ID/research" 2>/dev/null || true
+cp -f "$API_RUN_DIR/research.json" "$PRESS_MANUSCRIPTS/$API_SLUG/$RUN_ID/research.json" 2>/dev/null || true
+cp -r "$PROOFS_DIR" "$PRESS_MANUSCRIPTS/$API_SLUG/$RUN_ID/proofs" 2>/dev/null || true
# Archive discovery artifacts (sniff captures, URL lists, sniff report).
# Remove session state before archiving — contains authentication cookies/tokens.
@@ -1515,7 +1516,7 @@ if [ -d "$DISCOVERY_DIR" ]; then
jq 'del(.log.entries[].response.content.text)' "$har" > "${har}.stripped" 2>/dev/null && mv "${har}.stripped" "$har" || rm -f "${har}.stripped"
fi
done
- cp -r "$DISCOVERY_DIR" "$PRESS_MANUSCRIPTS/$CLI_NAME/$RUN_ID/discovery" 2>/dev/null || true
+ cp -r "$DISCOVERY_DIR" "$PRESS_MANUSCRIPTS/$API_SLUG/$RUN_ID/discovery" 2>/dev/null || true
fi
```
@@ -1537,10 +1538,10 @@ Skip this phase entirely if the final shipcheck verdict is `hold`. Only proceed
### Check for existing PR
-Run a lightweight check for your own open publish PR. The `--author @me` filter avoids matching someone else's PR for the same CLI name.
+Run a lightweight check for your own open publish PR. The `--author @me` filter avoids matching someone else's PR for the same API slug.
```bash
-gh pr list --repo mvanhorn/printing-press-library --head "feat/<cli-name>" --state open --author @me --json number,url --jq '.[0]' 2>/dev/null
+gh pr list --repo mvanhorn/printing-press-library --head "feat/<api>" --state open --author @me --json number,url --jq '.[0]' 2>/dev/null
```
If this fails (gh not authenticated, network error, etc.), continue without PR context — the publish skill will handle auth in its own Step 1.
@@ -1551,14 +1552,14 @@ Present via `AskUserQuestion`:
**If an existing open PR was found:**
-> "<cli-name> passed shipcheck. There's an open publish PR (#N). Want to update it with this version?"
+> "<api> passed shipcheck. There's an open publish PR (#N). Want to update it with this version?"
>
> 1. **Yes — update PR #N** (re-validate, re-package, and push to the existing PR)
> 2. **No — I'm done**
**If no existing PR:**
-> "<cli-name> passed shipcheck ([score]/100, verify [pass-rate]%). What do you want to do?"
+> "<api> passed shipcheck ([score]/100, verify [pass-rate]%). What do you want to do?"
>
> 1. **Publish now** (validate, package, and open a PR)
> 2. **Polish first** (run `/printing-press-polish` to fix verify failures, dead code, and README before publishing)
@@ -1569,11 +1570,11 @@ If the verdict was `ship-with-gaps`, prepend: "Note: shipcheck found minor gaps
### If "Publish now"
-Invoke `/printing-press publish <cli-name>`. The publish skill handles everything from there.
+Invoke `/printing-press publish <api>`. The publish skill handles everything from there.
### If "Polish first"
-Invoke `/printing-press-polish <cli-name>`. The polish skill runs diagnostics, fixes issues, reports the delta, and offers its own publish at the end.
+Invoke `/printing-press-polish <api>`. The polish skill runs diagnostics, fixes issues, reports the delta, and offers its own publish at the end.
### If "Run retro"
@@ -1581,7 +1582,7 @@ Invoke `/printing-press-retro`. The retro skill analyzes the session for generat
### If "Done for now"
-End normally. The CLI is in `$PRESS_LIBRARY/<api>-pp-cli` and the user can run `/printing-press publish` or `/printing-press-polish` later.
+End normally. The CLI is in `$PRESS_LIBRARY/<api>` and the user can run `/printing-press publish` or `/printing-press-polish` later.
## Fast Guidance
← 2e3b7860 chore(main): release 1.2.0 (#144)
·
back to Cli Printing Press
·
fix(cli): deduplicate config env var tags and add operations 816a9fd6 →