← 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
M .github/workflows/validate-catalog.ymlA catalog/catalog.goA docs/brainstorms/2026-03-28-repo-decoupling-requirements.mdA docs/plans/2026-03-28-001-refactor-repo-decoupling-plan.mdM internal/catalog/catalog.goM internal/catalog/catalog_test.goA internal/cli/catalog.goA internal/cli/catalog_test.goM internal/cli/root.goM internal/generator/generator_test.goM internal/generator/validate.goM internal/pipeline/contracts_test.goM internal/pipeline/fullrun.goM internal/pipeline/research.goM skills/printing-press-catalog/SKILL.mdM skills/printing-press-score/SKILL.mdM skills/printing-press/SKILL.mdM skills/printing-press/references/scorecard-patterns.md
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 →