[object Object]

← back to Cli Printing Press

refactor(cli): decouple skills and binary from repo checkout (#32)

dfc0eedc3e54c681842e63ef022a678bfd0b567a · 2026-03-28 21:43:57 -0700 · Trevin Chow

* refactor(decoupling): make skills and binary work without repo checkout

Embed catalog YAML data into the binary via go:embed so catalog entries
are available at runtime without the repo filesystem. Add catalog
list/show/search CLI subcommands with --json flag. Rewrite all three
skill setup contracts to check for the binary on $PATH instead of
assuming a repo checkout — scope derives from any git root or CWD,
canonicalized with pwd -P for symlink stability. Replace all
./printing-press invocations with PATH-based printing-press, remove
all cd "$REPO_ROOT" before binary calls, and remove go build from
skills. Add version detection via debug.ReadBuildInfo() for go install,
bump version to 0.2.0, add version --json for machine parsing.
Deprecate /printing-press-catalog in favor of the main skill's new
catalog-aware shortcut. Bump plugin to v0.5.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): sync plugin version metadata

Restore .claude-plugin/plugin.json to 0.4.0 so it matches
marketplace.json and internal/cli/root.go. This fixes the release
version consistency test in internal/cli.

* fix(cli): stabilize generator quality gates in CI

Reuse the shared Go build cache in generator tests so generated
projects do not recompile toolchain state per tempdir. Increase the
generator quality-gate timeout for go mod tidy, go vet, and go build
steps to reduce flaky CI timeouts on slower runners.

* fix(ci): split generator tests into a parallel job

Run internal/generator tests in a separate workflow job while the main
test job runs all remaining packages. This preserves the same test
coverage but reduces PR wall-clock time by sharding the heaviest
generator package.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit dfc0eedc3e54c681842e63ef022a678bfd0b567a
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Mar 28 21:43:57 2026 -0700

    refactor(cli): decouple skills and binary from repo checkout (#32)
    
    * refactor(decoupling): make skills and binary work without repo checkout
    
    Embed catalog YAML data into the binary via go:embed so catalog entries
    are available at runtime without the repo filesystem. Add catalog
    list/show/search CLI subcommands with --json flag. Rewrite all three
    skill setup contracts to check for the binary on $PATH instead of
    assuming a repo checkout — scope derives from any git root or CWD,
    canonicalized with pwd -P for symlink stability. Replace all
    ./printing-press invocations with PATH-based printing-press, remove
    all cd "$REPO_ROOT" before binary calls, and remove go build from
    skills. Add version detection via debug.ReadBuildInfo() for go install,
    bump version to 0.2.0, add version --json for machine parsing.
    Deprecate /printing-press-catalog in favor of the main skill's new
    catalog-aware shortcut. Bump plugin to v0.5.0.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): sync plugin version metadata
    
    Restore .claude-plugin/plugin.json to 0.4.0 so it matches
    marketplace.json and internal/cli/root.go. This fixes the release
    version consistency test in internal/cli.
    
    * fix(cli): stabilize generator quality gates in CI
    
    Reuse the shared Go build cache in generator tests so generated
    projects do not recompile toolchain state per tempdir. Increase the
    generator quality-gate timeout for go mod tidy, go vet, and go build
    steps to reduce flaky CI timeouts on slower runners.
    
    * fix(ci): split generator tests into a parallel job
    
    Run internal/generator tests in a separate workflow job while the main
    test job runs all remaining packages. This preserves the same test
    coverage but reduces PR wall-clock time by sharding the heaviest
    generator package.
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 .github/workflows/validate-catalog.yml             |  19 +-
 catalog/catalog.go                                 |   6 +
 .../2026-03-28-repo-decoupling-requirements.md     |  89 +++++
 ...2026-03-28-001-refactor-repo-decoupling-plan.md | 376 +++++++++++++++++++++
 internal/catalog/catalog.go                        |  24 +-
 internal/catalog/catalog_test.go                   |  43 +++
 internal/cli/catalog.go                            | 196 +++++++++++
 internal/cli/catalog_test.go                       | 139 ++++++++
 internal/cli/root.go                               |  38 ++-
 internal/generator/generator_test.go               |   4 +-
 internal/generator/validate.go                     |  10 +-
 internal/pipeline/contracts_test.go                |  20 +-
 internal/pipeline/fullrun.go                       |   2 +-
 internal/pipeline/research.go                      |  17 +-
 skills/printing-press-catalog/SKILL.md             |  40 ++-
 skills/printing-press-score/SKILL.md               |  57 ++--
 skills/printing-press/SKILL.md                     |  53 ++-
 .../references/scorecard-patterns.md               |   2 +-
 18 files changed, 1055 insertions(+), 80 deletions(-)

diff --git a/.github/workflows/validate-catalog.yml b/.github/workflows/validate-catalog.yml
index b9c01a78..022de112 100644
--- a/.github/workflows/validate-catalog.yml
+++ b/.github/workflows/validate-catalog.yml
@@ -58,6 +58,19 @@ jobs:
             rm -rf /tmp/${NAME}-cli /tmp/spec-validate.yaml
           done
 
+  test-generator:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: actions/setup-go@v6
+        with:
+          go-version-file: go.mod
+          cache: true
+
+      - name: Run generator tests
+        run: go test -p 2 ./internal/generator
+
   test:
     runs-on: ubuntu-latest
     steps:
@@ -68,5 +81,7 @@ jobs:
           go-version-file: go.mod
           cache: true
 
-      - name: Run all tests
-        run: go test -p 2 ./...
+      - name: Run non-generator tests
+        run: |
+          mapfile -t packages < <(go list ./... | grep -v '^github.com/mvanhorn/cli-printing-press/internal/generator$')
+          go test -p 2 "${packages[@]}"
diff --git a/catalog/catalog.go b/catalog/catalog.go
new file mode 100644
index 00000000..f628be5b
--- /dev/null
+++ b/catalog/catalog.go
@@ -0,0 +1,6 @@
+package catalog
+
+import "embed"
+
+//go:embed *.yaml
+var FS embed.FS
diff --git a/docs/brainstorms/2026-03-28-repo-decoupling-requirements.md b/docs/brainstorms/2026-03-28-repo-decoupling-requirements.md
new file mode 100644
index 00000000..ecb73a0d
--- /dev/null
+++ b/docs/brainstorms/2026-03-28-repo-decoupling-requirements.md
@@ -0,0 +1,89 @@
+---
+date: 2026-03-28
+topic: repo-decoupling
+---
+
+# Decouple Skills and Binary from Repo Checkout
+
+## Problem Frame
+
+The CLI Printing Press binary is already repo-independent (templates embedded, commands accept URL/path args, output goes to `~/printing-press/`). But all three skills assume they're running inside the cli-printing-press repo checkout: they `cd` to repo root, invoke `./printing-press` from there, and read `catalog/` from the filesystem. This prevents distributing the printing press as a standalone tool (binary + Claude Code plugin) for users who want to print CLIs without cloning the repo.
+
+PR #30 decoupled the output side. This work decouples the input side.
+
+## Requirements
+
+**Setup Contract**
+
+- R1. The shared setup contract (`PRESS_SETUP_CONTRACT`) must not require the cli-printing-press repo. It should work from any directory — another project's repo, a non-repo folder, a cloud instance.
+- R2. `PRESS_SCOPE` derivation: use git root if inside any git repo (not just cli-printing-press), fall back to CWD-based scope if not in a repo. Same isolation semantics as today, broader compatibility. Note: scoping by directory means a user who generates from project-A and then switches to project-B won't see project-A's runs in `/printing-press-score`. The scope should be derived from a stable, canonicalized path (resolve symlinks) to avoid drift when coding agents change the working directory mid-session.
+- R3. The setup contract must check for the `printing-press` binary on `$PATH` (via `command -v printing-press`). If not found, check if `~/go/bin/printing-press` exists (common when GOPATH/bin is not on PATH) and suggest adding it. Otherwise display install instructions (`go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest`) and halt until the user installs it.
+
+**Binary Discovery**
+
+- R4. Skills must invoke `printing-press` (PATH lookup) instead of `./printing-press` (repo-relative). All three skills need this change, including reference files under `skills/printing-press/references/` (e.g., `scorecard-patterns.md` contains `./printing-press scorecard`). All repo-specific text outside the setup contract markers must also be updated (prerequisites, error messages that say "cli-printing-press checkout").
+- R5. The `/printing-press-score` and `/printing-press-catalog` skills must stop running `go build -o ./printing-press ./cmd/printing-press`. They should use the installed binary like the main skill.
+- R5a. All `cd "$REPO_ROOT"` invocations before binary calls (Phase 2 generate, Phase 4 shipcheck, emboss mode) must be removed. The binary is on PATH and should be invocable from any working directory. Skills should `cd` to the output/working directory as needed, not to a repo root.
+
+**Catalog Embedding**
+
+- R6. Embed the `catalog/` YAML files into the binary via `go:embed`. The catalog data should be accessible at runtime without the repo filesystem. Note: Go's `//go:embed` can only embed files in the same directory or below the source file. Since `catalog/` is at repo root, the embed directive must live at or above that level (e.g., in `cmd/printing-press/` or a new top-level package), not in `internal/catalog/`.
+- R7. The `/printing-press-catalog` skill must read catalog data from the binary (e.g., `printing-press catalog list`, `printing-press catalog show stripe`) instead of reading `catalog/*.yaml` from disk.
+- R8. Add CLI subcommands to expose embedded catalog data: `printing-press catalog list` (all entries, JSON by default for skill consumption), `printing-press catalog show <name>` (single entry details including spec_url), and `printing-press catalog search <query>` (case-insensitive search across name, description, category). All subcommands must support `--json` output so skills can extract fields like `spec_url` programmatically.
+- R9. `loadCatalogAlternatives()` in `research.go` and `ParseDir()` in the catalog package should support reading from an embedded FS. The binary always uses embedded catalog data — no disk fallback. In-repo developers who modify catalog YAMLs rebuild the binary (same as template changes). The `fullrun.go` callsite that hardcodes `"catalog"` as the directory path must also be updated.
+
+**Plugin Packaging**
+
+- R10. The repo is already a correctly structured Claude Code plugin. No structural changes needed — once R1-R9 make the skills repo-independent, the plugin automatically works when installed outside the repo. Bump the plugin version in `.claude-plugin/plugin.json` when merging to main so users' Claude Code picks up the update.
+- R11. The plugin must not include Go source code, build scripts, or anything that assumes the repo. Skills only — the binary is installed separately.
+- R12. The plugin's README should document the two-step setup: (1) install binary via `go install`, (2) install skills plugin.
+
+**Backward Compatibility**
+
+- R13. In-repo development must continue to work. When running from the cli-printing-press repo checkout, the existing dev workflow (build from source, run tests, etc.) is unaffected. Catalog changes require a rebuild of the binary to update embedded data, but R9's disk-first fallback means local edits are visible without rebuilding.
+- R14. The setup contract should detect repo-vs-standalone transparently. No flags, no mode switches.
+- R15. The existing `contracts_test.go` (which asserts the setup contract block content across all skills) must be updated to match the new contract. This is a known test change, not a regression.
+- R16. Skills should declare a minimum binary version they require. The setup contract should check `printing-press version` and warn if the installed binary is older than the skill's minimum. Skills and binary evolve at different rates — version parity cannot be assumed.
+
+## Success Criteria
+
+- A user with Go installed can `go install` the binary and install the skills plugin, then successfully run `/printing-press <API>` from any directory without cloning the repo.
+- `/printing-press-catalog` works standalone, browsing and installing from embedded catalog data.
+- `/printing-press-score` works standalone using the PATH binary.
+- All existing in-repo workflows continue to work unchanged.
+- `go test ./...` passes.
+- `go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest` succeeds from a clean environment (validates the module is publicly installable).
+
+## Scope Boundaries
+
+- **Not in scope**: The `/printing-press-publish` skill. That's a separate future effort. The current `~/printing-press/library/` and `~/printing-press/manuscripts/` layout is sufficient for now.
+- **Not in scope**: Changing the cli-pp-Library repo structure. The publish skill will handle assembly (bundling CLI + manuscripts) when we build it.
+- **Not in scope**: Homebrew formula or binary release automation. `go install` is the initial distribution path.
+- **Not in scope**: Removing `catalog/` from the repo. It stays as a development convenience and the source-of-truth that gets embedded into the binary at build time.
+
+## Key Decisions
+
+- **Scope derivation**: Git root (any repo) with CWD fallback — not tied to cli-printing-press specifically.
+- **Binary distribution**: `go install` with skills guiding install if missing — not auto-download or bundled.
+- **Catalog**: Embed in binary, expose via new CLI subcommands — not remote fetch or removal.
+- **Skill distribution**: Claude Code plugin — not manual file installation.
+- **Publish prep**: Deferred. Current artifact layout is sufficient. The publish skill can assemble bundles from library + manuscripts at publish time.
+
+## Dependencies / Assumptions
+
+- The existing Claude Code plugin structure (`.claude-plugin/`) continues to work when installed outside the repo, provided skills don't reference repo-local resources.
+- `go install` works for the module path `github.com/mvanhorn/cli-printing-press/cmd/printing-press`. Validated in Success Criteria.
+- The binary name `printing-press` doesn't collide with anything common on PATH.
+
+## Outstanding Questions
+
+### Deferred to Planning
+
+- [Affects R6][Needs research] Where should the `//go:embed catalog` directive live given Go's directory constraint? Options: `cmd/printing-press/`, a new top-level `embed.go`, or restructuring `catalog/` under `internal/`.
+- [Affects R12] Should the plugin README live in the repo root or in a separate `plugin/` directory?
+- [Affects R5a] What working directory should skills use for binary invocations when `$REPO_ROOT` is removed? Likely the output/working directory for the current run.
+- [Affects R9][Needs research] Should `MakeBestCLI()`/`fullrun.go` pipeline work in standalone mode, or is it explicitly dev/test-only? If dev-only, document that explicitly.
+
+## Next Steps
+
+-> `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-03-28-001-refactor-repo-decoupling-plan.md b/docs/plans/2026-03-28-001-refactor-repo-decoupling-plan.md
new file mode 100644
index 00000000..11e506e5
--- /dev/null
+++ b/docs/plans/2026-03-28-001-refactor-repo-decoupling-plan.md
@@ -0,0 +1,376 @@
+---
+title: "refactor: Decouple skills and binary from repo checkout"
+type: refactor
+status: completed
+date: 2026-03-28
+origin: docs/brainstorms/2026-03-28-repo-decoupling-requirements.md
+---
+
+# Decouple Skills and Binary from Repo Checkout
+
+## Overview
+
+Make the CLI Printing Press distributable as a standalone `go install` binary + Claude Code plugin. All three skills currently assume they run inside the cli-printing-press repo checkout. This refactor removes that assumption so users can print CLIs from any directory without cloning the repo.
+
+## Problem Frame
+
+PR #30 decoupled the output side (artifacts to `~/printing-press/`). The input side still requires the repo: skills `cd` to repo root, invoke `./printing-press`, read `catalog/` from disk, and build from source. This blocks standalone distribution. (see origin: docs/brainstorms/2026-03-28-repo-decoupling-requirements.md)
+
+## Requirements Trace
+
+- R1. Setup contract works from any directory (not just cli-printing-press repo)
+- R2. PRESS_SCOPE: git root (any repo) with CWD fallback, canonicalized for symlink stability
+- R3. Binary check on $PATH with GOPATH/bin diagnostic and install instructions
+- R4. Skills invoke `printing-press` (PATH) not `./printing-press` (repo-relative), including reference files
+- R5. Score and catalog skills stop running `go build`
+- R5a. Remove all `cd "$REPO_ROOT"` before binary invocations
+- R6. Embed catalog/ YAMLs via go:embed
+- R7. Catalog skill reads from binary subcommands, not disk
+- R8. Catalog list/show/search subcommands with --json
+- R9. Catalog always uses embedded data; update fullrun.go and research.go to use embedded FS (no disk fallback — devs rebuild binary for catalog changes)
+- R10. Existing plugin structure works; bump version on merge
+- R11. Plugin ships skills only, no Go source
+- R12. Plugin README documents two-step setup
+- R13. In-repo dev unaffected; catalog changes require rebuild (same as template changes)
+- R14. Transparent repo-vs-standalone detection
+- R15. Update contracts_test.go for new contract
+- R16. Skills declare minimum binary version; setup contract checks it
+
+## Scope Boundaries
+
+- Not in scope: `/printing-press-publish` skill, cli-pp-Library changes, Homebrew/release automation
+- Not in scope: Removing `catalog/` from repo (stays as source-of-truth embedded at build time)
+- fullrun.go / MakeBestCLI() is dev/test-only; not required to work standalone
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- **Setup contract**: Copy-pasted across 3 skills between `<!-- PRESS_SETUP_CONTRACT_START/END -->` markers. Enforced by `internal/pipeline/contracts_test.go` TestSkillSetupBlocksMatchWorkspaceContract
+- **Catalog package**: `internal/catalog/catalog.go` — `ParseDir(dir string)` uses `os.ReadDir`, `ParseEntry(data []byte)` parses single YAML. `Entry` struct has all fields including `KnownAlternatives`
+- **Catalog consumers**: `loadCatalogAlternatives()` in `internal/pipeline/research.go:170` defaults to `"catalog"` dir. Called by `RunResearch()` in `fullrun.go:116` with hardcoded `"catalog"`
+- **go:embed pattern**: `internal/generator/generator.go:20` embeds `templates/` directory. Accessed via `templateFS.ReadFile()`
+- **CLI framework**: Cobra. Commands registered in `internal/cli/root.go` via `newXxxCmd()` pattern
+- **Path helpers**: `internal/pipeline/paths.go` — `PressHome()`, `WorkspaceScope()`, `repoRoot()` all support env var overrides (`PRINTING_PRESS_HOME`, `PRINTING_PRESS_SCOPE`, `PRINTING_PRESS_REPO_ROOT`)
+- **Version**: Hardcoded `var version = "0.1.0"` in `internal/cli/root.go:28`. `version` subcommand outputs `printing-press 0.1.0`
+- **Plugin**: `.claude-plugin/plugin.json` with name, version 0.4.0, description, repository. Skills discovered by convention from `skills/` directory
+
+### Institutional Learnings
+
+- `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`: Never hardcode `~/cli-printing-press`. Always resolve paths dynamically. Path logic centralized in `internal/pipeline/paths.go`, naming in `internal/naming/`
+- Scope derivation: basename + 8-char SHA256 of full path. Already falls back to CWD when not in a git repo (paths.go:152-173)
+
+## Key Technical Decisions
+
+- **go:embed location**: Create `catalog/catalog.go` as `package catalog` with `//go:embed *.yaml` and `var FS embed.FS`. This follows Go convention (package name matches directory name). Import as `github.com/mvanhorn/cli-printing-press/catalog`. Consumers that also import `internal/catalog` use an import alias (e.g., `catalogfs "github.com/mvanhorn/cli-printing-press/catalog"`) — standard Go practice for name collisions
+- **fs.FS interface for catalog**: Add `ParseFS(fsys fs.FS) ([]Entry, error)` to the existing `internal/catalog/catalog.go`. All consumers use the embedded FS
+- **Always use embedded catalog**: No disk-first fallback. The binary always reads from the embedded catalog data. In-repo developers who modify catalog YAMLs simply rebuild the binary (`go build`). This eliminates the risk of accidentally reading a stray `catalog/` directory in the user's CWD and avoids the complexity of per-entry vs per-directory fallback logic
+- **Version from build info**: Use `runtime/debug.ReadBuildInfo()` to detect the module version set by `go install`. Fall back to hardcoded version for local `go build`. This avoids needing `-ldflags` injection
+- **Minimum version protocol**: Skills declare `min-binary-version` in YAML frontmatter. The version check is an **agent instruction** outside the bash contract block (not bash — bash can't read its own file's YAML frontmatter). Claude Code reads the frontmatter, runs `printing-press version --json`, and compares. Warns but does not halt on mismatch. The minimum version is also hardcoded in the contract bash block as a comment for contracts_test.go to verify matches the frontmatter
+- **Catalog subcommand output**: Human-readable by default, `--json` flag for machine output (matching the existing convention in scorecard, generate, and version commands). Skills pass `--json` explicitly when invoking catalog subcommands
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Where does go:embed live?** `catalog/catalog.go` as `package catalog`. Go's embed can only reference same-dir or subdirs. `catalog/` is at repo root, unreachable from `internal/` or `cmd/`. Package name matches directory name per Go convention; consumers use import aliases when they also import `internal/catalog`
+- **How does version detection work with go install?** `debug.ReadBuildInfo()` returns the module version automatically when built via `go install`. No ldflags needed
+- **Working directory after removing cd $REPO_ROOT?** Binary commands already accept `--dir` and `--spec` flags. No specific working directory needed. Skills cd to the output/working directory for the current run
+- **Does fullrun.go need to work standalone?** No. It's dev/test-only. Explicitly not in scope
+- **Catalog output format?** Human-readable by default, `--json` flag for machine parsing (consistent with all other CLI commands). Skills pass `--json` explicitly
+
+### Deferred to Implementation
+
+- Exact semver comparison logic for R16 version checking (simple string comparison may suffice initially)
+- Whether `repoRoot()` in paths.go needs updating or if the existing env var override is sufficient
+- Whether the catalog skill should support adding user-provided catalog entries to the embedded set
+
+## Implementation Units
+
+- [ ] **Unit 1: Catalog embed package and fs.FS refactor**
+
+**Goal:** Make catalog data accessible without the repo filesystem by embedding YAMLs and adding fs.FS support to the catalog package.
+
+**Requirements:** R6, R9, R13
+
+**Dependencies:** None
+
+**Files:**
+- Create: `catalog/catalog.go`
+- Modify: `internal/catalog/catalog.go`
+- Modify: `AGENTS.md` (update project structure: `catalog/` now contains a Go package alongside YAML data)
+- Test: `internal/catalog/catalog_test.go`
+
+**Approach:**
+- Create `catalog/catalog.go` as `package catalog` with `//go:embed *.yaml` and `var FS embed.FS`
+- Add `ParseFS(fsys fs.FS) ([]Entry, error)` to `internal/catalog/catalog.go` that mirrors `ParseDir()` but reads from an `fs.FS` via `fs.ReadDir` and `fs.ReadFile`
+- Add `LookupFS(fsys fs.FS, name string) (*Entry, error)` for single-entry lookup by name (used by catalog show and loadCatalogAlternatives)
+- Keep `ParseDir()` unchanged for backward compatibility
+- Consumers that import both packages use an alias: `catalogfs "github.com/mvanhorn/cli-printing-press/catalog"`
+
+**Patterns to follow:**
+- `internal/generator/generator.go:20` — `//go:embed templates` with `embed.FS`
+- `internal/catalog/catalog.go:70` — `ParseDir()` structure for the new `ParseFS()`
+
+**Test scenarios:**
+- Happy path: ParseFS reads embedded catalog and returns all 17 entries
+- Happy path: LookupFS finds "stripe" entry with correct SpecURL
+- Edge case: LookupFS returns error for non-existent entry name
+- Edge case: ParseFS with empty fs.FS returns empty slice, no error
+- Integration: catalog.FS (from `github.com/mvanhorn/cli-printing-press/catalog`) is importable and contains the expected YAML files
+
+**Verification:**
+- `go test ./internal/catalog/...` passes
+- `go build ./catalog/...` succeeds (new package compiles)
+
+---
+
+- [ ] **Unit 2: Update catalog consumers to use embedded FS**
+
+**Goal:** Wire the embedded catalog into `loadCatalogAlternatives()` and `fullrun.go` so they always use embedded data instead of reading from disk.
+
+**Requirements:** R9
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/pipeline/research.go`
+- Modify: `internal/pipeline/fullrun.go`
+- Test: `internal/pipeline/research_test.go`
+
+**Approach:**
+- Refactor `loadCatalogAlternatives()` to use `LookupFS(catalogFS, apiName)` with the embedded FS instead of `os.ReadFile(filepath.Join(catalogDir, apiName+".yaml"))`
+- Remove the `catalogDir string` parameter from `loadCatalogAlternatives()` and `RunResearch()` — replace with the embedded FS
+- Update `fullrun.go` callsite to remove the hardcoded `"catalog"` directory argument
+- Note: fullrun.go is dev/test-only (see Scope Boundaries) but is updated here for API consistency — removing the `catalogDir` parameter that no longer exists. This is a signature cleanup, not a standalone requirement
+
+**Patterns to follow:**
+- Existing `loadCatalogAlternatives()` structure at `research.go:170`
+- Unit 1's `LookupFS()` for single-entry access
+
+**Test scenarios:**
+- Happy path: loadCatalogAlternatives reads from embedded FS and returns known alternatives for "stripe"
+- Edge case: API not in embedded catalog — returns nil (graceful, no error)
+- Happy path: RunResearch no longer takes a catalogDir parameter
+
+**Verification:**
+- `go test ./internal/pipeline/...` passes
+- When run from a directory without `catalog/`, the binary still finds catalog data
+
+---
+
+- [ ] **Unit 3: Catalog CLI subcommands**
+
+**Goal:** Add `printing-press catalog list|show|search` subcommands that expose embedded catalog data for skill consumption.
+
+**Requirements:** R7, R8
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Create: `internal/cli/catalog.go`
+- Modify: `internal/cli/root.go` (add `rootCmd.AddCommand(newCatalogCmd())`)
+- Test: `internal/cli/catalog_test.go`
+
+**Approach:**
+- Create `newCatalogCmd()` returning a parent Cobra command with three subcommands
+- `catalog list`: Parse all entries via `ParseFS(catalogfs.FS)`, output grouped-by-category display. `--json` flag for JSON array output
+- `catalog show <name>`: Lookup single entry via `LookupFS(catalogfs.FS, name)`, output formatted display. `--json` for JSON object with spec_url, category, description, known_alternatives
+- `catalog search <query>`: Case-insensitive search across name, display_name, description, category. Output matching entries. `--json` for JSON array
+- All subcommands use the embedded catalog FS directly — no disk fallback
+- Error handling: unknown name returns exit code + clear message
+
+**Patterns to follow:**
+- `internal/cli/scorecard.go` — Cobra command structure with `RunE` and local flags
+- `internal/cli/exitcodes.go` — custom exit code pattern
+
+**Test scenarios:**
+- Happy path: `catalog list` outputs grouped-by-category human-readable display
+- Happy path: `catalog list --json` returns all 17 entries as JSON array
+- Happy path: `catalog show stripe --json` returns Stripe entry with spec_url field
+- Happy path: `catalog search auth` returns entries matching "auth" in any field
+- Edge case: `catalog show nonexistent` returns error with clear message
+- Edge case: `catalog search` with no matches returns empty array
+
+**Verification:**
+- `go test ./internal/cli/...` passes
+- `./printing-press catalog list --json | jq length` returns 17
+- `./printing-press catalog show stripe --json | jq .spec_url` returns the Stripe spec URL
+
+---
+
+- [ ] **Unit 4: Version detection from build info**
+
+**Goal:** Make `printing-press version` report the correct version when installed via `go install`, and add machine-parseable output for skills to check.
+
+**Requirements:** R16
+
+**Dependencies:** None (parallel with Units 1-3)
+
+**Files:**
+- Modify: `internal/cli/root.go`
+- Test: `internal/cli/root_test.go` (or inline test in existing test file)
+
+**Approach:**
+- In `init()` or package-level var block, use `debug.ReadBuildInfo()` to read `(vcs.revision)` and module version. If module version is set (not empty or "(devel)"), use it as `version`
+- Bump hardcoded `version` from `"0.1.0"` to `"0.2.0"` in this PR (the version that adds catalog subcommands). Use `debug.ReadBuildInfo()` to override with the module version when available (go install). Fall back to the hardcoded `"0.2.0"` for local `go build`
+- `printing-press version` already outputs `printing-press X.Y.Z\n` — no format change needed
+- Add `--json` flag to version command for machine parsing: `{"version": "X.Y.Z", "go": "1.26.1"}`
+
+**Patterns to follow:**
+- Existing `newVersionCmd()` in `internal/cli/root.go:439-447`
+
+**Test scenarios:**
+- Happy path: Version command outputs `printing-press X.Y.Z` format
+- Happy path: `--json` flag outputs parseable JSON with version field
+- Edge case: When build info is unavailable, falls back to hardcoded version
+
+**Verification:**
+- `go test ./internal/cli/...` passes
+- `./printing-press version` outputs version string
+- `./printing-press version --json | jq .version` returns version
+
+---
+
+- [ ] **Unit 5: Rewrite setup contract and update contract tests**
+
+**Goal:** Replace the repo-dependent setup contract with one that works from any directory, checks binary on PATH, and verifies minimum version.
+
+**Requirements:** R1, R2, R3, R14, R15, R16
+
+**Dependencies:** Unit 4 (version --json needed for version check)
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md` (setup contract block only)
+- Modify: `skills/printing-press-catalog/SKILL.md` (setup contract block only)
+- Modify: `skills/printing-press-score/SKILL.md` (setup contract block only)
+- Modify: `internal/pipeline/contracts_test.go`
+
+**Approach:**
+- New contract block (between existing HTML comment markers):
+  1. Check `command -v printing-press`. If missing, check `~/go/bin/printing-press` and suggest PATH fix. Otherwise show `go install` instructions and halt
+  2. (Agent instruction outside contract block, not bash): Read `min-binary-version` from skill frontmatter, run `printing-press version --json`, compare versions. Warn if binary is older than minimum. The contract block includes a comment with the minimum version for `contracts_test.go` to verify matches frontmatter
+  3. Derive PRESS_SCOPE: try `git rev-parse --show-toplevel 2>/dev/null`. If succeeds, use that path (any repo, not just cli-printing-press). If fails, use CWD. In both cases, canonicalize with `cd "$dir" && pwd -P` for symlink stability (portable — macOS default readlink doesn't support `-f` and `realpath` wasn't universal until macOS 12.3)
+  4. Derive PRESS_HOME, PRESS_RUNSTATE, PRESS_LIBRARY, PRESS_MANUSCRIPTS from PRESS_SCOPE (same as today, just different root derivation)
+  5. Remove `cd "$REPO_ROOT"` — no longer needed
+- Add `min-binary-version: 0.2.0` to all three skill YAML frontmatter (0.2.0 = version that has catalog subcommands)
+- Update `contracts_test.go`:
+  - Remove assertion for `REPO_ROOT`
+  - Add assertions for `command -v printing-press`, `printing-press version`, `realpath`/symlink handling
+  - Keep assertions for PRESS_HOME, PRESS_SCOPE, PRESS_LIBRARY, PRESS_RUNSTATE, PRESS_MANUSCRIPTS
+
+**Patterns to follow:**
+- Existing `PRESS_SETUP_CONTRACT_START/END` HTML comment markers
+- `internal/pipeline/contracts_test.go` — extractContractBlock() and assertion pattern
+
+**Test scenarios:**
+- Happy path: Contract block extracted from each skill contains `command -v printing-press`
+- Happy path: Contract block contains PRESS_SCOPE derivation with git fallback to CWD
+- Happy path: Contract block contains `printing-press version` check
+- Happy path: Contract block does NOT contain `cd "$REPO_ROOT"`
+- Happy path: Contract block does NOT contain `./printing-press` (repo-relative)
+- Happy path: Contract block does NOT contain `git rev-parse --show-toplevel` as a hard requirement (only as optional scope hint)
+- Edge case: Contract block does NOT contain `go build`
+
+**Verification:**
+- `go test ./internal/pipeline/...` passes (contract tests green)
+- All three skills have identical contract blocks (within expected variation for PRESS_MANUSCRIPTS)
+
+---
+
+- [ ] **Unit 6: Rewrite skill binary invocations and text**
+
+**Goal:** Replace all `./printing-press` with `printing-press`, remove all `cd "$REPO_ROOT"`, add catalog-aware shortcut to the main skill, and deprecate `/printing-press-catalog`.
+
+**Requirements:** R4, R5, R5a, R7, R8
+
+**Dependencies:** Unit 3 (catalog subcommands), Unit 5 (new setup contract)
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+- Deprecate: `skills/printing-press-catalog/SKILL.md` (mark as deprecated, point users to main skill)
+- Modify: `skills/printing-press-score/SKILL.md`
+- Modify: `skills/printing-press/references/scorecard-patterns.md`
+
+**Approach:**
+- Main skill — binary path fixes: Replace all `cd "$REPO_ROOT" && ./printing-press` with just `printing-press`. Binary invocations that need a specific directory should use `--dir` flag, not `cd`
+- Main skill — catalog-aware shortcut: Before starting the research phase, check `printing-press catalog show <api> --json 2>/dev/null`. If the API is in the catalog, present a choice: "Stripe is in the built-in catalog (official spec, 500+ endpoints). Use the catalog config, or run full discovery?" If the user chooses catalog config, use the spec_url directly, skip discovery. If not in catalog or user chooses discovery, proceed with the normal workflow
+- Deprecate `/printing-press-catalog`: The main skill now handles catalog lookup automatically. Mark the catalog skill as deprecated in its frontmatter (`deprecated: true`) and add a note pointing users to `/printing-press <api>` instead. The `printing-press catalog list` CLI command still exists for browsing
+- Score skill: Remove `go build -o ./printing-press ./cmd/printing-press`. Replace `./printing-press scorecard` with `printing-press scorecard`. Remove prerequisite "Running from inside the cli-printing-press repo". Update error message
+- Reference files: Update `scorecard-patterns.md` to use `printing-press scorecard`
+
+**Patterns to follow:**
+- The new setup contract from Unit 5 already checks binary availability
+- Catalog subcommands from Unit 3 provide the `--json` output the skill parses
+
+**Test scenarios:**
+- Happy path: No skill file contains `./printing-press` (contract test assertion)
+- Happy path: No skill file contains `go build -o ./printing-press`
+- Happy path: No skill file contains `cd "$REPO_ROOT"` outside the setup contract markers
+- Happy path: No skill file contains "cli-printing-press checkout" or "cli-printing-press repo" in prerequisites
+- Happy path: Main skill's Phase 1 checks catalog for the API name before starting research
+- Happy path: Catalog skill is marked deprecated with redirect to main skill
+
+**Verification:**
+- `grep -r './printing-press' skills/` returns no matches
+- `grep -r 'go build.*printing-press' skills/` returns no matches
+- `go test ./internal/pipeline/...` passes (contract tests verify no deprecated patterns)
+
+---
+
+- [ ] **Unit 7: Plugin version bump and final validation**
+
+**Goal:** Bump plugin version, ensure the plugin works when installed standalone, and validate end-to-end.
+
+**Requirements:** R10, R11, R12, R13
+
+**Dependencies:** Units 1-6
+
+**Files:**
+- Modify: `.claude-plugin/plugin.json` (version bump)
+- Modify: `AGENTS.md` (if any repo-assumption text remains)
+
+**Approach:**
+- Bump `.claude-plugin/plugin.json` version (e.g., 0.4.0 -> 0.5.0)
+- Verify R11: no Go source, build scripts, or repo-assuming content ships with the plugin (skills/ directory is clean)
+- Verify R13: `go test ./...` passes (in-repo dev workflow intact)
+- Verify R12: README or AGENTS.md documents two-step setup
+- Final grep audit: no remaining `./printing-press`, `cd "$REPO_ROOT"`, or "cli-printing-press checkout" in skills or references
+
+**Test scenarios:**
+- Happy path: `go test ./...` — all tests pass
+- Happy path: `go build -o ./printing-press ./cmd/printing-press` — binary builds
+- Happy path: `./printing-press catalog list --json` — returns catalog data
+- Happy path: `./printing-press version --json` — returns version
+- Integration: From a directory outside the repo, `printing-press catalog list --json` works (if binary is on PATH)
+
+**Verification:**
+- All tests pass
+- Binary builds and runs standalone
+- No repo-path assumptions remain in skills
+
+## System-Wide Impact
+
+- **Interaction graph:** The setup contract block is shared (copy-pasted) across 3 skills. Changing it affects all skill invocations. The contract test in contracts_test.go is the safety net
+- **Error propagation:** Skills that can't find the binary now halt with install instructions (R3) instead of silently failing on `./printing-press`
+- **State lifecycle risks:** PRESS_SCOPE changes from "cli-printing-press repo root" to "any git root or CWD". Existing runstate in `~/printing-press/.runstate/` uses scopes derived from the old formula. Users with existing runs will get new scope hashes — old runs remain on disk but won't be discovered by the new scope. This is acceptable since the tool is pre-release
+- **API surface parity:** The `catalog list/show/search` subcommands are new CLI surface. The main `/printing-press` skill now checks the catalog automatically, making `/printing-press-catalog` redundant (deprecated). Two skills ship instead of three
+- **Unchanged invariants:** All output paths (`~/printing-press/library/`, `~/printing-press/manuscripts/`, `~/printing-press/.runstate/`) remain the same. Generated CLI structure is unaffected. The generate/dogfood/verify/scorecard/emboss commands are unchanged
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| go:embed path constraint — `catalog/` at repo root can't be embedded from `internal/` | Resolved: create `catalog/catalog.go` as `package catalog` at the same level |
+| Existing runstate scopes invalidated by PRESS_SCOPE change | Pre-release tool; old runs remain on disk. Document in release notes |
+| `go install` may not work if module not on public proxy | Success criteria includes validating this. Module is already public on GitHub |
+| Skills may reference repo paths in subtle ways not caught by search | Contract tests + grep audit in Unit 7 as safety net |
+| Version skew between binary and skills | R16 minimum version check warns users. Non-blocking to avoid hard failures |
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-03-28-repo-decoupling-requirements.md](docs/brainstorms/2026-03-28-repo-decoupling-requirements.md)
+- Related code: `internal/pipeline/paths.go`, `internal/catalog/catalog.go`, `internal/cli/root.go`, `internal/pipeline/contracts_test.go`
+- Related PR: #30 (feat(pipeline): move mutable runs into scoped runstate)
+- Prior plans: `docs/plans/2026-03-25-feat-distribution-skill-homebrew-catalog-plan.md`, `docs/plans/2026-03-23-feat-cli-printing-press-phase4-catalog-community-plan.md`
+- Institutional learning: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index 9f8a1d32..9ef1eec4 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -2,6 +2,7 @@ package catalog
 
 import (
 	"fmt"
+	"io/fs"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -68,9 +69,15 @@ func ParseEntry(data []byte) (*Entry, error) {
 }
 
 func ParseDir(dir string) ([]Entry, error) {
-	dirEntries, err := os.ReadDir(dir)
+	return ParseFS(os.DirFS(dir))
+}
+
+// ParseFS reads all YAML catalog entries from an fs.FS (e.g., an embedded filesystem).
+// It mirrors ParseDir but operates on the fs.FS interface instead of the OS filesystem.
+func ParseFS(fsys fs.FS) ([]Entry, error) {
+	dirEntries, err := fs.ReadDir(fsys, ".")
 	if err != nil {
-		return nil, fmt.Errorf("reading directory: %w", err)
+		return nil, fmt.Errorf("reading fs: %w", err)
 	}
 
 	sort.Slice(dirEntries, func(i, j int) bool {
@@ -86,8 +93,7 @@ func ParseDir(dir string) ([]Entry, error) {
 			continue
 		}
 
-		path := filepath.Join(dir, de.Name())
-		data, err := os.ReadFile(path)
+		data, err := fs.ReadFile(fsys, de.Name())
 		if err != nil {
 			return nil, fmt.Errorf("reading %s: %w", de.Name(), err)
 		}
@@ -102,6 +108,16 @@ func ParseDir(dir string) ([]Entry, error) {
 	return entries, nil
 }
 
+// LookupFS finds a single catalog entry by name from an fs.FS.
+// Returns an error if the entry is not found.
+func LookupFS(fsys fs.FS, name string) (*Entry, error) {
+	data, err := fs.ReadFile(fsys, name+".yaml")
+	if err != nil {
+		return nil, fmt.Errorf("catalog entry %q not found", name)
+	}
+	return ParseEntry(data)
+}
+
 func (e *Entry) Validate() error {
 	if e.Name == "" {
 		return fmt.Errorf("name is required")
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index ca6e03b4..bdd318f6 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -2,7 +2,9 @@ package catalog
 
 import (
 	"testing"
+	"testing/fstest"
 
+	catalogfs "github.com/mvanhorn/cli-printing-press/catalog"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -137,3 +139,44 @@ func TestParseDir(t *testing.T) {
 	assert.Contains(t, names, "test-api")
 	assert.Contains(t, names, "petstore")
 }
+
+func TestParseFSEmbeddedCatalog(t *testing.T) {
+	entries, err := ParseFS(catalogfs.FS)
+	require.NoError(t, err)
+	assert.Greater(t, len(entries), 0)
+}
+
+func TestLookupFSFindsStripe(t *testing.T) {
+	entry, err := LookupFS(catalogfs.FS, "stripe")
+	require.NoError(t, err)
+	assert.Equal(t, "stripe", entry.Name)
+	assert.Equal(t, "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", entry.SpecURL)
+}
+
+func TestLookupFSNotFound(t *testing.T) {
+	_, err := LookupFS(catalogfs.FS, "nonexistent-api")
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), `catalog entry "nonexistent-api" not found`)
+}
+
+func TestParseFSEmptyFS(t *testing.T) {
+	emptyFS := fstest.MapFS{}
+	entries, err := ParseFS(emptyFS)
+	require.NoError(t, err)
+	assert.Empty(t, entries)
+}
+
+func TestCatalogFSContainsYAMLFiles(t *testing.T) {
+	// Integration: verify the embedded FS from the catalog package is importable
+	// and contains YAML files.
+	entries, err := catalogfs.FS.ReadDir(".")
+	require.NoError(t, err)
+
+	var yamlCount int
+	for _, e := range entries {
+		if !e.IsDir() && len(e.Name()) > 5 && e.Name()[len(e.Name())-5:] == ".yaml" {
+			yamlCount++
+		}
+	}
+	assert.Greater(t, yamlCount, 0)
+}
diff --git a/internal/cli/catalog.go b/internal/cli/catalog.go
new file mode 100644
index 00000000..d5d89946
--- /dev/null
+++ b/internal/cli/catalog.go
@@ -0,0 +1,196 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+
+	catalogfs "github.com/mvanhorn/cli-printing-press/catalog"
+	"github.com/mvanhorn/cli-printing-press/internal/catalog"
+	"github.com/spf13/cobra"
+)
+
+func newCatalogCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "catalog",
+		Short: "Browse the embedded API catalog",
+		Example: `  # List all catalog entries
+  printing-press catalog list
+
+  # Show a single entry
+  printing-press catalog show stripe
+
+  # Search the catalog
+  printing-press catalog search auth`,
+	}
+
+	cmd.AddCommand(newCatalogListCmd())
+	cmd.AddCommand(newCatalogShowCmd())
+	cmd.AddCommand(newCatalogSearchCmd())
+
+	return cmd
+}
+
+func newCatalogListCmd() *cobra.Command {
+	var asJSON bool
+
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List all catalog entries",
+		Example: `  printing-press catalog list
+  printing-press catalog list --json`,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			entries, err := catalog.ParseFS(catalogfs.FS)
+			if err != nil {
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("reading catalog: %w", err)}
+			}
+
+			if asJSON {
+				enc := json.NewEncoder(os.Stdout)
+				enc.SetIndent("", "  ")
+				return enc.Encode(entries)
+			}
+
+			// Group by category
+			grouped := map[string][]catalog.Entry{}
+			for _, e := range entries {
+				grouped[e.Category] = append(grouped[e.Category], e)
+			}
+
+			categories := make([]string, 0, len(grouped))
+			for cat := range grouped {
+				categories = append(categories, cat)
+			}
+			sort.Strings(categories)
+
+			for _, cat := range categories {
+				fmt.Printf("%s:\n", cat)
+				for _, e := range grouped[cat] {
+					fmt.Printf("  %-20s %s\n", e.Name, e.Description)
+				}
+				fmt.Println()
+			}
+
+			return nil
+		},
+	}
+
+	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+
+	return cmd
+}
+
+func newCatalogShowCmd() *cobra.Command {
+	var asJSON bool
+
+	cmd := &cobra.Command{
+		Use:   "show <name>",
+		Short: "Show details for a catalog entry",
+		Example: `  printing-press catalog show stripe
+  printing-press catalog show stripe --json`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			entry, err := catalog.LookupFS(catalogfs.FS, args[0])
+			if err != nil {
+				return &ExitError{Code: ExitInputError, Err: err}
+			}
+
+			if asJSON {
+				enc := json.NewEncoder(os.Stdout)
+				enc.SetIndent("", "  ")
+				return enc.Encode(entry)
+			}
+
+			fmt.Printf("Name:           %s\n", entry.Name)
+			fmt.Printf("Display Name:   %s\n", entry.DisplayName)
+			fmt.Printf("Description:    %s\n", entry.Description)
+			fmt.Printf("Category:       %s\n", entry.Category)
+			fmt.Printf("Tier:           %s\n", entry.Tier)
+			fmt.Printf("Spec URL:       %s\n", entry.SpecURL)
+			fmt.Printf("Spec Format:    %s\n", entry.SpecFormat)
+			if entry.OpenAPIVersion != "" {
+				fmt.Printf("OpenAPI:        %s\n", entry.OpenAPIVersion)
+			}
+			if entry.Homepage != "" {
+				fmt.Printf("Homepage:       %s\n", entry.Homepage)
+			}
+			if entry.Notes != "" {
+				fmt.Printf("Notes:          %s\n", entry.Notes)
+			}
+			if entry.VerifiedDate != "" {
+				fmt.Printf("Verified:       %s\n", entry.VerifiedDate)
+			}
+
+			return nil
+		},
+	}
+
+	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+
+	return cmd
+}
+
+func newCatalogSearchCmd() *cobra.Command {
+	var asJSON bool
+
+	cmd := &cobra.Command{
+		Use:   "search <query>",
+		Short: "Search catalog entries by name, description, or category",
+		Example: `  printing-press catalog search auth
+  printing-press catalog search payments --json`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			entries, err := catalog.ParseFS(catalogfs.FS)
+			if err != nil {
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("reading catalog: %w", err)}
+			}
+
+			query := strings.ToLower(args[0])
+			matches := make([]catalog.Entry, 0)
+			for _, e := range entries {
+				if matchesCatalogQuery(e, query) {
+					matches = append(matches, e)
+				}
+			}
+
+			if asJSON {
+				enc := json.NewEncoder(os.Stdout)
+				enc.SetIndent("", "  ")
+				return enc.Encode(matches)
+			}
+
+			if len(matches) == 0 {
+				fmt.Printf("No entries matching %q\n", args[0])
+				return nil
+			}
+
+			fmt.Printf("Found %d matching entries:\n\n", len(matches))
+			for _, e := range matches {
+				fmt.Printf("  %-20s %-15s %s\n", e.Name, e.Category, e.Description)
+			}
+
+			return nil
+		},
+	}
+
+	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+
+	return cmd
+}
+
+func matchesCatalogQuery(e catalog.Entry, query string) bool {
+	fields := []string{
+		e.Name,
+		e.DisplayName,
+		e.Description,
+		e.Category,
+	}
+	for _, f := range fields {
+		if strings.Contains(strings.ToLower(f), query) {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/cli/catalog_test.go b/internal/cli/catalog_test.go
new file mode 100644
index 00000000..8196fc3b
--- /dev/null
+++ b/internal/cli/catalog_test.go
@@ -0,0 +1,139 @@
+package cli
+
+import (
+	"encoding/json"
+	"io"
+	"os"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/catalog"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// runWithCapturedStdout executes fn while capturing os.Stdout via a pipe.
+func runWithCapturedStdout(t *testing.T, fn func() error) (string, error) {
+	t.Helper()
+	r, w, err := os.Pipe()
+	require.NoError(t, err)
+	origStdout := os.Stdout
+	os.Stdout = w
+
+	execErr := fn()
+	w.Close()
+	os.Stdout = origStdout
+
+	out, _ := io.ReadAll(r)
+	r.Close()
+	return string(out), execErr
+}
+
+func TestCatalogListJSON(t *testing.T) {
+	cmd := newCatalogCmd()
+	cmd.SetArgs([]string{"list", "--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	var entries []catalog.Entry
+	require.NoError(t, json.Unmarshal([]byte(output), &entries))
+	assert.Greater(t, len(entries), 0, "catalog should have entries")
+
+	for _, e := range entries {
+		assert.NotEmpty(t, e.Name)
+		assert.NotEmpty(t, e.SpecURL)
+	}
+}
+
+func TestCatalogShowStripeJSON(t *testing.T) {
+	cmd := newCatalogCmd()
+	cmd.SetArgs([]string{"show", "stripe", "--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	var entry catalog.Entry
+	require.NoError(t, json.Unmarshal([]byte(output), &entry))
+	assert.Equal(t, "stripe", entry.Name)
+	assert.NotEmpty(t, entry.SpecURL)
+	assert.Contains(t, entry.SpecURL, "https://")
+}
+
+func TestCatalogShowNonexistent(t *testing.T) {
+	cmd := newCatalogCmd()
+	cmd.SetArgs([]string{"show", "nonexistent-api-xyz"})
+
+	err := cmd.Execute()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "not found")
+}
+
+func TestCatalogSearchAuth(t *testing.T) {
+	cmd := newCatalogCmd()
+	cmd.SetArgs([]string{"search", "auth", "--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	var matches []catalog.Entry
+	require.NoError(t, json.Unmarshal([]byte(output), &matches))
+	assert.Greater(t, len(matches), 0, "search for 'auth' should return results")
+
+	// Stytch is an auth-category entry
+	found := false
+	for _, m := range matches {
+		if m.Name == "stytch" {
+			found = true
+			break
+		}
+	}
+	assert.True(t, found, "stytch should appear in auth search results")
+}
+
+func TestCatalogSearchNoMatches(t *testing.T) {
+	cmd := newCatalogCmd()
+	cmd.SetArgs([]string{"search", "zzz-nonexistent-query-xyz", "--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	var matches []catalog.Entry
+	require.NoError(t, json.Unmarshal([]byte(output), &matches))
+	assert.Empty(t, matches, "search for nonsense query should return no results")
+}
+
+func TestVersionJSON(t *testing.T) {
+	cmd := newVersionCmd()
+	cmd.SetArgs([]string{"--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	var result map[string]string
+	require.NoError(t, json.Unmarshal([]byte(output), &result))
+	assert.NotEmpty(t, result["version"], "version key should be present and non-empty")
+	assert.NotEmpty(t, result["go"], "go key should be present and non-empty")
+}
+
+func TestCatalogListPlainText(t *testing.T) {
+	cmd := newCatalogCmd()
+	cmd.SetArgs([]string{"list"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	// Plain-text output groups entries by category with headers
+	assert.Contains(t, output, "payments:")
+	assert.Contains(t, output, "stripe")
+}
+
+func TestCatalogShowStripePlainText(t *testing.T) {
+	cmd := newCatalogCmd()
+	cmd.SetArgs([]string{"show", "stripe"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	assert.Contains(t, output, "Stripe")
+	assert.Contains(t, output, "Spec URL:")
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 04ad2200..419c7186 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -9,6 +9,8 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"runtime"
+	"runtime/debug"
 	"strings"
 	"time"
 
@@ -27,6 +29,24 @@ import (
 
 var version = "0.4.0" // x-release-please-version
 
+func init() {
+	info, ok := debug.ReadBuildInfo()
+	if !ok {
+		return
+	}
+	v := info.Main.Version
+	// Only use the build info version when it's a real tagged release.
+	// Skip empty, "(devel)", and pseudo-versions like "v0.0.0-20260328...".
+	if v == "" || v == "(devel)" {
+		return
+	}
+	trimmed := strings.TrimPrefix(v, "v")
+	if strings.HasPrefix(trimmed, "0.0.0-") {
+		return
+	}
+	version = trimmed
+}
+
 func Execute() error {
 	rootCmd := &cobra.Command{
 		Use:           "printing-press",
@@ -45,6 +65,7 @@ func Execute() error {
 	rootCmd.AddCommand(newVisionCmd())
 	rootCmd.AddCommand(newVersionCmd())
 	rootCmd.AddCommand(newPrintCmd())
+	rootCmd.AddCommand(newCatalogCmd())
 
 	return rootCmd.Execute()
 }
@@ -441,14 +462,27 @@ func fetchOrCacheSpec(specURL string, refresh bool, skipCache bool) ([]byte, err
 }
 
 func newVersionCmd() *cobra.Command {
-	return &cobra.Command{
+	var asJSON bool
+
+	cmd := &cobra.Command{
 		Use:     "version",
 		Short:   "Print version",
 		Example: `  printing-press version`,
-		Run: func(cmd *cobra.Command, args []string) {
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if asJSON {
+				return json.NewEncoder(os.Stdout).Encode(map[string]string{
+					"version": version,
+					"go":      runtime.Version(),
+				})
+			}
 			fmt.Printf("printing-press %s\n", version)
+			return nil
 		},
 	}
+
+	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+
+	return cmd
 }
 
 func newPrintCmd() *cobra.Command {
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 96c3c223..ed60b89d 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -104,7 +104,9 @@ func runGoCommand(t *testing.T, dir string, args ...string) {
 
 	cmd := exec.Command("go", args...)
 	cmd.Dir = dir
-	cmd.Env = append(os.Environ(), "GOCACHE="+filepath.Join(dir, ".cache", "go-build"))
+	cacheDir, err := goBuildCacheDir(dir)
+	require.NoError(t, err)
+	cmd.Env = append(os.Environ(), "GOCACHE="+cacheDir)
 	output, err := cmd.CombinedOutput()
 	require.NoError(t, err, string(output))
 }
diff --git a/internal/generator/validate.go b/internal/generator/validate.go
index 30820a4d..03798b39 100644
--- a/internal/generator/validate.go
+++ b/internal/generator/validate.go
@@ -19,6 +19,8 @@ type validationGate struct {
 	run  func() error
 }
 
+const qualityGateTimeout = 5 * time.Minute
+
 func (g *Generator) Validate() error {
 	binPath := filepath.Join(g.OutputDir, naming.ValidationBinary(g.Spec.Name))
 	if err := artifacts.CleanupGeneratedCLI(g.OutputDir, artifacts.CleanupOptions{
@@ -40,28 +42,28 @@ func (g *Generator) Validate() error {
 		{
 			name: "go mod tidy",
 			run: func() error {
-				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "mod", "tidy")
+				_, err := runCommand(g.OutputDir, qualityGateTimeout, "go", "mod", "tidy")
 				return err
 			},
 		},
 		{
 			name: "go vet ./...",
 			run: func() error {
-				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "vet", "./...")
+				_, err := runCommand(g.OutputDir, qualityGateTimeout, "go", "vet", "./...")
 				return err
 			},
 		},
 		{
 			name: "go build ./...",
 			run: func() error {
-				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "build", "./...")
+				_, err := runCommand(g.OutputDir, qualityGateTimeout, "go", "build", "./...")
 				return err
 			},
 		},
 		{
 			name: "build runnable binary",
 			run: func() error {
-				_, err := runCommand(g.OutputDir, 2*time.Minute, "go", "build", "-o", binPath, "./cmd/"+naming.CLI(g.Spec.Name))
+				_, err := runCommand(g.OutputDir, qualityGateTimeout, "go", "build", "-o", binPath, "./cmd/"+naming.CLI(g.Spec.Name))
 				return err
 			},
 		},
diff --git a/internal/pipeline/contracts_test.go b/internal/pipeline/contracts_test.go
index 25419b11..be933e5c 100644
--- a/internal/pipeline/contracts_test.go
+++ b/internal/pipeline/contracts_test.go
@@ -58,12 +58,26 @@ func TestSkillSetupBlocksMatchWorkspaceContract(t *testing.T) {
 			full := readContractFile(t, tt.path)
 			block := extractContractBlock(t, full)
 
-			assert.Contains(t, block, `REPO_ROOT="$(git rev-parse --show-toplevel)"`)
-			assert.Contains(t, block, `PRESS_BASE="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"`)
-			assert.Contains(t, block, `PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-8)"`)
+			// Binary on PATH check
+			assert.Contains(t, block, `command -v printing-press`)
+			// Version comment for frontmatter parity
+			assert.Contains(t, block, `# min-binary-version:`)
+			// Symlink-safe canonicalization
+			assert.Contains(t, block, `pwd -P`)
+
+			// Core workspace variables
 			assert.Contains(t, block, `PRESS_HOME="$HOME/printing-press"`)
+			assert.Contains(t, block, `PRESS_SCOPE=`)
 			assert.Contains(t, block, `PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"`)
 			assert.Contains(t, block, `PRESS_LIBRARY="$PRESS_HOME/library"`)
+
+			// Must NOT reference repo-local binary or build
+			assert.NotContains(t, block, `./printing-press`)
+			assert.NotContains(t, block, `go build`)
+			// Must NOT contain REPO_ROOT or cd to repo
+			assert.NotContains(t, block, `REPO_ROOT`)
+			assert.NotContains(t, block, `cd "$REPO_ROOT"`)
+
 			assert.NotContains(t, full, "~/cli-printing-press")
 
 			if tt.expectsManuscripts {
diff --git a/internal/pipeline/fullrun.go b/internal/pipeline/fullrun.go
index 15b01f19..a30dfd33 100644
--- a/internal/pipeline/fullrun.go
+++ b/internal/pipeline/fullrun.go
@@ -113,7 +113,7 @@ func MakeBestCLI(apiName, level, specFlag, specURL, outputDir, pressBinary strin
 	}
 
 	// Step 1: Research
-	research, err := RunResearch(apiName, "catalog", researchDir)
+	research, err := RunResearch(apiName, researchDir)
 	if err != nil {
 		result.ResearchError = err.Error()
 		result.Errors = append(result.Errors, fmt.Sprintf("research: %v", err))
diff --git a/internal/pipeline/research.go b/internal/pipeline/research.go
index 6ca34013..88637cfa 100644
--- a/internal/pipeline/research.go
+++ b/internal/pipeline/research.go
@@ -13,6 +13,7 @@ import (
 	"strings"
 	"time"
 
+	catalogfs "github.com/mvanhorn/cli-printing-press/catalog"
 	"github.com/mvanhorn/cli-printing-press/internal/catalog"
 	"github.com/mvanhorn/cli-printing-press/internal/llm"
 )
@@ -63,14 +64,14 @@ type Alternative struct {
 // RunResearch executes the research phase for an API.
 // It checks the catalog for known alternatives, then optionally
 // queries the GitHub API for additional CLI tools.
-func RunResearch(apiName, catalogDir, pipelineDir string) (*ResearchResult, error) {
+func RunResearch(apiName, pipelineDir string) (*ResearchResult, error) {
 	result := &ResearchResult{
 		APIName:      apiName,
 		ResearchedAt: time.Now(),
 	}
 
 	// Step 1: Check catalog for known alternatives
-	catalogAlts := loadCatalogAlternatives(apiName, catalogDir)
+	catalogAlts := loadCatalogAlternatives(apiName)
 	for _, alt := range catalogAlts {
 		result.Alternatives = append(result.Alternatives, Alternative{
 			Name:     alt.Name,
@@ -167,16 +168,8 @@ func LoadResearch(pipelineDir string) (*ResearchResult, error) {
 	return &r, nil
 }
 
-func loadCatalogAlternatives(apiName, catalogDir string) []catalog.KnownAlt {
-	if catalogDir == "" {
-		catalogDir = "catalog"
-	}
-	path := filepath.Join(catalogDir, apiName+".yaml")
-	data, err := os.ReadFile(path)
-	if err != nil {
-		return nil
-	}
-	entry, err := catalog.ParseEntry(data)
+func loadCatalogAlternatives(apiName string) []catalog.KnownAlt {
+	entry, err := catalog.LookupFS(catalogfs.FS, apiName)
 	if err != nil {
 		return nil
 	}
diff --git a/skills/printing-press-catalog/SKILL.md b/skills/printing-press-catalog/SKILL.md
index c16a1991..aa2f37e2 100644
--- a/skills/printing-press-catalog/SKILL.md
+++ b/skills/printing-press-catalog/SKILL.md
@@ -2,6 +2,8 @@
 name: printing-press-catalog
 description: Browse and install pre-built Go CLIs for popular APIs from the catalog
 version: 0.4.0
+min-binary-version: "0.2.0"
+deprecated: true
 allowed-tools:
   - Bash
   - Read
@@ -14,6 +16,8 @@ allowed-tools:
 
 # /printing-press-catalog
 
+> **Deprecated:** This skill is superseded by the main `/printing-press` skill, which now checks the built-in catalog automatically. Use `/printing-press <API>` instead. For browsing the catalog, use `printing-press catalog list` in your terminal.
+
 Browse and install pre-built Go CLIs for popular APIs.
 
 ## Quick Start
@@ -27,22 +31,36 @@ Browse and install pre-built Go CLIs for popular APIs.
 ## Prerequisites
 
 - Go 1.21+ installed
-- Running from inside the cli-printing-press repo (or a worktree of it)
+- `printing-press` binary on PATH (install with `go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest`)
 
 ## Setup
 
-Before any other commands, resolve and cd to the repo root. This ensures all relative paths work even from subdirectories or worktrees:
+Before any other commands, run the setup contract to verify the printing-press binary is on PATH and initialize scope variables:
 
 <!-- PRESS_SETUP_CONTRACT_START -->
 ```bash
-REPO_ROOT="$(git rev-parse --show-toplevel)"
-cd "$REPO_ROOT"
+# min-binary-version: 0.2.0
+if ! command -v printing-press >/dev/null 2>&1; then
+  if [ -x "$HOME/go/bin/printing-press" ]; then
+    echo "printing-press found at ~/go/bin/printing-press but not on PATH."
+    echo "Add GOPATH/bin to your PATH:  export PATH=\"\$HOME/go/bin:\$PATH\""
+  else
+    echo "printing-press binary not found."
+    echo "Install with:  go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest"
+  fi
+  return 1 2>/dev/null || exit 1
+fi
+
+# Derive scope: prefer git repo root, fall back to CWD
+_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
+_scope_dir="$(cd "$_scope_dir" && pwd -P)"
 
-PRESS_BASE="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
+PRESS_BASE="$(basename "$_scope_dir" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
 if [ -z "$PRESS_BASE" ]; then
   PRESS_BASE="workspace"
 fi
-PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-8)"
+
+PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$_scope_dir" | shasum -a 256 | cut -c1-8)"
 PRESS_HOME="$HOME/printing-press"
 PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"
 PRESS_LIBRARY="$PRESS_HOME/library"
@@ -51,7 +69,7 @@ mkdir -p "$PRESS_RUNSTATE" "$PRESS_LIBRARY"
 ```
 <!-- PRESS_SETUP_CONTRACT_END -->
 
-If `git rev-parse` fails, you are not inside a cli-printing-press checkout. Stop and tell the user.
+After running the setup contract, check binary version compatibility. Read the `min-binary-version` field from this skill's YAML frontmatter. Run `printing-press version --json` and parse the version from the output. Compare it to `min-binary-version` using semver rules. If the installed binary is older than the minimum, warn the user: "printing-press binary vX.Y.Z is older than the minimum required vA.B.C. Run `go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest` to update." Continue anyway but surface the warning prominently.
 
 Generated CLIs are published to `$PRESS_LIBRARY/`, not to the repo.
 
@@ -107,11 +125,7 @@ When invoked with `install <name>`:
 2. If file doesn't exist, show error: "No catalog entry for '<name>'. Run /printing-press-catalog to see available CLIs."
 3. Extract spec_url from the catalog entry
 4. Show preview: "Installing <display_name> CLI from <spec_url>"
-5. Build the printing-press binary if needed:
-   ```bash
-   go build -o ./printing-press ./cmd/printing-press
-   ```
-6. Download the spec and generate:
+5. Download the spec and generate:
    ```bash
    curl -sL -o /tmp/catalog-spec-$$.yaml "<spec_url>"
    OUTPUT_BASE="$PRESS_LIBRARY/<name>-pp-cli"
@@ -121,7 +135,7 @@ When invoked with `install <name>`:
      OUTPUT_DIR="${OUTPUT_BASE}-$i"
      i=$((i + 1))
    done
-   ./printing-press generate \
+   printing-press generate \
      --spec /tmp/catalog-spec-$$.yaml \
      --output "$OUTPUT_DIR" \
      --validate
diff --git a/skills/printing-press-score/SKILL.md b/skills/printing-press-score/SKILL.md
index e28231e0..dcf120ae 100644
--- a/skills/printing-press-score/SKILL.md
+++ b/skills/printing-press-score/SKILL.md
@@ -2,6 +2,7 @@
 name: printing-press-score
 description: Score a generated CLI against the Steinberger bar, compare two CLIs side-by-side
 version: 0.1.0
+min-binary-version: "0.2.0"
 allowed-tools:
   - Bash
   - Read
@@ -26,31 +27,47 @@ Score generated CLIs against the Steinberger bar. Supports rescoring, scoring by
 ## Prerequisites
 
 - Go 1.21+ installed
-- Running from inside the cli-printing-press repo (or a worktree of it)
+- `printing-press` binary on PATH (install with `go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest`)
 
-## Step 0: Resolve Repo Root
+## Step 0: Setup
 
-Before any other commands, resolve and cd to the repo root. This ensures all relative paths work even from subdirectories or worktrees:
+Before any other commands, run the setup contract to verify the printing-press binary is on PATH and initialize scope variables:
 
 <!-- PRESS_SETUP_CONTRACT_START -->
 ```bash
-REPO_ROOT="$(git rev-parse --show-toplevel)"
-cd "$REPO_ROOT"
+# min-binary-version: 0.2.0
+if ! command -v printing-press >/dev/null 2>&1; then
+  if [ -x "$HOME/go/bin/printing-press" ]; then
+    echo "printing-press found at ~/go/bin/printing-press but not on PATH."
+    echo "Add GOPATH/bin to your PATH:  export PATH=\"\$HOME/go/bin:\$PATH\""
+  else
+    echo "printing-press binary not found."
+    echo "Install with:  go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest"
+  fi
+  return 1 2>/dev/null || exit 1
+fi
+
+# Derive scope: prefer git repo root, fall back to CWD
+_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
+_scope_dir="$(cd "$_scope_dir" && pwd -P)"
 
-PRESS_BASE="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
+PRESS_BASE="$(basename "$_scope_dir" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
 if [ -z "$PRESS_BASE" ]; then
   PRESS_BASE="workspace"
 fi
-PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-8)"
+
+PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$_scope_dir" | shasum -a 256 | cut -c1-8)"
 PRESS_HOME="$HOME/printing-press"
 PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"
-PRESS_CURRENT="$PRESS_RUNSTATE/current"
 PRESS_LIBRARY="$PRESS_HOME/library"
 PRESS_MANUSCRIPTS="$PRESS_HOME/manuscripts"
+PRESS_CURRENT="$PRESS_RUNSTATE/current"
+
+mkdir -p "$PRESS_RUNSTATE" "$PRESS_LIBRARY" "$PRESS_MANUSCRIPTS" "$PRESS_CURRENT"
 ```
 <!-- PRESS_SETUP_CONTRACT_END -->
 
-If `git rev-parse` fails, you are not inside a cli-printing-press checkout. Stop and tell the user.
+After running the setup contract, check binary version compatibility. Read the `min-binary-version` field from this skill's YAML frontmatter. Run `printing-press version --json` and parse the version from the output. Compare it to `min-binary-version` using semver rules. If the installed binary is older than the minimum, warn the user: "printing-press binary vX.Y.Z is older than the minimum required vA.B.C. Run `go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest` to update." Continue anyway but surface the warning prominently.
 
 Current-run state is resolved from `$PRESS_RUNSTATE`. Published CLIs are resolved from `$PRESS_LIBRARY`. Archived manuscripts are resolved from `$PRESS_MANUSCRIPTS`.
 
@@ -111,24 +128,14 @@ For each resolved CLI directory, find the OpenAPI spec:
 2. If not found, scan `$PRESS_RUNSTATE/runs/*/state.json` files for one matching this CLI's directory. Read its `spec_path` field. If that file exists on disk, use it.
 3. If no spec found, **proceed without `--spec`**. Note to the user: "No spec found — spec-derived dimensions will be marked N/A and omitted from the denominator. Provide a spec path for full scoring."
 
-## Step 4: Build the Binary
-
-Before running the scorecard, build the printing-press binary:
-
-```bash
-go build -o ./printing-press ./cmd/printing-press
-```
-
-If the build fails, report the error and stop.
-
-## Step 5: Run Scorecard
+## Step 4: Run Scorecard
 
 ### Single Score Mode
 
 Run the scorecard command:
 
 ```bash
-./printing-press scorecard --dir <resolved-path> --json
+printing-press scorecard --dir <resolved-path> --json
 ```
 
 If a spec was found, add `--spec <spec-path>`.
@@ -174,15 +181,15 @@ Run **both** scorecard commands in **parallel** using two simultaneous Bash tool
 
 ```bash
 # Call 1:
-./printing-press scorecard --dir <path1> --spec <spec1> --json
+printing-press scorecard --dir <path1> --spec <spec1> --json
 
 # Call 2:
-./printing-press scorecard --dir <path2> --spec <spec2> --json
+printing-press scorecard --dir <path2> --spec <spec2> --json
 ```
 
 Parse both JSON outputs.
 
-## Step 6: Render Output
+## Step 5: Render Output
 
 ### Single Score Table
 
@@ -259,7 +266,7 @@ Domain Correctness (Tier 2)
 
 ## Error Handling
 
-- If the binary build fails → report build error, stop
+- If the printing-press binary is not on PATH → show install instructions: `go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest`
 - If the scorecard command fails → report the error with the full stderr output
 - If a CLI directory doesn't exist → report which name couldn't be resolved
 - If JSON parsing fails → show the raw output and report the parsing error
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 92f1b376..cd4e3082 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -2,6 +2,7 @@
 name: printing-press
 description: Generate a ship-ready CLI for an API with a lean research -> generate -> build -> shipcheck loop.
 version: 2.0.0
+min-binary-version: "0.2.0"
 allowed-tools:
   - Bash
   - Read
@@ -77,7 +78,7 @@ If the arguments start with `emboss`, this is a second-pass improvement cycle fo
 Use the built-in audit command:
 
 ```bash
-cd "$REPO_ROOT" && ./printing-press emboss --dir <cli-dir> --spec <spec-path> --audit-only
+printing-press emboss --dir <cli-dir> --spec <spec-path> --audit-only
 ```
 
 Emboss is:
@@ -107,15 +108,28 @@ Before doing anything else:
 
 <!-- PRESS_SETUP_CONTRACT_START -->
 ```bash
-REPO_ROOT="$(git rev-parse --show-toplevel)"
-cd "$REPO_ROOT"
+# min-binary-version: 0.2.0
+if ! command -v printing-press >/dev/null 2>&1; then
+  if [ -x "$HOME/go/bin/printing-press" ]; then
+    echo "printing-press found at ~/go/bin/printing-press but not on PATH."
+    echo "Add GOPATH/bin to your PATH:  export PATH=\"\$HOME/go/bin:\$PATH\""
+  else
+    echo "printing-press binary not found."
+    echo "Install with:  go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest"
+  fi
+  return 1 2>/dev/null || exit 1
+fi
+
+# Derive scope: prefer git repo root, fall back to CWD
+_scope_dir="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")"
+_scope_dir="$(cd "$_scope_dir" && pwd -P)"
 
-PRESS_BASE="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
+PRESS_BASE="$(basename "$_scope_dir" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g; s/^-+//; s/-+$//')"
 if [ -z "$PRESS_BASE" ]; then
   PRESS_BASE="workspace"
 fi
 
-PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$REPO_ROOT" | shasum -a 256 | cut -c1-8)"
+PRESS_SCOPE="$PRESS_BASE-$(printf '%s' "$_scope_dir" | shasum -a 256 | cut -c1-8)"
 PRESS_HOME="$HOME/printing-press"
 PRESS_RUNSTATE="$PRESS_HOME/.runstate/$PRESS_SCOPE"
 PRESS_LIBRARY="$PRESS_HOME/library"
@@ -126,6 +140,8 @@ mkdir -p "$PRESS_RUNSTATE" "$PRESS_LIBRARY" "$PRESS_MANUSCRIPTS" "$PRESS_CURRENT
 ```
 <!-- PRESS_SETUP_CONTRACT_END -->
 
+After running the setup contract, check binary version compatibility. Read the `min-binary-version` field from this skill's YAML frontmatter. Run `printing-press version --json` and parse the version from the output. Compare it to `min-binary-version` using semver rules. If the installed binary is older than the minimum, warn the user: "printing-press binary vX.Y.Z is older than the minimum required vA.B.C. Run `go install github.com/mvanhorn/cli-printing-press/cmd/printing-press@latest` to update." Continue anyway but surface the warning prominently.
+
 After you know `<api>`, initialize the run-scoped artifact paths:
 
 ```bash
@@ -207,6 +223,20 @@ Always resolve the API key gate before moving to Phase 1.
 
 ## Phase 1: Research Brief
 
+Before starting research, check if the API has a built-in catalog entry:
+
+```bash
+printing-press catalog show <api> --json 2>/dev/null
+```
+
+If the catalog has an entry for this API, present the user with a choice:
+
+> "<API> is in the built-in catalog (spec: <spec_url>). Use the catalog config to skip discovery, or run full discovery?"
+
+- If catalog config: use the spec_url from the catalog entry, skip the research/discovery phase
+- If full discovery: proceed with the normal research workflow
+- If the catalog doesn't have this API: proceed normally without mentioning the catalog
+
 Write one build-driving brief, not a stack of phase essays.
 
 The brief must answer:
@@ -340,7 +370,7 @@ Use the resolved spec source and generate immediately.
 OpenAPI / internal YAML:
 
 ```bash
-cd "$REPO_ROOT" && ./printing-press generate \
+printing-press generate \
   --spec <spec-path-or-url> \
   --output "$PRESS_LIBRARY/<api>-pp-cli" \
   --force --lenient --validate
@@ -349,7 +379,7 @@ cd "$REPO_ROOT" && ./printing-press generate \
 Docs-only:
 
 ```bash
-cd "$REPO_ROOT" && ./printing-press generate \
+printing-press generate \
   --docs <docs-url> \
   --name <api> \
   --output "$PRESS_LIBRARY/<api>-pp-cli" \
@@ -435,10 +465,9 @@ Include:
 Run one combined verification block.
 
 ```bash
-cd "$REPO_ROOT"
-./printing-press dogfood   --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec>
-./printing-press verify    --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec> --fix
-./printing-press scorecard --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec>
+printing-press dogfood   --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec>
+printing-press verify    --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec> --fix
+printing-press scorecard --dir "$PRESS_LIBRARY/<api>-pp-cli" --spec <same-spec>
 ```
 
 Interpretation:
@@ -493,7 +522,7 @@ Write:
 
 ### When to use `printing-press print`
 
-Use `./printing-press print <api>` only when the user explicitly wants a resumable on-disk pipeline with phase seeds. It is optional.
+Use `printing-press print <api>` only when the user explicitly wants a resumable on-disk pipeline with phase seeds. It is optional.
 
 The fast path for `/printing-press <API>` is:
 - brief
diff --git a/skills/printing-press/references/scorecard-patterns.md b/skills/printing-press/references/scorecard-patterns.md
index 408e99fa..42544b62 100644
--- a/skills/printing-press/references/scorecard-patterns.md
+++ b/skills/printing-press/references/scorecard-patterns.md
@@ -6,7 +6,7 @@ This table maps each scorecard dimension to the exact file and string patterns m
 
 Each dimension is 0-10. Total is 0-90. Grade: A >= 80%, B >= 65%, C >= 50%, D >= 35%, F < 35%.
 
-Run the scorecard: `./printing-press scorecard --dir ./<api>-cli`
+Run the scorecard: `printing-press scorecard --dir ./<api>-cli`
 
 ## Dimension Reference
 

← 4f4dd9c4 feat(websniff): add APISpec generator and sniff CLI command  ·  back to Cli Printing Press  ·  docs(cli): update README install instructions for standalone 46a94efe →