[object Object]

← 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

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 →