← back to Cli Printing Press
feat(cli): add publish skill to ship CLIs to printing-press-library (#54)
bf14db97ae866a6aaf5cab6830458ba9024d0361 · 2026-03-29 14:35:01 -0700 · Trevin Chow
* feat(cli): expand catalog taxonomy to 14 categories
Replace the 7-category system with a 14-category single-level taxonomy:
developer-tools, monitoring, cloud, project-management, productivity,
social-and-messaging, sales-and-crm, marketing, payments, auth,
commerce, ai, devices, other.
Reassign all 17 catalog entries to new categories:
- email -> marketing (sendgrid)
- crm -> sales-and-crm (hubspot, pipedrive)
- communication -> social-and-messaging (discord, front, telegram, twilio)
- developer-tools -> cloud (digitalocean)
- developer-tools -> monitoring (sentry)
The example category is retained for petstore.yaml test fixture but
excluded from the user-facing error message.
* feat(cli): add category and description fields to CLI manifest
Add Category and Description to CLIManifest struct with omitempty.
Both fields are populated from the catalog entry during
writeCLIManifestForPublish when available. These fields make the
CLI folder self-describing for the publish workflow.
* feat(cli): add library list command
Add `printing-press library list` to list all CLIs in the local
library with manifest metadata. Supports --json for structured
output. Scans ~/printing-press/library/ for CLI directories,
reads .printing-press.json when present, sorts by modification
time (most recent first). Gracefully handles missing or malformed
manifests.
* feat(cli): add publish validate and package commands
Add `printing-press publish validate --dir <path> --json` to run
pre-publish checks (manifest, go mod tidy, go vet, go build,
--help, --version, manuscripts). Returns structured JSON with
per-check pass/fail and captures --help output for PR descriptions.
Manuscripts check is warn-only per requirements.
Add `printing-press publish package --dir <path> --category <cat>
--target <staging> --json` to assemble a publishable folder with
CLI source and .manuscripts/ from the most recent run.
Add ExitPublishError (5) exit code for publish-specific failures.
* feat(skills): add printing-press-publish skill
Create /printing-press publish skill that orchestrates the full
publish flow: name resolution, category assignment, validation
via CLI binary, packaging, managed clone at ~/.publish-repo/,
and PR creation via gh CLI.
Includes best-in-class PR description template with --help output,
manuscript links, validation results table, and explicit gaps section.
Add skill to contracts_test.go for setup contract validation.
* docs(cli): add publish skill requirements and implementation plan
Requirements doc covers: 14-category taxonomy, manifest category +
description fields, CLI/skill responsibility split, managed clone,
name resolution, PR description format, library repo structure,
and trust verification (future scope).
Implementation plan covers 6 units: catalog taxonomy expansion,
manifest fields, library list command, publish validate/package
commands, library repo scaffolding, and the publish skill itself.
* feat(cli): add media-and-entertainment category
Add 15th category for streaming, sports, video, music, and content
platform APIs (e.g., Spotify, YouTube, ESPN, Netflix). This tightens
social-and-messaging back to communication-focused platforms and
avoids pushing media APIs into the catch-all other category.
* docs(cli): update AGENTS.md category list to match new taxonomy
Sync the documented valid categories with the expanded 15-category
taxonomy. Removes old categories (email, crm, communication) and
adds new ones (monitoring, cloud, productivity, social-and-messaging,
sales-and-crm, marketing, commerce, ai, media-and-entertainment,
devices, other).
* fix(cli): fix publish validate correctness issues
- Replace git-based go mod tidy check with file snapshot comparison.
Library CLIs aren't in git repos, so the git diff approach silently
passed while mutating go.mod/go.sum. Now snapshots files before
tidy, compares after, and always restores originals.
- Fix publish package --json exit code: return ExitPublishError on
validation failure regardless of output mode.
- Fix findBuiltBinary ordering: check existing candidate paths before
attempting builds. Add root-level build fallback for CLIs not using
cmd/<name>/ layout.
- Fix WARN/FAIL precedence: FAIL takes priority over WARN in
human-readable output.
* refactor(cli): maintainability fixes from review
- Remove dead no-op code in library.go human output
- Generate category error message from validCategories map instead
of hardcoding. Prevents drift between map and error message.
* test(cli): strengthen publish command test coverage
- Assert all 7 check names explicitly in TestPublishValidateJSONHasAllChecks
- Add TestPublishValidateExitCode to verify ExitPublishError code
- Add separate tests for each missing required flag on publish package
(--dir, --category, --target)
* fix(cli): add timeouts to exec.Command calls and fix skill recovery
- Add context.WithTimeout to all exec.Command calls in publish.go:
2min for go commands, 15s for binary --help/--version checks.
Prevents indefinite hangs from slow module proxies or buggy CLIs.
- Fix managed clone freshen: use git reset --hard origin/main instead
of git pull. Managed clone should always match upstream exactly.
Add clone health check (git rev-parse --is-inside-work-tree).
- Fix branch overwrite: use git checkout -B (force-create) when
overwriting an existing branch to handle committed-but-not-pushed
state from interrupted previous publishes.
* fix(cli): skip empty manuscript runs and clean staging on failure
- findMostRecentRun now checks that run directories actually contain
files before selecting them. Empty directories (from interrupted
archives) are skipped. Prevents ManuscriptsIncluded=true when
manuscripts are actually empty.
- Add staging cleanup instruction to skill error handling: remove
the staging directory if Steps 6-7 fail, preventing accumulation
of full CLI copies in temp directories across retries.
* fix(cli): prevent binary staging and category path traversal
- Clean up compiled binaries after validation. findBuiltBinary now
reports whether it created the binary, and runValidation defers
os.Remove for created binaries. Prevents CopyDir from staging
compiled artifacts into the library payload.
- Validate --category is a simple slug with no path separators or
'..'. Also verify the resolved staging path is under the target
directory as defense in depth. Prevents path traversal via
crafted category values like '../../../escape'.
* docs(cli): add compound solutions for validation mutation and path traversal
Two solutions documented from the publish skill review findings:
1. best-practices/validation-must-not-mutate-source-directory: Pattern
for non-destructive validation using snapshot-compare-restore and
temp directory builds. Prevents CopyDir from staging artifacts.
2. security-issues/filepath-join-traversal-with-user-input: Belt-and-
suspenders pattern for user input in filepath.Join — input validation
plus resolved-path containment check.
* feat(cli): export PublicCategories, refactor binary validation, add publish package happy-path test
- Export PublicCategories() and IsPublicCategory() from catalog package
for use by other packages and skill validation
- Refactor findBuiltBinary into buildValidationBinary that builds to a
temp directory (.publish-validate-*) instead of the source tree
- Add snapshotFiles/buildArtifactCandidates for defense-in-depth
artifact cleanup
- Add TestPublishPackageDoesNotStageCompiledBinary with
writePublishableTestCLI helper for end-to-end package validation
- Add contracts test for publish skill upstream/overwrite flow
- Update skill SKILL.md with upstream remote and force-with-lease push
Files touched
M AGENTS.mdM catalog/digitalocean.yamlM catalog/discord.yamlM catalog/front.yamlM catalog/hubspot.yamlM catalog/pipedrive.yamlM catalog/sendgrid.yamlM catalog/sentry.yamlM catalog/telegram.yamlM catalog/twilio.yamlA docs/brainstorms/2026-03-29-publish-skill-requirements.mdA docs/plans/2026-03-29-001-feat-publish-skill-plan.mdA docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.mdA docs/solutions/security-issues/filepath-join-traversal-with-user-input-2026-03-29.mdM internal/catalog/catalog.goM internal/catalog/catalog_test.goM internal/cli/exitcodes.goA internal/cli/library.goA internal/cli/library_test.goA internal/cli/publish.goA internal/cli/publish_test.goM internal/cli/root.goM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.goM internal/pipeline/contracts_test.goM internal/pipeline/publish.goA skills/printing-press-publish/SKILL.md
Diff
commit bf14db97ae866a6aaf5cab6830458ba9024d0361
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun Mar 29 14:35:01 2026 -0700
feat(cli): add publish skill to ship CLIs to printing-press-library (#54)
* feat(cli): expand catalog taxonomy to 14 categories
Replace the 7-category system with a 14-category single-level taxonomy:
developer-tools, monitoring, cloud, project-management, productivity,
social-and-messaging, sales-and-crm, marketing, payments, auth,
commerce, ai, devices, other.
Reassign all 17 catalog entries to new categories:
- email -> marketing (sendgrid)
- crm -> sales-and-crm (hubspot, pipedrive)
- communication -> social-and-messaging (discord, front, telegram, twilio)
- developer-tools -> cloud (digitalocean)
- developer-tools -> monitoring (sentry)
The example category is retained for petstore.yaml test fixture but
excluded from the user-facing error message.
* feat(cli): add category and description fields to CLI manifest
Add Category and Description to CLIManifest struct with omitempty.
Both fields are populated from the catalog entry during
writeCLIManifestForPublish when available. These fields make the
CLI folder self-describing for the publish workflow.
* feat(cli): add library list command
Add `printing-press library list` to list all CLIs in the local
library with manifest metadata. Supports --json for structured
output. Scans ~/printing-press/library/ for CLI directories,
reads .printing-press.json when present, sorts by modification
time (most recent first). Gracefully handles missing or malformed
manifests.
* feat(cli): add publish validate and package commands
Add `printing-press publish validate --dir <path> --json` to run
pre-publish checks (manifest, go mod tidy, go vet, go build,
--help, --version, manuscripts). Returns structured JSON with
per-check pass/fail and captures --help output for PR descriptions.
Manuscripts check is warn-only per requirements.
Add `printing-press publish package --dir <path> --category <cat>
--target <staging> --json` to assemble a publishable folder with
CLI source and .manuscripts/ from the most recent run.
Add ExitPublishError (5) exit code for publish-specific failures.
* feat(skills): add printing-press-publish skill
Create /printing-press publish skill that orchestrates the full
publish flow: name resolution, category assignment, validation
via CLI binary, packaging, managed clone at ~/.publish-repo/,
and PR creation via gh CLI.
Includes best-in-class PR description template with --help output,
manuscript links, validation results table, and explicit gaps section.
Add skill to contracts_test.go for setup contract validation.
* docs(cli): add publish skill requirements and implementation plan
Requirements doc covers: 14-category taxonomy, manifest category +
description fields, CLI/skill responsibility split, managed clone,
name resolution, PR description format, library repo structure,
and trust verification (future scope).
Implementation plan covers 6 units: catalog taxonomy expansion,
manifest fields, library list command, publish validate/package
commands, library repo scaffolding, and the publish skill itself.
* feat(cli): add media-and-entertainment category
Add 15th category for streaming, sports, video, music, and content
platform APIs (e.g., Spotify, YouTube, ESPN, Netflix). This tightens
social-and-messaging back to communication-focused platforms and
avoids pushing media APIs into the catch-all other category.
* docs(cli): update AGENTS.md category list to match new taxonomy
Sync the documented valid categories with the expanded 15-category
taxonomy. Removes old categories (email, crm, communication) and
adds new ones (monitoring, cloud, productivity, social-and-messaging,
sales-and-crm, marketing, commerce, ai, media-and-entertainment,
devices, other).
* fix(cli): fix publish validate correctness issues
- Replace git-based go mod tidy check with file snapshot comparison.
Library CLIs aren't in git repos, so the git diff approach silently
passed while mutating go.mod/go.sum. Now snapshots files before
tidy, compares after, and always restores originals.
- Fix publish package --json exit code: return ExitPublishError on
validation failure regardless of output mode.
- Fix findBuiltBinary ordering: check existing candidate paths before
attempting builds. Add root-level build fallback for CLIs not using
cmd/<name>/ layout.
- Fix WARN/FAIL precedence: FAIL takes priority over WARN in
human-readable output.
* refactor(cli): maintainability fixes from review
- Remove dead no-op code in library.go human output
- Generate category error message from validCategories map instead
of hardcoding. Prevents drift between map and error message.
* test(cli): strengthen publish command test coverage
- Assert all 7 check names explicitly in TestPublishValidateJSONHasAllChecks
- Add TestPublishValidateExitCode to verify ExitPublishError code
- Add separate tests for each missing required flag on publish package
(--dir, --category, --target)
* fix(cli): add timeouts to exec.Command calls and fix skill recovery
- Add context.WithTimeout to all exec.Command calls in publish.go:
2min for go commands, 15s for binary --help/--version checks.
Prevents indefinite hangs from slow module proxies or buggy CLIs.
- Fix managed clone freshen: use git reset --hard origin/main instead
of git pull. Managed clone should always match upstream exactly.
Add clone health check (git rev-parse --is-inside-work-tree).
- Fix branch overwrite: use git checkout -B (force-create) when
overwriting an existing branch to handle committed-but-not-pushed
state from interrupted previous publishes.
* fix(cli): skip empty manuscript runs and clean staging on failure
- findMostRecentRun now checks that run directories actually contain
files before selecting them. Empty directories (from interrupted
archives) are skipped. Prevents ManuscriptsIncluded=true when
manuscripts are actually empty.
- Add staging cleanup instruction to skill error handling: remove
the staging directory if Steps 6-7 fail, preventing accumulation
of full CLI copies in temp directories across retries.
* fix(cli): prevent binary staging and category path traversal
- Clean up compiled binaries after validation. findBuiltBinary now
reports whether it created the binary, and runValidation defers
os.Remove for created binaries. Prevents CopyDir from staging
compiled artifacts into the library payload.
- Validate --category is a simple slug with no path separators or
'..'. Also verify the resolved staging path is under the target
directory as defense in depth. Prevents path traversal via
crafted category values like '../../../escape'.
* docs(cli): add compound solutions for validation mutation and path traversal
Two solutions documented from the publish skill review findings:
1. best-practices/validation-must-not-mutate-source-directory: Pattern
for non-destructive validation using snapshot-compare-restore and
temp directory builds. Prevents CopyDir from staging artifacts.
2. security-issues/filepath-join-traversal-with-user-input: Belt-and-
suspenders pattern for user input in filepath.Join — input validation
plus resolved-path containment check.
* feat(cli): export PublicCategories, refactor binary validation, add publish package happy-path test
- Export PublicCategories() and IsPublicCategory() from catalog package
for use by other packages and skill validation
- Refactor findBuiltBinary into buildValidationBinary that builds to a
temp directory (.publish-validate-*) instead of the source tree
- Add snapshotFiles/buildArtifactCandidates for defense-in-depth
artifact cleanup
- Add TestPublishPackageDoesNotStageCompiledBinary with
writePublishableTestCLI helper for end-to-end package validation
- Add contracts test for publish skill upstream/overwrite flow
- Update skill SKILL.md with upstream remote and force-with-lease push
---
AGENTS.md | 2 +-
catalog/digitalocean.yaml | 2 +-
catalog/discord.yaml | 2 +-
catalog/front.yaml | 2 +-
catalog/hubspot.yaml | 2 +-
catalog/pipedrive.yaml | 2 +-
catalog/sendgrid.yaml | 2 +-
catalog/sentry.yaml | 2 +-
catalog/telegram.yaml | 2 +-
catalog/twilio.yaml | 2 +-
.../2026-03-29-publish-skill-requirements.md | 283 ++++++++++
.../2026-03-29-001-feat-publish-skill-plan.md | 526 +++++++++++++++++++
...-must-not-mutate-source-directory-2026-03-29.md | 95 ++++
...th-join-traversal-with-user-input-2026-03-29.md | 81 +++
internal/catalog/catalog.go | 48 +-
internal/catalog/catalog_test.go | 86 ++++
internal/cli/exitcodes.go | 1 +
internal/cli/library.go | 144 ++++++
internal/cli/library_test.go | 179 +++++++
internal/cli/publish.go | 572 +++++++++++++++++++++
internal/cli/publish_test.go | 400 ++++++++++++++
internal/cli/root.go | 2 +
internal/pipeline/climanifest.go | 2 +
internal/pipeline/climanifest_test.go | 10 +
internal/pipeline/contracts_test.go | 10 +
internal/pipeline/publish.go | 2 +
skills/printing-press-publish/SKILL.md | 372 ++++++++++++++
27 files changed, 2814 insertions(+), 19 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 53f8bc11..ad12fa27 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -72,7 +72,7 @@ Releases are fully automated. No manual steps required.
Catalog entries in `catalog/` must pass `internal/catalog` validation:
- Required fields: name, display_name, description, category, spec_url, spec_format, tier
- spec_url must use HTTPS
-- category must be: auth, payments, email, developer-tools, project-management, communication, crm, example
+- category must be: developer-tools, monitoring, cloud, project-management, productivity, social-and-messaging, sales-and-crm, marketing, payments, auth, commerce, ai, media-and-entertainment, devices, other
- tier must be: official or community
## Testing
diff --git a/catalog/digitalocean.yaml b/catalog/digitalocean.yaml
index e9b6764a..fba59e7e 100644
--- a/catalog/digitalocean.yaml
+++ b/catalog/digitalocean.yaml
@@ -1,7 +1,7 @@
name: digitalocean
display_name: DigitalOcean
description: Cloud infrastructure and developer platform API
-category: developer-tools
+category: cloud
spec_url: https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml
spec_format: yaml
openapi_version: "3.0"
diff --git a/catalog/discord.yaml b/catalog/discord.yaml
index b054a84f..879563a4 100644
--- a/catalog/discord.yaml
+++ b/catalog/discord.yaml
@@ -1,7 +1,7 @@
name: discord
display_name: Discord
description: Chat and community platform API
-category: communication
+category: social-and-messaging
spec_url: https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json
spec_format: json
openapi_version: "3.1"
diff --git a/catalog/front.yaml b/catalog/front.yaml
index 6843d764..1ee987e2 100644
--- a/catalog/front.yaml
+++ b/catalog/front.yaml
@@ -1,7 +1,7 @@
name: front
display_name: Front
description: Customer communication platform API
-category: communication
+category: social-and-messaging
spec_url: https://raw.githubusercontent.com/frontapp/front-api-specs/main/core-api/core-api.json
spec_format: json
openapi_version: "3.0"
diff --git a/catalog/hubspot.yaml b/catalog/hubspot.yaml
index b22b0d95..36800e75 100644
--- a/catalog/hubspot.yaml
+++ b/catalog/hubspot.yaml
@@ -1,7 +1,7 @@
name: hubspot
display_name: HubSpot
description: CRM contacts API
-category: crm
+category: sales-and-crm
spec_url: https://raw.githubusercontent.com/HubSpot/HubSpot-public-api-spec-collection/main/PublicApiSpecs/CRM/Contacts/Rollouts/424/v3/contacts.json
spec_format: json
openapi_version: "3.0"
diff --git a/catalog/pipedrive.yaml b/catalog/pipedrive.yaml
index de711cfd..f2bb3e3b 100644
--- a/catalog/pipedrive.yaml
+++ b/catalog/pipedrive.yaml
@@ -1,7 +1,7 @@
name: pipedrive
display_name: Pipedrive
description: CRM for sales teams - deals, contacts, pipelines, activities, and organizations
-category: crm
+category: sales-and-crm
spec_url: https://developers.pipedrive.com/docs/api/v1/openapi.yaml
spec_format: yaml
openapi_version: "3.0"
diff --git a/catalog/sendgrid.yaml b/catalog/sendgrid.yaml
index f79d6a5e..e215b5a6 100644
--- a/catalog/sendgrid.yaml
+++ b/catalog/sendgrid.yaml
@@ -1,7 +1,7 @@
name: sendgrid
display_name: SendGrid
description: Email delivery and marketing API
-category: email
+category: marketing
spec_url: https://raw.githubusercontent.com/twilio/sendgrid-oai/main/spec/json/sendgrid_oai.json
spec_format: json
openapi_version: "3.0"
diff --git a/catalog/sentry.yaml b/catalog/sentry.yaml
index 684a1e31..fb4d786f 100644
--- a/catalog/sentry.yaml
+++ b/catalog/sentry.yaml
@@ -1,7 +1,7 @@
name: sentry
display_name: Sentry
description: Error tracking and performance monitoring - projects, issues, events, releases
-category: developer-tools
+category: monitoring
spec_url: https://raw.githubusercontent.com/getsentry/sentry-api-schema/main/openapi-derefed.json
spec_format: json
openapi_version: "3.0"
diff --git a/catalog/telegram.yaml b/catalog/telegram.yaml
index e40ebea0..fe216135 100644
--- a/catalog/telegram.yaml
+++ b/catalog/telegram.yaml
@@ -1,7 +1,7 @@
name: telegram
display_name: Telegram Bot
description: Telegram Bot API for building bots - send messages, manage chats, webhooks, stickers
-category: communication
+category: social-and-messaging
spec_url: https://api.apis.guru/v2/specs/telegram.org/5.0.0/openapi.json
spec_format: json
openapi_version: "3.0"
diff --git a/catalog/twilio.yaml b/catalog/twilio.yaml
index da62d2eb..2ceed19d 100644
--- a/catalog/twilio.yaml
+++ b/catalog/twilio.yaml
@@ -1,7 +1,7 @@
name: twilio
display_name: Twilio
description: Communication APIs for SMS, voice, and messaging
-category: communication
+category: social-and-messaging
spec_url: https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json
spec_format: json
openapi_version: "3.0"
diff --git a/docs/brainstorms/2026-03-29-publish-skill-requirements.md b/docs/brainstorms/2026-03-29-publish-skill-requirements.md
new file mode 100644
index 00000000..6d4d6557
--- /dev/null
+++ b/docs/brainstorms/2026-03-29-publish-skill-requirements.md
@@ -0,0 +1,283 @@
+---
+date: 2026-03-29
+topic: publish-skill
+---
+
+# Publish Skill: Ship CLIs to the Library Repo
+
+## Problem Frame
+
+Users generate CLIs with the printing press and they end up in `~/printing-press/library/`. But there's no streamlined way to contribute a finished CLI to the shared printing-press-library repo (github.com/mvanhorn/printing-press-library). Today this would require manually cloning the repo, copying files, organizing them, and creating a PR. The publish skill makes this a single command: `/printing-press publish notion-pp-cli`.
+
+## User Flow
+
+```
+/printing-press publish notion-pp-cli
+ │
+ ▼
+┌─ 1. RESOLVE CLI NAME ─────────────────────────────────┐
+│ CLI binary: printing-press library list --json │
+│ │
+│ "notion-pp-cli" ──exact match──▶ found │
+│ "notion" ──suffix match──▶ notion-pp-cli │
+│ "cal" ──glob match──▶ multiple? ──▶ [ASK USER] │
+│ no match ──▶ show all CLIs ──▶ [ASK USER] │
+└────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─ 2. DETERMINE CATEGORY ───────────────────────────────┐
+│ Read .printing-press.json │
+│ │
+│ has category? ──▶ "Publish as productivity?" ─[ASK]─▶│
+│ has catalog_entry? ──▶ look up ──▶ confirm ──[ASK]──▶│
+│ neither? ──▶ show 14 categories ────────────[ASK]──▶ │
+└────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─ 3. VALIDATE ─────────────────────────────────────────┐
+│ CLI binary: printing-press publish validate --json │
+│ │
+│ ✓/✗ .printing-press.json ✓/✗ go mod tidy │
+│ ✓/✗ go vet ✓/✗ go build │
+│ ✓/✗ --help responds ✓/✗ --version responds │
+│ ✓/✗ manuscripts found │
+│ │
+│ any ✗? ──▶ report errors, STOP │
+└────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─ 4. PACKAGE ──────────────────────────────────────────┐
+│ CLI binary: printing-press publish package │
+│ --dir ~/printing-press/library/notion-pp-cli │
+│ --category productivity │
+│ --target /tmp/staging │
+│ │
+│ Creates: library/productivity/notion-pp-cli/ │
+│ cmd/ internal/ go.mod ... │
+│ .manuscripts/ │
+│ research/ proofs/ │
+└────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─ 5. GIT + PR (skill handles directly) ────────────────┐
+│ Ensure managed clone at ~/.publish-repo/ │
+│ git fetch + checkout main + pull │
+│ git checkout -b feat/notion-pp-cli │
+│ Copy staged package into clone │
+│ Update registry.json │
+│ git add + commit: "feat(notion): add notion-pp-cli" │
+│ git push + gh pr create │
+│ │
+│ ✓ PR opened: github.com/.../pull/42 │
+└────────────────────────────────────────────────────────┘
+```
+
+**User decisions:** Steps 1-2 may require input (name disambiguation, category confirmation). Steps 3-5 are automatic. Happy path = 1 decision (category confirm).
+
+## Architecture
+
+```
+LOCAL MACHINE GITHUB
+───────────────────────────────────────────── ──────────────────────
+
+~/printing-press/
+├── library/ mvanhorn/
+│ └── notion-pp-cli/ ◄── source of truth printing-press-library
+│ ├── cmd/ ├── library/
+│ ├── internal/ │ ├── productivity/
+│ ├── .printing-press.json │ │ └── notion-pp-cli/
+│ └── ... │ │ ├── cmd/
+│ │ │ ├── .manuscripts/
+├── manuscripts/ │ │ └── ...
+│ └── notion/ │ └── developer-tools/
+│ └── 20260328-132022/ ◄── provenance │ └── github-pp-cli/
+│ ├── research/ ├── registry.json
+│ └── proofs/ └── README.md
+│ └── proofs/
+│
+├── .publish-repo/ ◄── managed clone (skill)
+│ └── (clone of printing-press-library)
+│
+└── .publish-config.json ◄── cached state
+ { access: "push|fork",
+ protocol: "ssh|https",
+ repo_url: "..." }
+
+
+COMPONENT RESPONSIBILITIES
+──────────────────────────
+
+┌──────────────────────┐ ┌──────────────────────┐ ┌─────────────┐
+│ CLI BINARY (Go) │ │ SKILL (LLM/bash) │ │ GITHUB │
+│ │ │ │ │ │
+│ • library list │───▶│ • name resolution UX │ │ │
+│ • publish validate │───▶│ • category assignment │ │ │
+│ • publish package │───▶│ • validation display │ │ │
+│ │ │ • git clone/branch │───▶│ • fork │
+│ Structured JSON out │ │ • git commit/push │───▶│ • PR create │
+│ Zero LLM tokens │ │ • registry.json edit │ │ • PR merge │
+│ Reuses Go types │ │ • error guidance │ │ │
+└──────────────────────┘ └──────────────────────┘ └─────────────┘
+ deterministic interactive remote
+```
+
+## Requirements
+
+**Category Taxonomy**
+
+- R1. The printing-press-library repo organizes CLIs into category folders using a 15-category single-level taxonomy: `developer-tools`, `monitoring`, `cloud`, `project-management`, `productivity`, `social-and-messaging`, `sales-and-crm`, `marketing`, `payments`, `auth`, `commerce`, `ai`, `media-and-entertainment`, `devices`, `other`
+- R2. The `other` category acts as a catch-all. When enough CLIs accumulate around a theme in `other`, that signals a new category should be split out
+- R3. The catalog's `validCategories` in `internal/catalog/catalog.go` must be updated to match this taxonomy. Four current categories are dropped and require mandatory reassignment: `email` -> `marketing`, `crm` -> `sales-and-crm`, `communication` -> `social-and-messaging`. The `example` category is removed from the public taxonomy; `petstore.yaml` (test fixture) retains `example` as an internal-only category not used in the library repo. Additional reassignments: DigitalOcean `developer-tools` -> `cloud`, Sentry `developer-tools` -> `monitoring`. All 17 catalog YAML files must be validated against the new taxonomy before merging
+- R4. [Cross-cutting: requires changes to the printing-press skill and CLI binary] The `.printing-press.json` CLI manifest gains a `category` field (string, matching one of the 14 slugs). This is populated during the printing process using the catalog entry's category when available, or determined by the research phase for non-catalog APIs. The publish skill reads this field (R20) but does not write it
+- R4a. [Cross-cutting: requires changes to the printing-press skill and CLI binary] The `.printing-press.json` CLI manifest gains a `description` field (string, one-liner). This description is generated after shipcheck/emboss completes — when the CLI is finalized and the actual command surface is known. The manifest stores it as the canonical one-liner. The CLI README and PR description both consume it. The publish skill reads this field but does not write it
+- R4b. [Cross-cutting: requires changes to the printing-press skill] Each generated CLI includes a `README.md` in its root directory. The README is generated after shipcheck/emboss — not during the research phase — because the CLI's actual commands, features, and product thesis may diverge from the initial brief during build and verification. The publish skill assumes the README already exists when the user runs `/printing-press publish`. The README follows this structure:
+ 1. **Title + one-liner** from the manifest `description`
+ 2. **API links** — links to the API provider's homepage, developer docs, and public spec (if available)
+ 3. **Why this exists** — 2-3 sentences. The product thesis tested against the actual built CLI
+ 4. **Install** — Homebrew, `go install`, binary download. Copy-paste commands only
+ 5. **Quick start** — Numbered steps: install → `doctor` → first real command
+ 6. **Commands** — Grouped by resource, one-liner per command. Generated from the actual `--help` output
+ 7. **Agent & automation** — Factual description of the CLI's agent-optimized properties: non-interactive (never prompts), `--json` to stdout, `--select` for field filtering, `--dry-run` for safe preview, typed exit codes. Not promotional — states how the CLI behaves in pipelines and agent contexts
+ 8. **Exit codes** — Table mapping codes to meanings
+ 9. **Configuration** — Config file path, environment variables
+ 10. **Troubleshooting** — Exit code → cause → fix, domain-specific
+ 11. **Attribution** — "Built with [CLI Printing Press](https://github.com/mvanhorn/cli-printing-press)." Single line, end of file. Users can edit or remove this
+
+**Library Repo Structure**
+
+- R5. The printing-press-library repo nests all CLIs under a `library/` root folder: `library/<category>/<cli-name>/`. This keeps the repo root clean for README, registry.json, contributing docs, and future tooling
+- R6. Each CLI folder (`library/<category>/<cli-name>/`) contains the full CLI source code plus a `.manuscripts/` directory (dot-prefixed) containing the research and proof artifacts from the printing run
+- R7. The repo root contains a `registry.json` — a machine-readable index of all CLIs with their name, category, api_name, and description
+- R8. The repo's `.gitignore` is the single source of truth for excluding build artifacts (compiled binaries, .DS_Store, vendor/, etc.) from commits
+
+**CLI/Skill Responsibility Split**
+
+- R9. The CLI binary owns all deterministic, local operations: validation checks (`publish validate`), package assembly (`publish package`), and library listing (`library list`). These commands accept `--json` for structured output and cost zero LLM tokens
+- R10. The skill owns all interactive and networked operations: name resolution UX, category assignment, git plumbing (clone, branch, commit, push), PR creation via `gh`, registry.json updates, and error guidance
+- R11. CLI validation (`printing-press publish validate --dir <path> --json`) runs all checks from R22 and returns structured JSON with pass/fail per check and error details. The skill interprets the results and presents them to the user
+- R12. CLI package assembly (`printing-press publish package --dir <path> --category <cat> --target <staging-dir> --json`) copies the CLI source and manuscripts into a staging directory matching the library repo structure (`library/<category>/<cli-name>/`). It re-validates before packaging and returns JSON describing what was assembled
+
+**Managed Clone**
+
+- R13a. The publish skill manages its own clone of the printing-press-library repo at `~/printing-press/.publish-repo/`. Users never need to manually clone, pull, or interact with this repo
+- R13b. On first publish, the skill detects push access via `gh api` and either clones directly (push access) or forks first (no push access) using `gh repo fork`. SSH vs HTTPS is auto-detected based on the user's git configuration
+- R13c. On subsequent publishes, the skill freshens the clone (`git fetch`, checkout main, pull) before creating a new branch
+- R13d. The target repo URL is configured in one place so it can be changed in the future without user-facing impact
+- R13e. Access level (push vs fork), git protocol (SSH vs HTTPS), and clone path are cached in `~/printing-press/.publish-config.json` so the skill does not re-probe on every publish
+
+**Name Resolution**
+
+- R14. The skill accepts a CLI name argument: `/printing-press publish <name>`. The skill uses `printing-press library list --json` to get the available CLIs, then applies resolution logic
+- R15. Exact match: look for `<name>` in the library list. If found, use it
+- R16. Suffix match: if no exact match, try `<name>-pp-cli` (e.g., `notion` -> `notion-pp-cli`). If found, use it
+- R17. Glob match: if no suffix match, search for entries containing `<name>` as a substring. If multiple matches, present via AskUserQuestion for user selection. Show at most 5 matches sorted by modification time (most recent first). This resolution order (exact -> suffix -> glob) matches the score skill for consistent behavior across skills
+- R18. No match: if nothing matches, list available CLIs and ask the user to pick or re-enter
+- R19. No argument: if invoked as `/printing-press publish` with no name, list all CLIs in the library sorted by modification time and let the user pick
+
+**Category Assignment**
+
+- R20. If the CLI's `.printing-press.json` has a `category` field, use it as the default. Present to the user for confirmation with the option to change
+- R21. If no `category` in manifest but `catalog_entry` is present, look up the category from the embedded catalog. Present for confirmation
+- R22. If neither source provides a category, present the full category list and ask the user to choose
+
+**Publish Validation**
+
+- R23. The CLI binary's `publish validate` command checks: `.printing-press.json` exists with required fields, `go mod tidy` reports no changes needed, `go vet ./...` passes, `go build ./...` succeeds, the built binary responds to `--help` and `--version`, and manuscripts exist. Returns structured JSON with pass/fail per check
+- R24. If validation fails, the skill reports what's wrong (from the JSON output) and stops. Do not create a partial PR
+
+**Package Assembly**
+
+- R25. The CLI binary's `publish package` command resolves manuscripts from `$PRESS_MANUSCRIPTS/<api-name>/`, selecting the most recent run by lexicographic sort on the run-id directory name (run-ids are timestamp-prefixed, e.g., `20260328-132022`, so lexicographic order equals chronological order). If no manuscripts exist, it warns but proceeds
+- R26. The package command copies the CLI source and manuscripts into a staging directory structured as `library/<category>/<cli-name>/` with manuscripts in `.manuscripts/`, ready to drop into the library repo
+
+**PR Creation**
+
+- R27. The skill creates a branch named `feat/<cli-name>` in the managed clone. If a local or remote branch with that name already exists (stale from a previous attempt), detect it and ask the user whether to overwrite or create a timestamped variant (e.g., `feat/<cli-name>-20260329`)
+- R28. The skill commits with conventional format: `feat(<api-name>): add <cli-name>`
+- R29. The skill pushes and creates a PR via `gh pr create` with a best-in-class structured description that enables any reviewer (human or agent) to understand the contribution without prior context. The PR body includes: the one-liner description from the manifest, the API name and service it connects to, the category, the full `--help` output of the built CLI binary in a bash code block (giving reviewers an instant view of the command surface), a link to the CLI's README within the PR branch, links to the `.manuscripts/` folders (research brief, absorb manifest, shipcheck results) within the PR branch, the validation check results, and the printing-press version used to generate it. If any manifest fields are missing (e.g., no `description`, no `spec_url`), the PR description flags these gaps explicitly rather than silently omitting them
+- R30. If a PR already exists for this CLI name (e.g., from a previous publish attempt), warn the user and ask whether to update the existing branch or create a new one
+
+**Error Handling**
+
+- R31. If `gh` CLI is not authenticated, detect this early and tell the user to run `gh auth login`
+- R32. If the printing-press-library repo is unreachable, report the error clearly
+- R33. If there are uncommitted changes in the managed clone from a previous interrupted publish, detect and offer to reset or continue
+
+**Registry Update**
+
+- R34. During each publish, the skill updates `registry.json` at the repo root by adding or updating the entry for the published CLI. The entry is derived from the CLI's `.printing-press.json` manifest and the chosen category
+
+## Success Criteria
+
+- A user can go from `/printing-press publish notion-pp-cli` to an open PR in under 2 minutes with no manual git operations
+- Non-catalog CLIs get published just as easily as catalog CLIs (category is asked, not blocked)
+- The publish flow is recoverable — interrupted runs don't leave the managed clone in a broken state
+- The printing-press-library repo is browsable by category, and `registry.json` is machine-readable for tooling
+
+## Scope Boundaries
+
+- This skill does NOT run `printing-press verify` or `printing-press scorecard` as part of validation. It checks build + manifest + manuscripts only. Full quality gates are the printing process's responsibility
+- This skill does NOT modify the user's local library (`~/printing-press/library/`). It only reads from it
+- This skill does NOT handle updating an already-published CLI (re-publish / version bump). That's a future capability
+- The category taxonomy update to `internal/catalog/catalog.go` is in scope. Reassigning existing catalog entries to new categories is in scope
+- Updating the printing-press-library README to reflect the new structure is in scope
+- The `registry.json` schema and initial generation are in scope, but tooling that consumes it is not
+- CLI README generation (R4b) is a cross-cutting change to the printing-press skill and generator, not part of the publish skill itself. The publish skill assumes a README already exists. If it doesn't, the PR description notes its absence
+- **Trust verification is NOT in scope for the publish skill.** The publish skill ensures provenance data (manuscripts, manifest) is always included so trust can be verified, but the actual verification happens in the printing-press-library repo's CI and review process. See "Future: Library Trust Verification" below
+
+## Key Decisions
+
+- **Single-level taxonomy**: Two-level nesting (category/subcategory) was considered but rejected. Adds classification complexity without enough benefit at current scale. Can revisit when the library has 50+ CLIs
+- **`other` catch-all**: Preferred over forcing every API into an imperfect category. Signals when new categories are needed organically
+- **Managed clone at `~/printing-press/.publish-repo/`**: Users never manually interact with the library repo. The skill handles all git plumbing. This follows the existing pattern where `~/printing-press/` is managed space
+- **CLI binary for validation + packaging, skill for interaction + git**: Deterministic local operations (validation checks, file assembly, library listing) run in the CLI binary with `--json` output — zero tokens, structured errors. Interactive and networked operations (name resolution UX, category assignment, git/GitHub plumbing, PR creation) stay in the skill. This split keeps the skill thin and token-efficient while ensuring validation is repeatable and fast
+- **Category in manifest**: Adding `category` to `.printing-press.json` means the CLI folder is self-describing even outside the library repo. The printing process determines category via catalog lookup or research-phase classification
+- **Replaced `communication` with `social-and-messaging`**: Broadens the scope to cover messaging infrastructure (Twilio, Front), social platforms (Twitter, Reddit), and media/streaming (Spotify, YouTube) under one umbrella. This is a semantic expansion, not just a rename — Front and Twilio are communication tools, while Spotify and YouTube are media platforms, but all share the "connect people to content or each other" pattern
+- **`.manuscripts/` dot-prefixed**: Keeps provenance data present but unobtrusive in the CLI folder listing
+- **Auto-classification for non-catalog APIs**: The research phase (Phase 1) auto-classifies the API against the taxonomy and writes the category to the manifest. It always picks a best-fit category rather than leaving it empty. The user can override at publish time (R20)
+- **Description and README generated after shipcheck/emboss**: Both artifacts are produced when the CLI is finalized, not during the research phase. The research brief contains a thesis, but the actual CLI may diverge significantly during build and verification — commands renamed, features added/dropped, transcendence features built. The description and README reflect the real CLI, not the plan
+- **One-liner description as canonical source**: The manifest stores the one-liner. The README uses it as the opening line. The PR description pulls it from the manifest. This avoids having multiple independent sources for "what does this CLI do"
+- **CLI README is a generation-time artifact**: The README exists before publish and is part of the generated CLI. The publish skill consumes it (for PR description context and linking) but doesn't create it. This separation means READMEs can improve through emboss passes before publishing
+
+## Dependencies / Assumptions
+
+- `gh` CLI is installed and authenticated (the skill checks this early)
+- User has a GitHub account (needed for fork/PR workflow)
+- The printing process already ran and the CLI is in `~/printing-press/library/`
+- Manuscripts exist in `~/printing-press/manuscripts/` from the printing run
+- The printing-press-library repo is cloned locally at `~/Code/printing-press-library` (github.com/mvanhorn/printing-press-library). README, CONTRIBUTING.md, .gitignore, and initial repo scaffolding (category folders, registry.json) are updated there as part of this work
+
+## Future: Library Trust Verification
+
+The printing-press-library endorses CLIs — anything merged carries an implicit stamp of quality and safety. The publish skill ensures provenance (manuscripts + manifest) ships with every submission, but verifying trust is the library repo's responsibility. CLIs are explicitly allowed to be human-modified after printing (emboss passes, manual improvements), so an immutable-artifact approach won't work.
+
+**Recommended approach: CI-based verification on every PR to printing-press-library.**
+
+Three layers, in priority order:
+
+1. **Regeneration diff** — Re-run `printing-press generate` from the spec in `.manuscripts/` and produce a focused diff against the submitted code. Human modifications are expected and allowed, but the diff makes them explicit. Reviewers (human or automated) only need to scrutinize the delta, not the entire CLI. Unexplained changes to network-facing code get flagged.
+
+2. **Network audit** — Statically scan all Go source for outbound HTTP calls (`http.Get`, `http.Post`, `net/http.NewRequest`, etc.) and compare every target URL/host against the spec's `servers` URLs. Any call to a host not in the spec is a hard flag. This catches the exact malicious-exfiltration scenario regardless of whether code was modified.
+
+3. **Dependency audit** — Scan `go.mod` for unexpected dependencies not present in the press-generated baseline. New dependencies in a modified CLI aren't automatically bad, but they warrant review.
+
+This is a separate initiative owned by the printing-press-library repo. The publish skill's contribution is ensuring `.manuscripts/` and `.printing-press.json` are always present (R6, R25) so these checks have the provenance data they need.
+
+## Outstanding Questions
+
+### Resolve Before Planning
+
+(None — all blocking questions resolved)
+
+### Deferred to Planning
+
+- [Affects R7][Technical] What fields should `registry.json` contain? Likely: cli_name, api_name, category, description, printing_press_version, published_date. Needs schema design
+- [Affects R13b][Needs research] Does `gh repo fork --clone` handle the case where the user already has a fork gracefully? Need to test the exact `gh` behavior
+- [Affects R13d][Technical] Where should the target repo URL be configured? Options: skill frontmatter, a press config file at `~/printing-press/.publish-config.json`, or hardcoded with a flag override
+- [Affects R26][Technical] Should manuscripts be copied flat (all runs merged) or preserve the run-ID directory structure inside `.manuscripts/`?
+- [Affects R9-R12][Technical] Exact CLI subcommand naming and flag design for `publish validate`, `publish package`, and `library list`. Needs to align with existing CLI command structure
+- [Affects R10][Technical] If the skill's inline git/GitHub bash (managed clone, fork detection, branch management, PR creation) grows beyond ~30 lines or accumulates complex error handling, consider extracting it into shell scripts under `skills/printing-press-publish/scripts/`. Go CLI is still preferred for validation and packaging (reuses Go types), but scripts are a good middle ground for orchestration logic that is too complex for inline skill bash but doesn't need Go
+
+## Next Steps
+
+-> `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-03-29-001-feat-publish-skill-plan.md b/docs/plans/2026-03-29-001-feat-publish-skill-plan.md
new file mode 100644
index 00000000..6ab49c74
--- /dev/null
+++ b/docs/plans/2026-03-29-001-feat-publish-skill-plan.md
@@ -0,0 +1,526 @@
+---
+title: "feat: Add publish skill to ship CLIs to printing-press-library"
+type: feat
+status: completed
+date: 2026-03-29
+origin: docs/brainstorms/2026-03-29-publish-skill-requirements.md
+---
+
+# feat: Add publish skill to ship CLIs to printing-press-library
+
+## Overview
+
+Add a `/printing-press publish` skill that packages a generated CLI from the local library, validates it, and opens a PR against the printing-press-library repo — all from a single command. This also requires expanding the catalog taxonomy to 14 categories, adding a `category` field to the CLI manifest, adding new CLI subcommands (`library list`, `publish validate`, `publish package`), and scaffolding the printing-press-library repo.
+
+## Problem Frame
+
+Users generate CLIs with the printing press and they land in `~/printing-press/library/`. There's no streamlined way to contribute a finished CLI to the shared printing-press-library repo. Today this would require manually cloning the repo, copying files, organizing them, and creating a PR. The publish skill makes this a single command. (see origin: docs/brainstorms/2026-03-29-publish-skill-requirements.md)
+
+## Requirements Trace
+
+- R1-R2. 15-category single-level taxonomy with `other` catch-all
+- R3. Catalog `validCategories` updated; all 17 YAML files reassigned
+- R4. `category` field added to `.printing-press.json` manifest
+- R4a. `description` field added to `.printing-press.json` manifest (one-liner, canonical source)
+- R4b. [Dependency, not in this plan's scope] CLI README generation at print time (11-section structure: title + one-liner, API links, why-it-exists, install, quick start, commands, agent & automation, exit codes, config, troubleshooting, attribution)
+- R5-R8. Library repo structure: `library/<category>/<cli-name>/` with `.manuscripts/`, `registry.json`, `.gitignore`
+- R9-R12. CLI binary owns validation, packaging, library listing; skill owns interaction + git
+- R13a-R13e. Managed clone at `~/printing-press/.publish-repo/` with cached config
+- R14-R19. Name resolution: exact → suffix → glob → ask user
+- R20-R22. Category assignment: manifest → catalog lookup → ask user
+- R23-R24. Publish validation via CLI binary with structured JSON
+- R25-R26. Package assembly via CLI binary with manuscript resolution
+- R27-R30. PR creation: branch naming, conventional commits, `gh pr create`
+- R31-R33. Error handling: `gh` auth check, repo reachability, interrupted state
+- R34. Registry.json updated during each publish
+
+## Scope Boundaries
+
+- Does NOT run `printing-press verify` or `scorecard` — checks build + manifest + manuscripts only
+- Does NOT modify the user's local library — read-only
+- Does NOT handle re-publish / version bump — future capability
+- CLI README generation (R4b) is out of scope — it's a cross-cutting change to the printing-press skill and generator. The publish skill assumes a README already exists and flags its absence in the PR description if missing
+- Trust verification is NOT in scope — future CI work in the library repo (see origin: Future: Library Trust Verification)
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- **Cobra command pattern:** Each command is a `newXxxCmd()` function in its own file under `internal/cli/`. Parent commands with subcommands follow `internal/cli/catalog.go` pattern (`newCatalogCmd()` adds `list/show/search` children)
+- **Flag conventions:** `cmd.Flags().StringVar()` (not `cmd.Flags().String()`), `--json` for machine output, `--dir` for directory input. Human text to stderr, JSON to stdout
+- **Exit codes:** `ExitError` with typed codes from `internal/cli/exitcodes.go`
+- **Manifest:** `CLIManifest` in `internal/pipeline/climanifest.go`. Written by `WriteCLIManifest()` with `json.MarshalIndent`. Optional fields use `omitempty`
+- **Catalog validation:** `validCategories` map in `internal/catalog/catalog.go`. Error message on line 138 hardcodes the list. `ParseEntry` validates on read — categories and YAML files must change atomically
+- **Path helpers:** `internal/pipeline/paths.go` — `PressHome()`, `PublishedLibraryRoot()`, `PublishedManuscriptsRoot()`, `ArchivedManuscriptDir()`. Reuse these, don't re-derive
+- **Naming:** `internal/naming/naming.go` — `CLI()`, `TrimCLISuffix()`, `IsCLIDirName()`
+- **File copying:** `CopyDir()` in `internal/pipeline/publish.go` — recursive with symlink support
+- **Skill structure:** YAML frontmatter (`name`, `description`, `version`, `min-binary-version`, `allowed-tools`) + markdown body. Setup contract between `PRESS_SETUP_CONTRACT_START/END` markers, validated by `contracts_test.go`
+- **Test pattern:** Table-driven with `testify/assert`. Use `setPressTestEnv(t)` for isolation. CLI commands tested via `newXxxCmd()` + `cmd.SetArgs()`
+
+### Institutional Learnings
+
+- Catalog categories and YAML entries must be updated atomically — `ParseEntry` validates on read (docs/solutions/best-practices/checkout-scoped-printing-press-output-layout)
+- `example` category stays in `validCategories` for petstore.yaml test fixture but isn't part of the public 14-category taxonomy
+- Skills invoke `printing-press` on PATH, never `./printing-press`. New skill must follow this and pass `contracts_test.go`
+- Adding `category` to CLIManifest with `omitempty` doesn't require a schema version bump — it's additive
+- Library listing should handle both `-pp-cli` and `-pp-cli-N` suffixed directories (claimed reruns)
+- Decoupling plan explicitly deferred the publish skill as "a separate future effort" — this plan implements it
+
+## Key Technical Decisions
+
+- **`example` retained in `validCategories`:** Petstore test fixture needs it. The publish skill and library repo don't offer it as a choice. Adding it to the map alongside the 14 public categories is simpler than special-case handling. (see origin: R3 discussion of `example`)
+- **`publish` as parent command with `validate`/`package` children:** Follows the `catalog` parent pattern. Keeps `printing-press publish validate --dir ... --json` and `printing-press publish package --dir ... --json` as distinct subcommands
+- **`library` as parent command with `list` child:** Leaves room for future `library show`, `library search`, etc. Follows `catalog` convention
+- **registry.json schema:** `{ "schema_version": 1, "entries": [{ "cli_name", "api_name", "category", "description", "printing_press_version", "published_date" }] }`. Description sourced from catalog entry (if `catalog_entry` present) or first line of README. The skill writes this, not the CLI binary (see origin: R34, R10)
+- **Manuscripts preserve run-ID structure:** `.manuscripts/<run-id>/research/`, `.manuscripts/<run-id>/proofs/`. Preserves provenance trail and avoids ambiguity across runs. Most recent run selected by lexicographic sort on directory name (see origin: R25)
+- **Target repo URL in `.publish-config.json`:** Alongside cached access level and protocol. Single file for all publish state at `~/printing-press/.publish-config.json` (see origin: R13d, R13e)
+- **New exit code for publish validation failures:** Add `ExitPublishError = 5` to `exitcodes.go` for publish-specific failures distinct from spec/generation errors
+- **`description` as canonical one-liner in manifest:** Generated after shipcheck/emboss when the CLI is finalized — not during research, because the CLI's actual features may diverge from the brief. The manifest stores it, the README uses it, the PR description pulls from it. Single source of truth for "what does this CLI do." (see origin: R4a)
+- **Best-in-class PR description:** The PR body is the first impression for any reviewer. It includes the one-liner, API details, README excerpt, manuscript links (within the PR branch), validation results table, and an explicit Gaps section for missing manifest fields. Designed so a reviewer with zero prior context can evaluate the contribution (see origin: R29)
+- **CLI README is a dependency, not in scope:** The publish skill assumes a README exists at `<cli-dir>/README.md`. If missing, the PR description notes the gap. Generating READMEs at print time is a cross-cutting change to the printing-press skill tracked separately. The README has an 11-section structure defined in the origin doc (R4b) including API links, agent/automation properties, exit codes, and a one-line attribution to the printing press (see origin: R4b)
+
+## Open Questions
+
+### Resolved During Planning
+
+- **registry.json schema:** Fields are `cli_name`, `api_name`, `category`, `description`, `printing_press_version`, `published_date`. Description from catalog entry or README first paragraph
+- **Manuscripts structure:** Preserve run-ID directory structure inside `.manuscripts/`
+- **Target repo URL config:** In `~/printing-press/.publish-config.json` alongside other publish state
+- **`example` category:** Keep in `validCategories` for test fixture, exclude from public taxonomy
+
+### Deferred to Implementation
+
+- **`gh repo fork` behavior with existing fork:** Need to test `gh repo fork --clone` when fork already exists. If it errors, the skill should detect the existing fork and clone it instead
+- **Scripts escape hatch:** If the skill's git/GitHub inline bash grows beyond ~30 lines, consider extracting to `skills/printing-press-publish/scripts/`. Assess during implementation
+- **library list output format:** Exact JSON field names and whether to include manifest parse errors per-entry or filter silently. Decide during implementation based on what the skill needs
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+```
+PUBLISH FLOW — CLI BINARY + SKILL COOPERATION
+
+Skill invocation:
+ /printing-press publish [name]
+
+Step 1 (Skill): Name Resolution
+ printing-press library list --json
+ → JSON array of { cli_name, dir, api_name, category, modified }
+ → Skill applies: exact → suffix(-pp-cli) → glob(*name*, max 5 by recency) → ask user
+
+Step 2 (Skill): Category Assignment
+ Read .printing-press.json from resolved CLI dir
+ → has category? → confirm with user
+ → has catalog_entry? → printing-press catalog show <entry> --json → get category → confirm
+ → neither? → present 14 categories → ask user
+
+Step 3 (CLI): Validate
+ printing-press publish validate --dir <cli-dir> --json
+ → { "passed": bool, "checks": [{ "name": "manifest", "passed": bool, "error": "..." }, ...] }
+ → Skill shows results, stops if failed
+
+Step 4 (CLI): Package
+ printing-press publish package --dir <cli-dir> --category <cat> --target <staging> --json
+ → Copies CLI + .manuscripts/<run-id>/ to staging/library/<cat>/<cli-name>/
+ → { "staged_dir": "...", "cli_name": "...", "manuscripts_included": bool, "run_id": "..." }
+
+Step 5 (Skill): Git + PR
+ Managed clone at ~/printing-press/.publish-repo/
+ → git checkout -b feat/<cli-name>
+ → cp -r <staging>/* .
+ → update registry.json
+ → git add + commit + push
+ → gh pr create
+```
+
+## Implementation Units
+
+- [x] **Unit 1: Expand catalog taxonomy to 14 categories**
+
+**Goal:** Update `validCategories` to the new 14-category taxonomy and reassign all catalog YAML files.
+
+**Requirements:** R1, R2, R3
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/catalog/catalog.go`
+- Modify: `internal/catalog/catalog_test.go`
+- Modify: `catalog/asana.yaml`, `catalog/digitalocean.yaml`, `catalog/discord.yaml`, `catalog/front.yaml`, `catalog/github.yaml`, `catalog/hubspot.yaml`, `catalog/launchdarkly.yaml`, `catalog/pipedrive.yaml`, `catalog/plaid.yaml`, `catalog/sendgrid.yaml`, `catalog/sentry.yaml`, `catalog/square.yaml`, `catalog/stripe.yaml`, `catalog/stytch.yaml`, `catalog/telegram.yaml`, `catalog/twilio.yaml`
+- Modify: `catalog/petstore.yaml` (stays `example`)
+- Modify: `internal/cli/catalog_test.go` (if category strings appear in test expectations)
+- Test: `internal/catalog/catalog_test.go`
+
+**Approach:**
+- Replace `validCategories` map with the 14 public categories plus `example` (15 total in the map)
+- Update the error message string in `Validate()` to list the 14 public categories (exclude `example` from the user-facing message since it's internal-only)
+- Reassign YAML files: `sendgrid`: `email`→`marketing`, `hubspot`/`pipedrive`: `crm`→`sales-and-crm`, `discord`/`front`/`telegram`/`twilio`: `communication`→`social-and-messaging`, `digitalocean`: `developer-tools`→`cloud`, `sentry`: `developer-tools`→`monitoring`
+- Remaining entries keep their current categories: `asana`→`project-management`, `github`/`launchdarkly`→`developer-tools`, `plaid`/`square`/`stripe`→`payments`, `stytch`→`auth`, `petstore`→`example`
+
+**Patterns to follow:**
+- `internal/catalog/catalog.go` line 17-26 for the map structure
+- `internal/catalog/catalog_test.go` for table-driven validation tests
+
+**Test scenarios:**
+- Happy path: each of the 14 public categories passes validation
+- Happy path: `example` still passes validation (petstore backward compat)
+- Error path: old categories (`email`, `crm`, `communication`) are rejected
+- Happy path: `go test ./...` passes with all 17 YAML files after reassignment
+- Edge case: error message lists the 14 public categories, not `example`
+
+**Verification:**
+- `go test ./internal/catalog/...` passes
+- `go test ./internal/cli/...` passes (catalog command tests)
+- All 17 catalog YAML files load without validation errors
+
+---
+
+- [x] **Unit 2: Add `category` and `description` fields to CLIManifest**
+
+**Goal:** Add `Category` and `Description` to the `CLIManifest` struct and populate them during CLI publishing.
+
+**Requirements:** R4, R4a
+
+**Dependencies:** Unit 1 (valid categories must exist for validation context)
+
+**Files:**
+- Modify: `internal/pipeline/climanifest.go`
+- Modify: `internal/pipeline/climanifest_test.go`
+- Modify: `internal/pipeline/publish.go` (in `writeCLIManifestForPublish`)
+- Test: `internal/pipeline/climanifest_test.go`
+
+**Approach:**
+- Add `Category string \`json:"category,omitempty"\`` to `CLIManifest` after `CatalogEntry`
+- Add `Description string \`json:"description,omitempty"\`` to `CLIManifest` after `Category`
+- In `writeCLIManifestForPublish`, after the catalog lookup (line 206), set `m.Category = entry.Category` and `m.Description = entry.Description` when the catalog entry is found
+- No schema version bump — additive fields with `omitempty`
+- The `description` field is the canonical one-liner for the CLI. It is generated after shipcheck/emboss when the CLI is finalized — not during research, because the CLI's actual commands and features may diverge from the initial brief. At generation time, the catalog entry description serves as a placeholder when available. The full description is a cross-cutting change to the printing-press skill tracked separately. At publish time, the skill reads it from the manifest for the PR description
+
+**Patterns to follow:**
+- Existing `CatalogEntry` field for placement and `omitempty` convention
+- `writeCLIManifestForPublish` in `internal/pipeline/publish.go` for where to populate
+
+**Test scenarios:**
+- Happy path: manifest written with `category` and `description` when catalog entry exists — verify JSON contains both fields
+- Happy path: `category` and `description` omitted from JSON when catalog entry not found (non-catalog API)
+- Happy path: round-trip read/write preserves both fields
+- Edge case: existing manifests without `category` or `description` fields parse without error
+
+**Verification:**
+- `go test ./internal/pipeline/...` passes
+- Test confirms category and description appear in JSON output when catalog entry has them
+
+---
+
+- [x] **Unit 3: Add `library list` command**
+
+**Goal:** Add a `printing-press library list` command that lists all CLIs in the local library with manifest metadata.
+
+**Requirements:** R9, R14
+
+**Dependencies:** Unit 2 (reads `category` from manifest)
+
+**Files:**
+- Create: `internal/cli/library.go`
+- Create: `internal/cli/library_test.go`
+- Modify: `internal/cli/root.go` (register `newLibraryCmd()`)
+- Test: `internal/cli/library_test.go`
+
+**Approach:**
+- Follow `catalog.go` parent command pattern: `newLibraryCmd()` → `newLibraryListCmd()`
+- Scan `PublishedLibraryRoot()` for directories matching `IsCLIDirName()` or containing `.printing-press.json`
+- For each directory, attempt to read `.printing-press.json`. If readable, extract `api_name`, `cli_name`, `category`, `catalog_entry`. If not readable, still include with empty manifest fields
+- Sort by directory modification time (most recent first)
+- `--json` outputs array of `{ "cli_name", "dir", "api_name", "category", "catalog_entry", "modified" }`
+- Human output: table format similar to `catalog list`
+
+**Patterns to follow:**
+- `internal/cli/catalog.go` for parent/child command structure
+- `json.NewEncoder(os.Stdout)` for `--json` output
+- `ExitError` with `ExitInputError` for filesystem errors
+
+**Test scenarios:**
+- Happy path: library with 2 CLIs, both with manifests — JSON output contains both entries with correct fields
+- Happy path: human output shows formatted table
+- Edge case: library directory doesn't exist — returns empty list, no error
+- Edge case: CLI directory exists but `.printing-press.json` is missing — entry included with empty manifest fields
+- Edge case: CLI directory with malformed `.printing-press.json` — entry included with empty manifest fields, no crash
+- Happy path: handles `-pp-cli-2` suffixed directories (claimed reruns)
+
+**Verification:**
+- `go test ./internal/cli/...` passes
+- `printing-press library list --json` produces valid JSON in a test environment
+
+---
+
+- [x] **Unit 4: Add `publish validate` and `publish package` commands**
+
+**Goal:** Add `printing-press publish validate` and `printing-press publish package` commands for pre-publish validation and package assembly.
+
+**Requirements:** R9, R11, R12, R23, R24, R25, R26
+
+**Dependencies:** Unit 2 (reads `category` from manifest), Unit 3 (shared library infrastructure)
+
+**Files:**
+- Create: `internal/cli/publish.go`
+- Create: `internal/cli/publish_test.go`
+- Modify: `internal/cli/root.go` (register `newPublishCmd()`)
+- Modify: `internal/cli/exitcodes.go` (add `ExitPublishError`)
+- Test: `internal/cli/publish_test.go`
+
+**Approach:**
+
+`publish validate --dir <path> --json`:
+- Required flag: `--dir`
+- Read `.printing-press.json` from `--dir`, unmarshal into `CLIManifest`
+- Run check sequence: manifest exists + required fields, `go mod tidy` (check for diff), `go vet ./...`, `go build ./...`, built binary `--help` and `--version`, manuscripts exist in `PublishedManuscriptsRoot()/<api_name>/`
+- **Manuscripts check is warn-only:** Unlike other checks, missing manuscripts sets `"passed": true` with a `"warning"` field rather than failing. This matches R25 ("warns but proceeds"). The overall `"passed"` result is unaffected by manuscript warnings. The package command's re-validation (below) uses this same semantic
+- JSON output: `{ "passed": bool, "cli_name": "...", "api_name": "...", "help_output": "...", "checks": [{ "name": "manifest", "passed": bool, "error": "..." }, ...] }`
+- The `help_output` field captures the full `--help` output of the built binary. The skill uses this in the PR description to show the CLI's command surface without needing to re-run the binary
+- Each check runs independently (don't short-circuit) so the user sees all failures at once
+- Use `ExitPublishError` exit code on validation failure
+
+`publish package --dir <path> --category <cat> --target <staging-dir> --json`:
+- Required flags: `--dir`, `--category`, `--target`
+- Re-validate (call same validation logic, fail early if invalid)
+- Resolve manuscripts: scan `PublishedManuscriptsRoot()/<api_name>/` for run directories, lexicographic sort, use most recent
+- Create staging structure: `<target>/library/<category>/<cli_name>/`
+- `CopyDir` CLI source into staging
+- `CopyDir` manuscripts into `<target>/library/<category>/<cli_name>/.manuscripts/<run_id>/`
+- JSON output: `{ "staged_dir": "...", "cli_name": "...", "api_name": "...", "category": "...", "manuscripts_included": bool, "run_id": "..." }`
+
+**Patterns to follow:**
+- `internal/cli/catalog.go` for parent/child structure
+- `internal/pipeline/publish.go` `CopyDir()` for file copying
+- `internal/pipeline/paths.go` for `PublishedManuscriptsRoot()`
+- `exec.Command("go", "vet", "./...")` for running go tools (see `contracts_test.go` `runGoContractCommand`)
+
+**Test scenarios:**
+- Happy path: valid CLI directory passes all checks — JSON shows all checks passed
+- Error path: missing `.printing-press.json` — manifest check fails, others may still run
+- Error path: `go vet` fails — that check fails, build check may still run
+- Error path: manuscripts directory doesn't exist — manuscripts check warns but doesn't fail (per R25)
+- Happy path: package creates correct directory structure with CLI source and manuscripts
+- Happy path: package resolves most recent manuscript run by lexicographic sort
+- Edge case: multiple manuscript runs — uses the most recent
+- Edge case: no manuscript runs — packages without `.manuscripts/`, warns in output
+- Happy path: `--json` output contains all expected fields
+- Error path: `--target` directory already exists — fails with clear error
+
+**Verification:**
+- `go test ./internal/cli/...` passes
+- Validate command exits with `ExitPublishError` on failure, `ExitSuccess` on pass
+- Package command creates the correct directory tree in a temp staging dir
+
+---
+
+- [x] **Unit 5: Scaffold printing-press-library repo**
+
+**Goal:** Set up the printing-press-library repo at `~/Code/printing-press-library` with the correct directory structure, README, CONTRIBUTING.md, .gitignore, and empty registry.json.
+
+**Requirements:** R5, R6, R7, R8
+
+**Dependencies:** None (can run in parallel with Units 1-4)
+
+**Files (in ~/Code/printing-press-library/):**
+- Modify: `README.md`
+- Modify: `CONTRIBUTING.md`
+- Modify: `.gitignore`
+- Create: `registry.json`
+- Create: `library/.gitkeep` (anchor for the library root)
+
+**Approach:**
+- Rewrite README.md to reflect the new structure: `library/<category>/<cli-name>/`, the 14-category taxonomy, how the publish skill works, what "endorsed" means
+- Rewrite CONTRIBUTING.md to describe the publish-skill-driven workflow instead of manual PR instructions. Include: how to install printing-press, how to run `/printing-press publish`, what validation checks run, what the PR will contain
+- Update .gitignore to exclude: compiled Go binaries (`*-pp-cli` pattern without extension), `.DS_Store`, `vendor/`, `*.exe`, `*.test`, `*.out`, `__debug_bin*`
+- Create `registry.json` with schema: `{ "schema_version": 1, "entries": [] }`
+- Create `library/.gitkeep` so the directory exists in git. Category subdirectories are created by the publish skill on first use — no need to pre-create all 14
+
+**Patterns to follow:**
+- Current README.md structure for tone and content organization
+- Standard Go .gitignore patterns
+
+**Test scenarios:**
+- Happy path: `registry.json` is valid JSON with `schema_version: 1` and empty `entries` array
+- Happy path: `.gitignore` excludes compiled binaries and common artifacts
+- Happy path: README documents the `library/<category>/<cli-name>/` structure and the 14 categories
+
+**Verification:**
+- `cat registry.json | jq .` succeeds (valid JSON)
+- README accurately describes the publish workflow and category taxonomy
+- .gitignore covers the expected artifact patterns
+
+---
+
+- [x] **Unit 6: Create printing-press-publish skill**
+
+**Goal:** Create the `/printing-press publish` skill that orchestrates the full publish flow: name resolution, category assignment, validation, packaging, managed clone, and PR creation.
+
+**Requirements:** R10, R13a-R13e, R14-R22, R27-R34
+
+**Dependencies:** Units 3, 4 (CLI commands must exist), Unit 5 (library repo must be scaffolded)
+
+**Files:**
+- Create: `skills/printing-press-publish/SKILL.md`
+- Modify: `internal/pipeline/contracts_test.go` (add new skill to setup contract test table)
+- Test: `internal/pipeline/contracts_test.go`
+
+**Approach:**
+
+SKILL.md frontmatter:
+- `name: printing-press-publish`
+- `description: Publish a generated CLI to the printing-press-library repo`
+- `version: 0.1.0`
+- `min-binary-version:` set to whatever version includes the new `library list` and `publish` commands
+- `allowed-tools: Bash, Read, Write, Edit, Glob, Grep, AskUserQuestion`
+
+Setup contract: Copy the existing setup contract block from the main skill. It must pass `contracts_test.go` assertions (binary on PATH, `PRESS_HOME`, `PRESS_SCOPE`, `PRESS_RUNSTATE`, `PRESS_LIBRARY`, `PRESS_MANUSCRIPTS`, no `./printing-press`, no `go build`)
+
+Skill flow (markdown instructions in SKILL.md):
+
+**Step 1: Prerequisites check**
+- Verify `gh auth status` succeeds, else tell user to run `gh auth login`
+- Run setup contract
+
+**Step 2: Name resolution**
+- Run `printing-press library list --json`
+- Apply resolution: exact → suffix (`-pp-cli`) → glob (substring, capped at 5 most-recent matches per R17) → ask user
+- If no argument, show all CLIs sorted by modification time, ask user to pick
+
+**Step 3: Read manifest + determine category**
+- Read `.printing-press.json` from resolved CLI directory
+- If `category` present → confirm with user ("Publish as <category>?")
+- Else if `catalog_entry` present → `printing-press catalog show <entry> --json` → extract category → confirm
+- Else → present the 14 categories via AskUserQuestion
+
+**Step 4: Validate**
+- Run `printing-press publish validate --dir <path> --json`
+- Parse JSON result, display check results
+- If any check failed → report and stop
+
+**Step 5: Package**
+- Create temp staging dir
+- Run `printing-press publish package --dir <path> --category <cat> --target <staging> --json`
+- Parse JSON result
+
+**Step 6: Managed clone**
+- Check if `~/printing-press/.publish-repo/` exists
+- If not: detect push access via `gh api repos/mvanhorn/printing-press-library --jq '.permissions.push'`. Fork or clone based on result. Detect SSH vs HTTPS from git config. Cache in `~/printing-press/.publish-config.json`
+- If exists: read config, `git fetch origin`, `git checkout main`, `git pull`
+
+**Step 7: Branch + commit + PR**
+- Check for existing branch `feat/<cli-name>` (local and remote). If exists, ask user
+- `git checkout -b feat/<cli-name>`
+- Copy staged package into the managed clone
+- Update `registry.json` (read, add/update entry, write back)
+- `git add library/ registry.json` (targeted, not `git add .`)
+- `git commit -m "feat(<api-name>): add <cli-name>"`
+- `git push`
+- `gh pr create` with best-in-class PR description (see PR Description Format below)
+- Display the PR URL
+
+**PR Description Format:**
+The PR body is built from the manifest, README, and manuscripts. It should enable any reviewer (human or agent with no prior context) to understand the contribution:
+
+```
+## <cli-name>
+
+<one-liner description from manifest, or "No description available" if missing>
+
+**API:** <api_name> | **Category:** <category> | **Press version:** <printing_press_version>
+**Spec:** <spec_url or spec_path from manifest, or "Not specified">
+
+### CLI Shape
+
+\`\`\`bash
+$ <cli-name> --help
+<full --help output captured during validation>
+\`\`\`
+
+### What This CLI Does
+
+<First 2-3 paragraphs from the CLI's README, or note that README is missing>
+
+### Manuscripts
+
+- [Research Brief](link to .manuscripts/<run-id>/research/ in the PR branch)
+- [Shipcheck Results](link to .manuscripts/<run-id>/proofs/ in the PR branch)
+
+### Validation Results
+
+| Check | Result |
+|-------|--------|
+| Manifest | pass/fail |
+| go mod tidy | pass/fail |
+| go vet | pass/fail |
+| go build | pass/fail |
+| --help | pass/fail |
+| --version | pass/fail |
+| Manuscripts | present/missing |
+
+### Gaps
+
+<List any missing manifest fields: no description, no spec_url, no category, etc. Omit section if no gaps>
+```
+
+If any manifest fields are empty (description, spec_url, category), the Gaps section explicitly flags them rather than silently omitting. This gives reviewers a clear checklist of what's missing
+
+**Error recovery (Step 6/7):**
+- If managed clone has uncommitted changes, detect via `git status --porcelain` and ask user whether to reset or continue
+- If push fails, report the error clearly
+
+Update `contracts_test.go`: Add the new skill to the `TestSkillSetupBlocksMatchWorkspaceContract` test table with `expectsManuscripts: true`
+
+**Patterns to follow:**
+- `skills/printing-press/SKILL.md` for setup contract and structure
+- `skills/printing-press-score/SKILL.md` for name resolution pattern (exact → suffix → glob)
+
+**Test scenarios:**
+- Happy path: `contracts_test.go` validates the setup contract block contains all required variables and markers
+- Happy path: skill SKILL.md contains `PRESS_SETUP_CONTRACT_START` and `PRESS_SETUP_CONTRACT_END` markers
+- Happy path: skill SKILL.md does not reference `./printing-press` or `go build`
+- Happy path: skill SKILL.md references `PRESS_MANUSCRIPTS`
+
+**Verification:**
+- `go test ./internal/pipeline/...` passes (contracts test)
+- SKILL.md is valid YAML frontmatter + markdown
+- The skill flow covers all steps from the user flow diagram in the origin document
+
+## System-Wide Impact
+
+- **Interaction graph:** The publish skill invokes three new CLI commands (`library list`, `publish validate`, `publish package`) and two existing ones (`catalog show`, `version`). It also uses `gh` CLI for GitHub operations. The new CLI commands read from `~/printing-press/library/` and `~/printing-press/manuscripts/` (read-only)
+- **Error propagation:** CLI commands return structured JSON with per-check results. The skill interprets these and provides user-facing guidance. `ExitPublishError` code distinguishes publish failures from other error types
+- **State lifecycle risks:** The managed clone at `~/printing-press/.publish-repo/` can accumulate stale state from interrupted publishes. The skill detects uncommitted changes and offers reset. The `.publish-config.json` cache can become stale if the user changes GitHub access — cache entries should be re-probed on auth errors
+- **API surface parity:** The `library list` command introduces a new public CLI surface. It should follow the same `--json` conventions as `catalog list`
+- **Unchanged invariants:** The printing process itself is not modified. Existing CLIs without a `category` field continue to work — the field is `omitempty`. The `catalog` commands continue to work with the expanded category set. `petstore.yaml` continues to use `example` category
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| `gh repo fork` may behave unexpectedly when fork already exists | Test during implementation; fall back to detecting existing fork and cloning it |
+| Catalog category changes break existing tests | Atomic update in Unit 1 — categories and YAML files change together, all tests run before merge |
+| Version consistency test fails when version files are out of sync | Don't manually edit versions; let release-please handle it (see AGENTS.md versioning) |
+| Skill becomes token-heavy due to inline git bash | Monitor complexity during implementation; extract to scripts/ if git logic exceeds ~30 lines |
+| printing-press-library repo URL changes in the future | URL stored in `.publish-config.json`, single point of change (R13d) |
+
+## Documentation / Operational Notes
+
+- The printing-press-library README and CONTRIBUTING.md are rewritten as part of Unit 5
+- AGENTS.md in this repo may need a note about the new `library` and `publish` commands once they ship
+- The new skill needs a `min-binary-version` in frontmatter that gates on the version containing the new commands
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-03-29-publish-skill-requirements.md](docs/brainstorms/2026-03-29-publish-skill-requirements.md)
+- Catalog validation: `internal/catalog/catalog.go`
+- CLI manifest: `internal/pipeline/climanifest.go`
+- Path helpers: `internal/pipeline/paths.go`
+- Cobra command pattern: `internal/cli/catalog.go`
+- Setup contract tests: `internal/pipeline/contracts_test.go`
+- Naming conventions: `internal/naming/naming.go`
+- File copying: `internal/pipeline/publish.go` (`CopyDir`)
+- Score skill name resolution: `skills/printing-press-score/SKILL.md`
+- Decoupling plan: `docs/plans/2026-03-28-001-refactor-repo-decoupling-plan.md`
+- Library repo: `~/Code/printing-press-library` (github.com/mvanhorn/printing-press-library)
diff --git a/docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md b/docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md
new file mode 100644
index 00000000..8612e8ae
--- /dev/null
+++ b/docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md
@@ -0,0 +1,95 @@
+---
+title: Validation must not mutate the source directory
+date: 2026-03-29
+category: best-practices
+module: publish-workflow
+problem_type: best_practice
+component: tooling
+symptoms:
+ - go mod tidy validation silently passed in non-git directories while modifying go.mod/go.sum
+ - Compiled binaries left in CLI directory after validation were staged into library payload by CopyDir
+ - PRs opened from publish package contained checked-in compiled binaries
+root_cause: logic_error
+resolution_type: code_fix
+severity: high
+tags:
+ - validation
+ - mutation
+ - go-mod-tidy
+ - copydir
+ - binary-cleanup
+ - publish
+---
+
+# Validation must not mutate the source directory
+
+## Problem
+
+Validation steps in `publish validate` and `publish package` wrote artifacts into the CLI source directory. Since `publish package` copies the entire directory via `CopyDir`, any files created or modified during validation leaked into the packaged output — including compiled binaries and modified `go.mod`/`go.sum` files.
+
+## Symptoms
+
+- `go mod tidy` validation always passed in non-git directories, even when modules were untidy, because the git-based detection silently errored
+- `go.mod` and `go.sum` were permanently modified after running `publish validate` on standalone CLI directories
+- `publish package` staged a compiled binary alongside source code, bloating the payload and shipping a platform-specific binary into a cross-platform source distribution
+
+## What Didn't Work
+
+- **`git diff --name-only go.mod go.sum`** to detect tidy changes: returns non-zero in non-git directories. Error was swallowed, check reported PASS regardless
+- **`git checkout -- go.mod go.sum`** to restore after tidy: also fails in non-git directories, leaving mutation permanent
+- **Not tracking whether `findBuiltBinary` created the binary vs. found an existing one**: no way to know what to clean up without destroying user's pre-existing builds
+
+## Solution
+
+Two patterns applied:
+
+**Snapshot-compare-restore for go mod tidy:**
+
+Read `go.mod` and `go.sum` into byte slices before running `go mod tidy`. After tidy completes, compare the new content against the snapshots. Always restore originals regardless of outcome. If `go.sum` didn't exist before and tidy created it, remove it.
+
+```go
+// checkGoModTidy snapshots, runs tidy, compares, restores
+origMod, _ := os.ReadFile(modPath)
+origSum, _ := os.ReadFile(sumPath)
+
+// run go mod tidy...
+
+newMod, _ := os.ReadFile(modPath)
+modChanged := string(origMod) != string(newMod)
+
+// Always restore (validation is non-destructive)
+_ = os.WriteFile(modPath, origMod, 0o644)
+```
+
+**Build to temp directory for binary checks:**
+
+`buildValidationBinary` creates a temp directory inside the CLI directory (`.publish-validate-*`), builds the binary there, and returns a cleanup function. The binary never exists in the source tree proper.
+
+```go
+func buildValidationBinary(dir, cliName string) (path string, cleanup func(), err error) {
+ tempDir, err := os.MkdirTemp(dir, ".publish-validate-*")
+ // build into tempDir, not dir
+ // cleanup = func() { os.RemoveAll(tempDir) }
+}
+```
+
+Additionally, `snapshotFiles` captures the state of known build artifact locations before validation and restores them after, as a defense-in-depth measure.
+
+## Why This Works
+
+- **No git dependency.** The snapshot pattern uses only `os.ReadFile`/`os.WriteFile`. Works identically in git repos, standalone directories, or mounted volumes.
+- **Unconditional restore.** Originals are always written back, even if tidy or build fails.
+- **Precise cleanup.** Building to a temp directory means the binary never appears in the source tree. The `defer cleanup()` pattern ensures removal even on error paths.
+- **Ordering guarantee.** In `publish package`, validation runs first, deferred cleanups fire when validation returns, then `CopyDir` runs against a clean directory.
+
+## Prevention
+
+- **Treat the source directory as read-only during validation.** Any function that writes to the directory being validated must restore original state before returning. This is the implicit contract that `CopyDir` depends on.
+- **Build validation artifacts to temp directories, not the source tree.** Use `os.MkdirTemp` inside the directory (so the Go build's module context is correct) with a dot-prefixed name (excluded by `.gitignore`).
+- **Functions that create temporary artifacts must report what they created.** The `(value, cleanup func())` return pattern makes cleanup responsibility explicit at the call site.
+- **Never depend on git for validation of non-repo directories.** Library CLIs at `~/printing-press/library/` are standalone. Validation logic must work with pure filesystem operations.
+
+## Related Issues
+
+- `internal/cli/publish.go` — `checkGoModTidy`, `buildValidationBinary`, `snapshotFiles`, `runValidation`
+- Tangentially related: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md` — same "don't mutate in place" principle applied to emboss workflows
diff --git a/docs/solutions/security-issues/filepath-join-traversal-with-user-input-2026-03-29.md b/docs/solutions/security-issues/filepath-join-traversal-with-user-input-2026-03-29.md
new file mode 100644
index 00000000..7fcfe9b5
--- /dev/null
+++ b/docs/solutions/security-issues/filepath-join-traversal-with-user-input-2026-03-29.md
@@ -0,0 +1,81 @@
+---
+title: User input in filepath.Join needs traversal protection
+date: 2026-03-29
+category: security-issues
+module: publish-workflow
+problem_type: security_issue
+component: tooling
+symptoms:
+ - publish package --category '../../../escape' created directories outside the target root
+ - No error raised — filepath.Join silently resolves .. segments
+ - staged_dir in JSON output showed the escaped path
+root_cause: missing_validation
+resolution_type: code_fix
+severity: high
+tags:
+ - path-traversal
+ - filepath-join
+ - input-validation
+ - security
+ - publish
+---
+
+# User input in filepath.Join needs traversal protection
+
+## Problem
+
+User-supplied input passed directly to `filepath.Join` as a path segment enables directory traversal. The `publish package` command's `--category` flag was concatenated into `filepath.Join(target, "library", category, cliName)`. A value like `../../../escape` caused the staging directory to be created outside the target directory entirely.
+
+## Symptoms
+
+- Running `publish package --category "../../../escape"` created the staged directory at an unexpected absolute path (e.g., `/escape/cli` instead of `/tmp/staging/library/escape/cli`)
+- No error was raised — `filepath.Join` silently resolves `..` segments, and `os.MkdirAll` creates any directory the process has write permission for
+- The `PackageResult.StagedDir` in JSON output showed the escaped path, but nothing stopped the operation
+
+## What Didn't Work
+
+- **Non-empty check only**: Checking `category == ""` catches blank input but permits any string including path traversal sequences
+- **Relying on the OS to prevent writes**: Go's `filepath.Join` resolves `..` without error. `os.MkdirAll` happily creates directories anywhere. There is no built-in guardrail
+
+## Solution
+
+Belt-and-suspenders: input validation AND resolved-path verification.
+
+**Layer 1 — Input validation:** Reject values containing `/`, `\`, or `..` before they reach `filepath.Join`:
+
+```go
+if strings.Contains(category, "/") || strings.Contains(category, "\\") || strings.Contains(category, "..") {
+ return fmt.Errorf("--category must be a simple slug (no path separators or '..')")
+}
+```
+
+**Layer 2 — Resolved-path verification:** After `filepath.Join`, verify the absolute path is under the target:
+
+```go
+absTarget, _ := filepath.Abs(target)
+absStaging, _ := filepath.Abs(stagingCLIDir)
+if !strings.HasPrefix(absStaging, absTarget+string(filepath.Separator)) {
+ return fmt.Errorf("resolved path %s escapes target %s", absStaging, absTarget)
+}
+```
+
+The `+ string(filepath.Separator)` suffix prevents false positives where a sibling directory shares a prefix (e.g., `/tmp/staging-evil` would falsely match `/tmp/staging` without the separator).
+
+## Why This Works
+
+- **Layer 1 catches obvious cases** at the input boundary with a clear error message. Fast, user-friendly.
+- **Layer 2 catches anything Layer 1 misses.** It operates on the resolved path, not raw input, so encoding variations and platform-specific normalization can't bypass it.
+- **Either check alone has edge cases; together they cover each other's gaps.**
+
+## Prevention
+
+- **Treat all user-provided strings used in path construction as untrusted.** Even "name-like" inputs (categories, slugs, project names) become path components the moment they enter `filepath.Join`.
+- **Use the belt-and-suspenders pattern.** Validate raw input for traversal characters AND verify the resolved absolute path is contained within the expected root.
+- **Codify the containment assertion.** Any function that accepts user input and feeds it to `filepath.Join` should include: `strings.HasPrefix(absResult, absRoot + sep)`. This is cheap, stateless, and catches classes of bugs that input validation alone cannot.
+- **Never assume `filepath.Join` is safe.** Unlike URL path joining in some frameworks, Go's `filepath.Join` performs lexical `..` resolution without any security boundary. It is a string operation, not an access-control mechanism.
+- **Existing precedent in this codebase:** `sanitizeResourceName()` in `internal/openapi/parser.go` addresses the same vulnerability class for OpenAPI resource names. The `publish package` path through `internal/cli/publish.go` is a separate attack surface that needed its own protection.
+
+## Related Issues
+
+- `internal/cli/publish.go` — `newPublishPackageCmd` category validation and path containment check
+- `internal/openapi/parser.go` — `sanitizeResourceName()` for the same vulnerability class in OpenAPI parsing
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index 9ef1eec4..57d76c38 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -15,14 +15,22 @@ import (
var namePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
var validCategories = map[string]struct{}{
- "auth": {},
- "payments": {},
- "email": {},
- "developer-tools": {},
- "project-management": {},
- "communication": {},
- "crm": {},
- "example": {},
+ "developer-tools": {},
+ "monitoring": {},
+ "cloud": {},
+ "project-management": {},
+ "productivity": {},
+ "social-and-messaging": {},
+ "sales-and-crm": {},
+ "marketing": {},
+ "payments": {},
+ "auth": {},
+ "commerce": {},
+ "ai": {},
+ "media-and-entertainment": {},
+ "devices": {},
+ "other": {},
+ "example": {},
}
var validSpecFormats = map[string]struct{}{
@@ -135,7 +143,7 @@ func (e *Entry) Validate() error {
return fmt.Errorf("category is required")
}
if _, ok := validCategories[e.Category]; !ok {
- return fmt.Errorf("category must be one of: auth, payments, email, developer-tools, project-management, communication, crm, example")
+ return fmt.Errorf("category must be one of: %s", strings.Join(PublicCategories(), ", "))
}
if e.SpecURL == "" {
return fmt.Errorf("spec_url is required")
@@ -158,3 +166,25 @@ func (e *Entry) Validate() error {
return nil
}
+
+// PublicCategories returns the sorted list of user-facing categories.
+// It excludes "example", which is internal-only for test fixtures.
+func PublicCategories() []string {
+ cats := make([]string, 0, len(validCategories))
+ for c := range validCategories {
+ if c != "example" {
+ cats = append(cats, c)
+ }
+ }
+ sort.Strings(cats)
+ return cats
+}
+
+// IsPublicCategory reports whether category is allowed in user-facing workflows.
+func IsPublicCategory(category string) bool {
+ if category == "example" {
+ return false
+ }
+ _, ok := validCategories[category]
+ return ok
+}
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index bdd318f6..3e5b8e64 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -126,6 +126,92 @@ func TestValidateEntry(t *testing.T) {
}
}
+func TestAllPublicCategoriesAreValid(t *testing.T) {
+ publicCategories := []string{
+ "developer-tools", "monitoring", "cloud", "project-management",
+ "productivity", "social-and-messaging", "sales-and-crm", "marketing",
+ "payments", "auth", "commerce", "ai", "media-and-entertainment",
+ "devices", "other",
+ }
+ base := Entry{
+ Name: "test-api",
+ DisplayName: "Test API",
+ Description: "A valid catalog entry",
+ SpecURL: "https://example.com/openapi.yaml",
+ SpecFormat: "yaml",
+ Tier: "official",
+ }
+ for _, cat := range publicCategories {
+ t.Run(cat, func(t *testing.T) {
+ entry := base
+ entry.Category = cat
+ assert.NoError(t, entry.Validate())
+ })
+ }
+}
+
+func TestExampleCategoryStillValid(t *testing.T) {
+ entry := Entry{
+ Name: "test-api",
+ DisplayName: "Test API",
+ Description: "A valid catalog entry",
+ Category: "example",
+ SpecURL: "https://example.com/openapi.yaml",
+ SpecFormat: "yaml",
+ Tier: "official",
+ }
+ assert.NoError(t, entry.Validate())
+}
+
+func TestOldCategoriesRejected(t *testing.T) {
+ base := Entry{
+ Name: "test-api",
+ DisplayName: "Test API",
+ Description: "A valid catalog entry",
+ SpecURL: "https://example.com/openapi.yaml",
+ SpecFormat: "yaml",
+ Tier: "official",
+ }
+ for _, cat := range []string{"email", "crm", "communication"} {
+ t.Run(cat, func(t *testing.T) {
+ entry := base
+ entry.Category = cat
+ err := entry.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "category must be one of")
+ })
+ }
+}
+
+func TestCategoryErrorMessageExcludesExample(t *testing.T) {
+ entry := Entry{
+ Name: "test-api",
+ DisplayName: "Test API",
+ Description: "A valid catalog entry",
+ Category: "invalid-cat",
+ SpecURL: "https://example.com/openapi.yaml",
+ SpecFormat: "yaml",
+ Tier: "official",
+ }
+ err := entry.Validate()
+ require.Error(t, err)
+ assert.NotContains(t, err.Error(), "example")
+}
+
+func TestPublicCategoriesExcludeExample(t *testing.T) {
+ categories := PublicCategories()
+ assert.NotContains(t, categories, "example")
+ assert.Contains(t, categories, "developer-tools")
+ assert.Contains(t, categories, "other")
+}
+
+func TestIsPublicCategory(t *testing.T) {
+ assert.True(t, IsPublicCategory("developer-tools"))
+ assert.True(t, IsPublicCategory("other"))
+ assert.False(t, IsPublicCategory("example"))
+ assert.False(t, IsPublicCategory("banana"))
+}
+
func TestParseDir(t *testing.T) {
entries, err := ParseDir("../../testdata/catalog")
require.NoError(t, err)
diff --git a/internal/cli/exitcodes.go b/internal/cli/exitcodes.go
index 10501d21..9d7be347 100644
--- a/internal/cli/exitcodes.go
+++ b/internal/cli/exitcodes.go
@@ -7,6 +7,7 @@ const (
ExitSpecError = 2
ExitGenerationError = 3
ExitUnknownError = 4
+ ExitPublishError = 5
)
// ExitError wraps an error with a specific exit code.
diff --git a/internal/cli/library.go b/internal/cli/library.go
new file mode 100644
index 00000000..9d615846
--- /dev/null
+++ b/internal/cli/library.go
@@ -0,0 +1,144 @@
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+
+ "github.com/mvanhorn/cli-printing-press/internal/naming"
+ "github.com/mvanhorn/cli-printing-press/internal/pipeline"
+ "github.com/spf13/cobra"
+)
+
+// LibraryEntry represents a CLI in the local library for listing purposes.
+type LibraryEntry struct {
+ CLIName string `json:"cli_name"`
+ Dir string `json:"dir"`
+ APIName string `json:"api_name,omitempty"`
+ Category string `json:"category,omitempty"`
+ CatalogEntry string `json:"catalog_entry,omitempty"`
+ Description string `json:"description,omitempty"`
+ Modified time.Time `json:"modified"`
+}
+
+func newLibraryCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "library",
+ Short: "Manage CLIs in the local library",
+ Example: ` # List all CLIs in the library
+ printing-press library list
+
+ # List as JSON for tooling
+ printing-press library list --json`,
+ }
+
+ cmd.AddCommand(newLibraryListCmd())
+
+ return cmd
+}
+
+func newLibraryListCmd() *cobra.Command {
+ var asJSON bool
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List all CLIs in the local library",
+ Example: ` printing-press library list
+ printing-press library list --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ entries, err := scanLibrary()
+ if err != nil {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("scanning library: %w", err)}
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(entries)
+ }
+
+ if len(entries) == 0 {
+ fmt.Fprintln(os.Stderr, "No CLIs found in library.")
+ return nil
+ }
+
+ fmt.Fprintf(os.Stderr, "Found %d CLIs in library:\n\n", len(entries))
+ for _, e := range entries {
+ cat := e.Category
+ if cat == "" {
+ cat = "-"
+ }
+ fmt.Printf(" %-30s %-20s %s\n", e.CLIName, cat, e.Description)
+ }
+
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+
+ return cmd
+}
+
+func scanLibrary() ([]LibraryEntry, error) {
+ libRoot := pipeline.PublishedLibraryRoot()
+
+ dirEntries, err := os.ReadDir(libRoot)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []LibraryEntry{}, nil
+ }
+ return nil, fmt.Errorf("reading library: %w", err)
+ }
+
+ var entries []LibraryEntry
+ for _, de := range dirEntries {
+ if !de.IsDir() {
+ continue
+ }
+ dirName := de.Name()
+
+ // Accept directories that look like CLI names or contain a manifest
+ dirPath := filepath.Join(libRoot, dirName)
+ manifestPath := filepath.Join(dirPath, pipeline.CLIManifestFilename)
+
+ entry := LibraryEntry{
+ CLIName: dirName,
+ Dir: dirPath,
+ }
+
+ // Get modification time
+ if info, err := de.Info(); err == nil {
+ entry.Modified = info.ModTime()
+ }
+
+ // Try to read the manifest for metadata
+ if data, err := os.ReadFile(manifestPath); err == nil {
+ var m pipeline.CLIManifest
+ if json.Unmarshal(data, &m) == nil {
+ if m.CLIName != "" {
+ entry.CLIName = m.CLIName
+ }
+ entry.APIName = m.APIName
+ entry.Category = m.Category
+ entry.CatalogEntry = m.CatalogEntry
+ entry.Description = m.Description
+ }
+ }
+
+ // Only include directories that look like CLIs or have a manifest
+ if naming.IsCLIDirName(dirName) || entry.APIName != "" {
+ entries = append(entries, entry)
+ }
+ }
+
+ // Sort by modification time, most recent first
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].Modified.After(entries[j].Modified)
+ })
+
+ return entries, nil
+}
diff --git a/internal/cli/library_test.go b/internal/cli/library_test.go
new file mode 100644
index 00000000..04116e16
--- /dev/null
+++ b/internal/cli/library_test.go
@@ -0,0 +1,179 @@
+package cli
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/internal/pipeline"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func setLibraryTestEnv(t *testing.T) string {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", home)
+ return home
+}
+
+func writeTestManifest(t *testing.T, dir string, m pipeline.CLIManifest) {
+ t.Helper()
+ data, err := json.MarshalIndent(m, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, pipeline.CLIManifestFilename), data, 0o644))
+}
+
+func TestLibraryListJSONWithManifests(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ // Create two CLI directories with manifests
+ cli1Dir := filepath.Join(libDir, "notion-pp-cli")
+ require.NoError(t, os.MkdirAll(cli1Dir, 0o755))
+ writeTestManifest(t, cli1Dir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "notion",
+ CLIName: "notion-pp-cli",
+ Category: "productivity",
+ CatalogEntry: "notion",
+ Description: "Notion workspace API",
+ })
+
+ cli2Dir := filepath.Join(libDir, "stripe-pp-cli")
+ require.NoError(t, os.MkdirAll(cli2Dir, 0o755))
+ writeTestManifest(t, cli2Dir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "stripe",
+ CLIName: "stripe-pp-cli",
+ Category: "payments",
+ CatalogEntry: "stripe",
+ Description: "Stripe payment processing API",
+ })
+
+ cmd := newLibraryCmd()
+ cmd.SetArgs([]string{"list", "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var entries []LibraryEntry
+ require.NoError(t, json.Unmarshal([]byte(output), &entries))
+ assert.Len(t, entries, 2)
+
+ // Verify fields are populated from manifest
+ names := map[string]bool{}
+ for _, e := range entries {
+ names[e.CLIName] = true
+ assert.NotEmpty(t, e.Dir)
+ assert.NotEmpty(t, e.APIName)
+ assert.NotEmpty(t, e.Category)
+ assert.False(t, e.Modified.IsZero())
+ }
+ assert.True(t, names["notion-pp-cli"])
+ assert.True(t, names["stripe-pp-cli"])
+}
+
+func TestLibraryListEmptyLibrary(t *testing.T) {
+ setLibraryTestEnv(t)
+ // Library directory doesn't exist yet
+
+ cmd := newLibraryCmd()
+ cmd.SetArgs([]string{"list", "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var entries []LibraryEntry
+ require.NoError(t, json.Unmarshal([]byte(output), &entries))
+ assert.Empty(t, entries)
+}
+
+func TestLibraryListMissingManifest(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ // CLI directory exists but no manifest
+ cliDir := filepath.Join(libDir, "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ cmd := newLibraryCmd()
+ cmd.SetArgs([]string{"list", "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var entries []LibraryEntry
+ require.NoError(t, json.Unmarshal([]byte(output), &entries))
+ assert.Len(t, entries, 1)
+ assert.Equal(t, "test-pp-cli", entries[0].CLIName)
+ assert.Empty(t, entries[0].APIName)
+ assert.Empty(t, entries[0].Category)
+}
+
+func TestLibraryListMalformedManifest(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ cliDir := filepath.Join(libDir, "bad-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(cliDir, pipeline.CLIManifestFilename),
+ []byte("not valid json{{{"),
+ 0o644,
+ ))
+
+ cmd := newLibraryCmd()
+ cmd.SetArgs([]string{"list", "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var entries []LibraryEntry
+ require.NoError(t, json.Unmarshal([]byte(output), &entries))
+ assert.Len(t, entries, 1)
+ assert.Equal(t, "bad-pp-cli", entries[0].CLIName)
+ assert.Empty(t, entries[0].APIName)
+}
+
+func TestLibraryListClaimedRerunSuffix(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ // A claimed rerun directory with -2 suffix
+ cliDir := filepath.Join(libDir, "test-pp-cli-2")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ cmd := newLibraryCmd()
+ cmd.SetArgs([]string{"list", "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var entries []LibraryEntry
+ require.NoError(t, json.Unmarshal([]byte(output), &entries))
+ assert.Len(t, entries, 1)
+ assert.Equal(t, "test-pp-cli-2", entries[0].CLIName)
+}
+
+func TestLibraryListIgnoresNonCLIDirectories(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ libDir := filepath.Join(home, "library")
+
+ // A directory that's not a CLI and has no manifest
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "random-dir"), 0o755))
+ // A real CLI directory
+ require.NoError(t, os.MkdirAll(filepath.Join(libDir, "test-pp-cli"), 0o755))
+
+ cmd := newLibraryCmd()
+ cmd.SetArgs([]string{"list", "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var entries []LibraryEntry
+ require.NoError(t, json.Unmarshal([]byte(output), &entries))
+ assert.Len(t, entries, 1)
+ assert.Equal(t, "test-pp-cli", entries[0].CLIName)
+}
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
new file mode 100644
index 00000000..0d016886
--- /dev/null
+++ b/internal/cli/publish.go
@@ -0,0 +1,572 @@
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ catalogpkg "github.com/mvanhorn/cli-printing-press/internal/catalog"
+ "github.com/mvanhorn/cli-printing-press/internal/naming"
+ "github.com/mvanhorn/cli-printing-press/internal/pipeline"
+ "github.com/spf13/cobra"
+)
+
+const (
+ goCommandTimeout = 2 * time.Minute
+ binaryCheckTimeout = 15 * time.Second
+)
+
+func newPublishCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "publish",
+ Short: "Validate and package CLIs for publishing",
+ Example: ` # Validate a CLI before publishing
+ printing-press publish validate --dir ~/printing-press/library/notion-pp-cli --json
+
+ # Package a CLI for publishing
+ printing-press publish package --dir ~/printing-press/library/notion-pp-cli --category productivity --target /tmp/staging --json`,
+ }
+
+ cmd.AddCommand(newPublishValidateCmd())
+ cmd.AddCommand(newPublishPackageCmd())
+
+ return cmd
+}
+
+// CheckResult represents a single validation check.
+type CheckResult struct {
+ Name string `json:"name"`
+ Passed bool `json:"passed"`
+ Error string `json:"error,omitempty"`
+ Warning string `json:"warning,omitempty"`
+}
+
+// ValidateResult is the JSON output of publish validate.
+type ValidateResult struct {
+ Passed bool `json:"passed"`
+ CLIName string `json:"cli_name"`
+ APIName string `json:"api_name"`
+ HelpOutput string `json:"help_output,omitempty"`
+ Checks []CheckResult `json:"checks"`
+}
+
+// PackageResult is the JSON output of publish package.
+type PackageResult struct {
+ StagedDir string `json:"staged_dir"`
+ CLIName string `json:"cli_name"`
+ APIName string `json:"api_name"`
+ Category string `json:"category"`
+ ManuscriptsIncluded bool `json:"manuscripts_included"`
+ RunID string `json:"run_id,omitempty"`
+}
+
+func newPublishValidateCmd() *cobra.Command {
+ var dir string
+ var asJSON bool
+
+ cmd := &cobra.Command{
+ Use: "validate",
+ Short: "Validate a CLI is ready for publishing",
+ Example: ` printing-press publish validate --dir ~/printing-press/library/notion-pp-cli
+ printing-press publish validate --dir ~/printing-press/library/notion-pp-cli --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dir == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
+ }
+
+ result := runValidation(dir)
+
+ if asJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ if encErr := enc.Encode(result); encErr != nil {
+ return encErr
+ }
+ if !result.Passed {
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("validation failed")}
+ }
+ return nil
+ }
+
+ // Human-readable output
+ for _, c := range result.Checks {
+ status := "PASS"
+ if c.Warning != "" && c.Passed {
+ status = "WARN"
+ }
+ if !c.Passed {
+ status = "FAIL"
+ }
+ fmt.Fprintf(os.Stderr, " %-20s %s", c.Name, status)
+ if c.Error != "" {
+ fmt.Fprintf(os.Stderr, " %s", c.Error)
+ }
+ if c.Warning != "" {
+ fmt.Fprintf(os.Stderr, " %s", c.Warning)
+ }
+ fmt.Fprintln(os.Stderr)
+ }
+
+ if !result.Passed {
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("validation failed")}
+ }
+ fmt.Fprintln(os.Stderr, "\nAll checks passed.")
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dir, "dir", "", "CLI directory to validate (required)")
+ cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+
+ return cmd
+}
+
+func newPublishPackageCmd() *cobra.Command {
+ var dir string
+ var category string
+ var target string
+ var asJSON bool
+
+ cmd := &cobra.Command{
+ Use: "package",
+ Short: "Package a CLI for publishing to the library repo",
+ Example: ` printing-press publish package --dir ~/printing-press/library/notion-pp-cli --category productivity --target /tmp/staging --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dir == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
+ }
+ if category == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--category is required")}
+ }
+ if strings.Contains(category, "/") || strings.Contains(category, "\\") || strings.Contains(category, "..") {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--category must be a simple slug (no path separators or '..')")}
+ }
+ if !catalogpkg.IsPublicCategory(category) {
+ return &ExitError{
+ Code: ExitInputError,
+ Err: fmt.Errorf("--category must be one of: %s", strings.Join(catalogpkg.PublicCategories(), ", ")),
+ }
+ }
+ if target == "" {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--target is required")}
+ }
+
+ // Check target doesn't exist (before expensive validation)
+ if _, err := os.Stat(target); err == nil {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("target directory already exists: %s", target)}
+ }
+
+ // Re-validate before packaging
+ vResult := runValidation(dir)
+ if !vResult.Passed {
+ if asJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ if encErr := enc.Encode(vResult); encErr != nil {
+ return encErr
+ }
+ }
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("validation failed, cannot package")}
+ }
+
+ // Determine CLI name from manifest or directory name
+ cliName := vResult.CLIName
+ if cliName == "" {
+ cliName = filepath.Base(dir)
+ }
+
+ // Build staging structure: target/library/<category>/<cli-name>/
+ stagingCLIDir := filepath.Join(target, "library", category, cliName)
+
+ // Verify the resolved path is actually under target (defense in depth)
+ absTarget, _ := filepath.Abs(target)
+ absStaging, _ := filepath.Abs(stagingCLIDir)
+ if !strings.HasPrefix(absStaging, absTarget+string(filepath.Separator)) {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("resolved staging path %s escapes target directory %s", absStaging, absTarget)}
+ }
+
+ cleanupTarget := func() {
+ _ = os.RemoveAll(target)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(stagingCLIDir), 0o755); err != nil {
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("creating staging dir: %w", err)}
+ }
+
+ // Copy CLI source
+ if err := pipeline.CopyDir(dir, stagingCLIDir); err != nil {
+ cleanupTarget()
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("copying CLI: %w", err)}
+ }
+
+ // Resolve and copy manuscripts
+ result := PackageResult{
+ StagedDir: stagingCLIDir,
+ CLIName: cliName,
+ APIName: vResult.APIName,
+ Category: category,
+ }
+
+ apiName := vResult.APIName
+ if apiName == "" {
+ apiName = naming.TrimCLISuffix(cliName)
+ }
+
+ msRoot := pipeline.PublishedManuscriptsRoot()
+ msAPIDir := filepath.Join(msRoot, apiName)
+ runID, err := findMostRecentRun(msAPIDir)
+ if err == nil && runID != "" {
+ result.RunID = runID
+ srcMsDir := filepath.Join(msAPIDir, runID)
+ dstMsDir := filepath.Join(stagingCLIDir, ".manuscripts", runID)
+ if err := pipeline.CopyDir(srcMsDir, dstMsDir); err != nil {
+ cleanupTarget()
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("copying manuscripts: %w", err)}
+ } else {
+ result.ManuscriptsIncluded = true
+ }
+ } else {
+ fmt.Fprintln(os.Stderr, "warning: no manuscripts found, packaging without them")
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(result)
+ }
+
+ fmt.Fprintf(os.Stderr, "Packaged %s at %s\n", cliName, stagingCLIDir)
+ if result.ManuscriptsIncluded {
+ fmt.Fprintf(os.Stderr, " Manuscripts: %s (run %s)\n", ".manuscripts/"+runID, runID)
+ } else {
+ fmt.Fprintln(os.Stderr, " Manuscripts: not included (none found)")
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dir, "dir", "", "CLI directory to package (required)")
+ cmd.Flags().StringVar(&category, "category", "", "Category for the CLI (required)")
+ cmd.Flags().StringVar(&target, "target", "", "Staging directory to create (required)")
+ cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
+
+ return cmd
+}
+
+func runValidation(dir string) ValidateResult {
+ result := ValidateResult{}
+ allPassed := true
+
+ // 1. Manifest check
+ manifestPath := filepath.Join(dir, pipeline.CLIManifestFilename)
+ data, err := os.ReadFile(manifestPath)
+ if err != nil {
+ result.Checks = append(result.Checks, CheckResult{Name: "manifest", Passed: false, Error: "missing .printing-press.json"})
+ allPassed = false
+ } else {
+ var m pipeline.CLIManifest
+ if err := json.Unmarshal(data, &m); err != nil {
+ result.Checks = append(result.Checks, CheckResult{Name: "manifest", Passed: false, Error: fmt.Sprintf("invalid JSON: %v", err)})
+ allPassed = false
+ } else {
+ if m.APIName == "" || m.CLIName == "" {
+ result.Checks = append(result.Checks, CheckResult{Name: "manifest", Passed: false, Error: "missing required fields (api_name, cli_name)"})
+ allPassed = false
+ } else {
+ result.Checks = append(result.Checks, CheckResult{Name: "manifest", Passed: true})
+ result.CLIName = m.CLIName
+ result.APIName = m.APIName
+ }
+ }
+ }
+
+ cliName := result.CLIName
+ if cliName == "" {
+ cliName = filepath.Base(dir)
+ }
+
+ restoreBuildArtifacts := snapshotFiles(buildArtifactCandidates(dir, cliName))
+ defer restoreBuildArtifacts()
+
+ // 2. go mod tidy check — snapshot files, run tidy, compare, restore
+ tidyCheck := checkGoModTidy(dir)
+ if !tidyCheck.Passed {
+ allPassed = false
+ }
+ result.Checks = append(result.Checks, tidyCheck)
+
+ // 3. go vet check
+ vetCheck := runGoCheck(dir, "vet", "./...")
+ if !vetCheck.Passed {
+ allPassed = false
+ }
+ result.Checks = append(result.Checks, vetCheck)
+
+ // 4. go build check
+ buildCheck := runGoCheck(dir, "build", "./...")
+ if !buildCheck.Passed {
+ allPassed = false
+ }
+ result.Checks = append(result.Checks, buildCheck)
+
+ // 5. --help / --version checks use a dedicated temp binary so validation
+ // exercises current source without depending on or mutating source-tree artifacts.
+ binaryPath, cleanupBinary, err := buildValidationBinary(dir, cliName)
+ if cleanupBinary != nil {
+ defer cleanupBinary()
+ }
+ if err != nil {
+ result.Checks = append(result.Checks, CheckResult{Name: "--help", Passed: false, Error: "built binary not found"})
+ result.Checks = append(result.Checks, CheckResult{Name: "--version", Passed: false, Error: "built binary not found"})
+ allPassed = false
+ } else {
+ helpCtx, helpCancel := context.WithTimeout(context.Background(), binaryCheckTimeout)
+ defer helpCancel()
+ helpCmd := exec.CommandContext(helpCtx, binaryPath, "--help")
+ helpCmd.Dir = dir
+ helpOut, helpErr := helpCmd.CombinedOutput()
+ if helpErr != nil {
+ errMsg := fmt.Sprintf("exit error: %v", helpErr)
+ if helpCtx.Err() == context.DeadlineExceeded {
+ errMsg = fmt.Sprintf("timed out after %s", binaryCheckTimeout)
+ }
+ result.Checks = append(result.Checks, CheckResult{Name: "--help", Passed: false, Error: errMsg})
+ allPassed = false
+ } else {
+ result.Checks = append(result.Checks, CheckResult{Name: "--help", Passed: true})
+ result.HelpOutput = string(helpOut)
+ }
+
+ // 6. --version check
+ verCtx, verCancel := context.WithTimeout(context.Background(), binaryCheckTimeout)
+ defer verCancel()
+ versionCmd := exec.CommandContext(verCtx, binaryPath, "--version")
+ versionCmd.Dir = dir
+ if _, vErr := versionCmd.CombinedOutput(); vErr != nil {
+ errMsg := fmt.Sprintf("exit error: %v", vErr)
+ if verCtx.Err() == context.DeadlineExceeded {
+ errMsg = fmt.Sprintf("timed out after %s", binaryCheckTimeout)
+ }
+ result.Checks = append(result.Checks, CheckResult{Name: "--version", Passed: false, Error: errMsg})
+ allPassed = false
+ } else {
+ result.Checks = append(result.Checks, CheckResult{Name: "--version", Passed: true})
+ }
+ }
+
+ // 7. Manuscripts check (warn-only)
+ apiName := result.APIName
+ if apiName == "" {
+ apiName = naming.TrimCLISuffix(cliName)
+ }
+ msDir := filepath.Join(pipeline.PublishedManuscriptsRoot(), apiName)
+ if _, err := os.Stat(msDir); os.IsNotExist(err) {
+ result.Checks = append(result.Checks, CheckResult{Name: "manuscripts", Passed: true, Warning: "no manuscripts found"})
+ } else {
+ runID, err := findMostRecentRun(msDir)
+ if err != nil || runID == "" {
+ result.Checks = append(result.Checks, CheckResult{Name: "manuscripts", Passed: true, Warning: "manuscripts directory exists but no runs found"})
+ } else {
+ result.Checks = append(result.Checks, CheckResult{Name: "manuscripts", Passed: true})
+ }
+ }
+
+ result.Passed = allPassed
+ return result
+}
+
+func runGoCheck(dir string, args ...string) CheckResult {
+ name := "go " + args[0]
+ ctx, cancel := context.WithTimeout(context.Background(), goCommandTimeout)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "go", args...)
+ cmd.Dir = dir
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ errMsg := strings.TrimSpace(string(output))
+ if ctx.Err() == context.DeadlineExceeded {
+ errMsg = fmt.Sprintf("timed out after %s", goCommandTimeout)
+ } else if errMsg == "" {
+ errMsg = err.Error()
+ }
+ return CheckResult{Name: name, Passed: false, Error: errMsg}
+ }
+ return CheckResult{Name: name, Passed: true}
+}
+
+func checkGoModTidy(dir string) CheckResult {
+ modPath := filepath.Join(dir, "go.mod")
+ sumPath := filepath.Join(dir, "go.sum")
+
+ // Snapshot current content
+ origMod, modErr := os.ReadFile(modPath)
+ origSum, _ := os.ReadFile(sumPath) // go.sum may not exist yet
+
+ if modErr != nil {
+ return CheckResult{Name: "go mod tidy", Passed: false, Error: "go.mod not found"}
+ }
+
+ // Run go mod tidy with timeout
+ ctx, cancel := context.WithTimeout(context.Background(), goCommandTimeout)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "go", "mod", "tidy")
+ cmd.Dir = dir
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ // Restore originals before returning
+ _ = os.WriteFile(modPath, origMod, 0o644)
+ if origSum != nil {
+ _ = os.WriteFile(sumPath, origSum, 0o644)
+ }
+ errMsg := strings.TrimSpace(string(output))
+ if errMsg == "" {
+ errMsg = err.Error()
+ }
+ return CheckResult{Name: "go mod tidy", Passed: false, Error: errMsg}
+ }
+
+ // Compare with originals
+ newMod, _ := os.ReadFile(modPath)
+ newSum, _ := os.ReadFile(sumPath)
+
+ modChanged := string(origMod) != string(newMod)
+ sumChanged := string(origSum) != string(newSum)
+
+ // Always restore originals (validation should be non-destructive)
+ _ = os.WriteFile(modPath, origMod, 0o644)
+ if origSum != nil {
+ _ = os.WriteFile(sumPath, origSum, 0o644)
+ } else {
+ // go.sum didn't exist before; if tidy created it, remove it
+ if sumChanged {
+ _ = os.Remove(sumPath)
+ }
+ }
+
+ if modChanged || sumChanged {
+ return CheckResult{Name: "go mod tidy", Passed: false, Error: "go.mod or go.sum is not tidy"}
+ }
+ return CheckResult{Name: "go mod tidy", Passed: true}
+}
+
+func buildValidationBinary(dir, cliName string) (path string, cleanup func(), err error) {
+ tempDir, err := os.MkdirTemp(dir, ".publish-validate-*")
+ if err != nil {
+ return "", nil, err
+ }
+
+ cleanup = func() {
+ _ = os.RemoveAll(tempDir)
+ }
+
+ outPath := filepath.Join(tempDir, cliName)
+ if err := buildBinaryAtPath(dir, outPath, "./cmd/"+cliName); err == nil {
+ return outPath, cleanup, nil
+ }
+ if err := buildBinaryAtPath(dir, outPath, "."); err == nil {
+ return outPath, cleanup, nil
+ }
+
+ cleanup()
+ return "", nil, fmt.Errorf("building validation binary")
+}
+
+func buildBinaryAtPath(dir, outPath, pkg string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), goCommandTimeout)
+ defer cancel()
+ buildCmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, pkg)
+ buildCmd.Dir = dir
+ return buildCmd.Run()
+}
+
+func buildArtifactCandidates(dir, cliName string) []string {
+ return []string{
+ filepath.Join(dir, cliName),
+ filepath.Join(dir, "cmd", cliName, cliName),
+ }
+}
+
+type fileSnapshot struct {
+ path string
+ exists bool
+ mode os.FileMode
+ content []byte
+}
+
+func snapshotFiles(paths []string) func() {
+ snapshots := make([]fileSnapshot, 0, len(paths))
+ for _, path := range paths {
+ snap := fileSnapshot{path: path}
+ if info, err := os.Stat(path); err == nil && !info.IsDir() {
+ content, readErr := os.ReadFile(path)
+ if readErr == nil {
+ snap.exists = true
+ snap.mode = info.Mode()
+ snap.content = content
+ }
+ }
+ snapshots = append(snapshots, snap)
+ }
+
+ return func() {
+ for _, snap := range snapshots {
+ if snap.exists {
+ if err := os.WriteFile(snap.path, snap.content, snap.mode); err == nil {
+ _ = os.Chmod(snap.path, snap.mode)
+ }
+ continue
+ }
+ _ = os.Remove(snap.path)
+ }
+ }
+}
+
+func findMostRecentRun(msAPIDir string) (string, error) {
+ entries, err := os.ReadDir(msAPIDir)
+ if err != nil {
+ return "", err
+ }
+
+ var runs []string
+ for _, e := range entries {
+ if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
+ // Only include runs that actually contain files
+ if hasContent(filepath.Join(msAPIDir, e.Name())) {
+ runs = append(runs, e.Name())
+ }
+ }
+ }
+
+ if len(runs) == 0 {
+ return "", nil
+ }
+
+ // Lexicographic sort (run-ids are timestamp-prefixed)
+ sort.Strings(runs)
+ return runs[len(runs)-1], nil
+}
+
+// hasContent checks if a directory contains at least one non-directory entry,
+// recursively. Returns false for empty directories or directories containing
+// only empty subdirectories.
+func hasContent(dir string) bool {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return false
+ }
+ for _, e := range entries {
+ if !e.IsDir() {
+ return true
+ }
+ if hasContent(filepath.Join(dir, e.Name())) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
new file mode 100644
index 00000000..878615ee
--- /dev/null
+++ b/internal/cli/publish_test.go
@@ -0,0 +1,400 @@
+package cli
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/internal/pipeline"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPublishValidateMissingManifest(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"validate", "--dir", cliDir, "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ // Should fail with ExitPublishError
+ require.Error(t, err)
+
+ var result ValidateResult
+ require.NoError(t, json.Unmarshal([]byte(output), &result))
+ assert.False(t, result.Passed)
+
+ // Find the manifest check
+ var manifestCheck *CheckResult
+ for i := range result.Checks {
+ if result.Checks[i].Name == "manifest" {
+ manifestCheck = &result.Checks[i]
+ break
+ }
+ }
+ require.NotNil(t, manifestCheck)
+ assert.False(t, manifestCheck.Passed)
+ assert.Contains(t, manifestCheck.Error, "missing")
+}
+
+func TestPublishValidateManifestMissingFields(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ // Write a manifest missing required fields
+ writeTestManifest(t, cliDir, pipeline.CLIManifest{SchemaVersion: 1})
+
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"validate", "--dir", cliDir, "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.Error(t, err)
+
+ var result ValidateResult
+ require.NoError(t, json.Unmarshal([]byte(output), &result))
+ assert.False(t, result.Passed)
+
+ var manifestCheck *CheckResult
+ for i := range result.Checks {
+ if result.Checks[i].Name == "manifest" {
+ manifestCheck = &result.Checks[i]
+ break
+ }
+ }
+ require.NotNil(t, manifestCheck)
+ assert.False(t, manifestCheck.Passed)
+ assert.Contains(t, manifestCheck.Error, "required fields")
+}
+
+func TestPublishValidateMissingDirFlag(t *testing.T) {
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"validate", "--json"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--dir is required")
+}
+
+func TestPublishValidateManuscriptsWarnOnly(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ writeTestManifest(t, cliDir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "test",
+ CLIName: "test-pp-cli",
+ })
+
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"validate", "--dir", cliDir, "--json"})
+
+ output, _ := runWithCapturedStdout(t, cmd.Execute)
+
+ var result ValidateResult
+ require.NoError(t, json.Unmarshal([]byte(output), &result))
+
+ // Find the manuscripts check
+ var msCheck *CheckResult
+ for i := range result.Checks {
+ if result.Checks[i].Name == "manuscripts" {
+ msCheck = &result.Checks[i]
+ break
+ }
+ }
+ require.NotNil(t, msCheck, "manuscripts check should always be present")
+ // Manuscripts missing should be a warning, not a failure
+ assert.True(t, msCheck.Passed, "manuscripts check should pass (warn-only)")
+ assert.NotEmpty(t, msCheck.Warning, "should have a warning about missing manuscripts")
+}
+
+func TestPublishValidateJSONHasAllChecks(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ writeTestManifest(t, cliDir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "test",
+ CLIName: "test-pp-cli",
+ })
+
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"validate", "--dir", cliDir, "--json"})
+
+ output, _ := runWithCapturedStdout(t, cmd.Execute)
+
+ var result ValidateResult
+ require.NoError(t, json.Unmarshal([]byte(output), &result))
+
+ // Should have all 7 check names
+ checkNames := make(map[string]bool)
+ for _, c := range result.Checks {
+ checkNames[c.Name] = true
+ }
+
+ // All 7 checks should be present (they may fail in test env, but must exist)
+ expectedChecks := []string{"manifest", "go mod tidy", "go vet", "go build", "--help", "--version", "manuscripts"}
+ for _, name := range expectedChecks {
+ assert.True(t, checkNames[name], "should have %q check", name)
+ }
+ assert.Len(t, result.Checks, 7, "should have exactly 7 checks")
+}
+
+func TestPublishValidateExitCode(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+ // No manifest -> validation fails
+
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"validate", "--dir", cliDir, "--json"})
+
+ _, err := runWithCapturedStdout(t, cmd.Execute)
+ require.Error(t, err)
+
+ var exitErr *ExitError
+ require.ErrorAs(t, err, &exitErr)
+ assert.Equal(t, ExitPublishError, exitErr.Code, "should use ExitPublishError exit code")
+}
+
+func TestPublishPackageMissingDirFlag(t *testing.T) {
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--json"})
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--dir is required")
+}
+
+func TestPublishPackageMissingCategoryFlag(t *testing.T) {
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", "/tmp/fake", "--json"})
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--category is required")
+}
+
+func TestPublishPackageMissingTargetFlag(t *testing.T) {
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", "/tmp/fake", "--category", "ai", "--json"})
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--target is required")
+}
+
+func TestPublishPackageTargetExists(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ writeTestManifest(t, cliDir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "test",
+ CLIName: "test-pp-cli",
+ })
+
+ // Create target directory (already exists)
+ target := filepath.Join(home, "staging")
+ require.NoError(t, os.MkdirAll(target, 0o755))
+
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "developer-tools", "--target", target, "--json"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "already exists")
+}
+
+func TestPublishPackageCategoryPathTraversal(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(cliDir, 0o755))
+
+ writeTestManifest(t, cliDir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "test",
+ CLIName: "test-pp-cli",
+ })
+
+ tests := []struct {
+ name string
+ category string
+ wantErr string
+ }{
+ {"dotdot traversal", "../../../escape", "simple slug"},
+ {"forward slash", "foo/bar", "simple slug"},
+ {"backslash", "foo\\bar", "simple slug"},
+ {"dotdot only", "..", "simple slug"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ target := filepath.Join(t.TempDir(), "staging")
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", tt.category, "--target", target, "--json"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
+func TestPublishPackageRejectsUnknownCategory(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ writePublishableTestCLI(t, cliDir)
+
+ target := filepath.Join(t.TempDir(), "staging")
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "banana", "--target", target, "--json"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--category must be one of:")
+}
+
+func TestPublishPackageDoesNotStageCompiledBinary(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ writePublishableTestCLI(t, cliDir)
+
+ target := filepath.Join(t.TempDir(), "staging")
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--target", target, "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.NoError(t, err)
+
+ var result PackageResult
+ require.NoError(t, json.Unmarshal([]byte(output), &result))
+
+ _, sourceErr := os.Stat(filepath.Join(cliDir, "test-pp-cli"))
+ assert.ErrorIs(t, sourceErr, os.ErrNotExist, "validation should not leave a root binary behind")
+
+ _, stagedErr := os.Stat(filepath.Join(result.StagedDir, "test-pp-cli"))
+ assert.ErrorIs(t, stagedErr, os.ErrNotExist, "packaged source should not include a compiled binary")
+}
+
+func TestPublishPackageFailsWhenManuscriptsCopyFails(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ writePublishableTestCLI(t, cliDir)
+
+ runID := "20260328-132022"
+ manuscriptFile := filepath.Join(home, "manuscripts", "test", runID, "research", "brief.md")
+ require.NoError(t, os.MkdirAll(filepath.Dir(manuscriptFile), 0o755))
+ require.NoError(t, os.WriteFile(manuscriptFile, []byte("brief"), 0o600))
+ require.NoError(t, os.Chmod(manuscriptFile, 0))
+ defer func() {
+ _ = os.Chmod(manuscriptFile, 0o600)
+ }()
+
+ target := filepath.Join(t.TempDir(), "staging")
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--target", target, "--json"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "copying manuscripts")
+
+ _, statErr := os.Stat(target)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "failed packaging should clean up the staging target")
+}
+
+func TestFindMostRecentRun(t *testing.T) {
+ dir := t.TempDir()
+
+ // Create run directories with timestamp-prefixed names and content
+ for _, run := range []string{"20260327-100000", "20260328-132022", "20260326-090000"} {
+ researchDir := filepath.Join(dir, run, "research")
+ require.NoError(t, os.MkdirAll(researchDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(researchDir, "brief.md"), []byte("test"), 0o644))
+ }
+
+ runID, err := findMostRecentRun(dir)
+ require.NoError(t, err)
+ assert.Equal(t, "20260328-132022", runID, "should pick the most recent by lexicographic sort")
+}
+
+func TestFindMostRecentRunSkipsEmptyDirectories(t *testing.T) {
+ dir := t.TempDir()
+
+ // Most recent run is empty (interrupted archive)
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "20260329-100000"), 0o755))
+
+ // Older run has actual content
+ researchDir := filepath.Join(dir, "20260328-132022", "research")
+ require.NoError(t, os.MkdirAll(researchDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(researchDir, "brief.md"), []byte("test"), 0o644))
+
+ runID, err := findMostRecentRun(dir)
+ require.NoError(t, err)
+ assert.Equal(t, "20260328-132022", runID, "should skip empty run and use older one with content")
+}
+
+func TestFindMostRecentRunAllEmpty(t *testing.T) {
+ dir := t.TempDir()
+
+ // All runs are empty (no actual manuscript content)
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "20260328-132022"), 0o755))
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "20260327-100000"), 0o755))
+
+ runID, err := findMostRecentRun(dir)
+ require.NoError(t, err)
+ assert.Empty(t, runID, "should return empty when all runs are empty directories")
+}
+
+func TestFindMostRecentRunEmpty(t *testing.T) {
+ dir := t.TempDir()
+
+ runID, err := findMostRecentRun(dir)
+ require.NoError(t, err)
+ assert.Empty(t, runID)
+}
+
+func TestFindMostRecentRunNonexistentDir(t *testing.T) {
+ _, err := findMostRecentRun("/nonexistent/path")
+ assert.Error(t, err)
+}
+
+func writePublishableTestCLI(t *testing.T, dir string) {
+ t.Helper()
+
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "cmd", "test-pp-cli"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(`module example.com/test-pp-cli
+
+go 1.24
+`), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "cmd", "test-pp-cli", "main.go"), []byte(`package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ if len(os.Args) > 1 {
+ switch os.Args[1] {
+ case "--help":
+ fmt.Println("help")
+ return
+ case "--version":
+ fmt.Println("v0.0.0")
+ return
+ }
+ }
+ fmt.Println("ok")
+}
+`), 0o644))
+
+ writeTestManifest(t, dir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "test",
+ CLIName: "test-pp-cli",
+ })
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index aded16a1..6d051d01 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -47,6 +47,8 @@ func Execute() error {
rootCmd.AddCommand(newPrintCmd())
rootCmd.AddCommand(newSniffCmd())
rootCmd.AddCommand(newCatalogCmd())
+ rootCmd.AddCommand(newLibraryCmd())
+ rootCmd.AddCommand(newPublishCmd())
return rootCmd.Execute()
}
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index d54ff308..63b50ed4 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -31,6 +31,8 @@ type CLIManifest struct {
SpecChecksum string `json:"spec_checksum,omitempty"`
RunID string `json:"run_id,omitempty"`
CatalogEntry string `json:"catalog_entry,omitempty"`
+ Category string `json:"category,omitempty"`
+ Description string `json:"description,omitempty"`
}
// WriteCLIManifest marshals m as indented JSON and writes it to
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index 222df2b2..393a4f7f 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -29,6 +29,8 @@ func TestWriteCLIManifest(t *testing.T) {
SpecChecksum: "sha256:abc123",
RunID: "20260328T150405Z-abcd1234",
CatalogEntry: "notion",
+ Category: "productivity",
+ Description: "Notion workspace API",
}
err := WriteCLIManifest(dir, m)
@@ -50,6 +52,8 @@ func TestWriteCLIManifest(t *testing.T) {
assert.Equal(t, "sha256:abc123", got.SpecChecksum)
assert.Equal(t, "20260328T150405Z-abcd1234", got.RunID)
assert.Equal(t, "notion", got.CatalogEntry)
+ assert.Equal(t, "productivity", got.Category)
+ assert.Equal(t, "Notion workspace API", got.Description)
assert.Equal(t, m.GeneratedAt, got.GeneratedAt)
}
@@ -96,6 +100,12 @@ func TestWriteCLIManifestOmitsEmptyOptionalFields(t *testing.T) {
_, hasSpecPath := raw["spec_path"]
assert.False(t, hasSpecPath, "spec_path should be omitted when empty")
+
+ _, hasCategory := raw["category"]
+ assert.False(t, hasCategory, "category should be omitted when empty")
+
+ _, hasDescription := raw["description"]
+ assert.False(t, hasDescription, "description should be omitted when empty")
}
func TestWriteCLIManifestNonexistentDir(t *testing.T) {
diff --git a/internal/pipeline/contracts_test.go b/internal/pipeline/contracts_test.go
index a6b0c21e..30426629 100644
--- a/internal/pipeline/contracts_test.go
+++ b/internal/pipeline/contracts_test.go
@@ -51,6 +51,7 @@ func TestSkillSetupBlocksMatchWorkspaceContract(t *testing.T) {
{path: filepath.Join("..", "..", "skills", "printing-press", "SKILL.md"), expectsManuscripts: true},
{path: filepath.Join("..", "..", "skills", "printing-press-score", "SKILL.md"), expectsManuscripts: true},
{path: filepath.Join("..", "..", "skills", "printing-press-catalog", "SKILL.md"), expectsManuscripts: false},
+ {path: filepath.Join("..", "..", "skills", "printing-press-publish", "SKILL.md"), expectsManuscripts: true},
}
for _, tt := range tests {
@@ -108,6 +109,15 @@ func TestPrintingPressSkillExamplesUseCurrentCLINaming(t *testing.T) {
assert.NotContains(t, skill, "github.com/mvanhorn/discord-cli")
}
+func TestPublishSkillTracksCanonicalUpstreamAndOverwriteFlow(t *testing.T) {
+ skill := readContractFile(t, filepath.Join("..", "..", "skills", "printing-press-publish", "SKILL.md"))
+
+ assert.Contains(t, skill, "add `upstream` pointing at `mvanhorn/printing-press-library`")
+ assert.Contains(t, skill, "git fetch upstream 2>/dev/null || true")
+ assert.Contains(t, skill, "git reset --hard upstream/main")
+ assert.Contains(t, skill, "git push --force-with-lease -u origin feat/<cli-name>")
+}
+
func TestREADMEOutputContract(t *testing.T) {
readme := readContractFile(t, filepath.Join("..", "..", "README.md"))
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 2673d985..16861901 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -206,6 +206,8 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
// Look up catalog entry by API name; empty string if not found.
if entry, err := catalogpkg.LookupFS(catalog.FS, state.APIName); err == nil {
m.CatalogEntry = entry.Name
+ m.Category = entry.Category
+ m.Description = entry.Description
}
return WriteCLIManifest(dir, m)
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
new file mode 100644
index 00000000..bd8f5f9e
--- /dev/null
+++ b/skills/printing-press-publish/SKILL.md
@@ -0,0 +1,372 @@
+---
+name: printing-press-publish
+description: Publish a generated CLI to the printing-press-library repo
+version: 0.1.0
+min-binary-version: "0.5.0"
+allowed-tools:
+ - Bash
+ - Read
+ - Write
+ - Edit
+ - Glob
+ - Grep
+ - AskUserQuestion
+---
+
+# /printing-press publish
+
+Publish a generated CLI from your local library to the [printing-press-library](https://github.com/mvanhorn/printing-press-library) repo as a pull request.
+
+```bash
+/printing-press publish notion-pp-cli
+/printing-press publish notion
+/printing-press publish
+```
+
+## Setup
+
+Before doing anything else:
+
+<!-- PRESS_SETUP_CONTRACT_START -->
+```bash
+# min-binary-version: 0.5.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 "$_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' "$_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"
+PRESS_MANUSCRIPTS="$PRESS_HOME/manuscripts"
+PRESS_CURRENT="$PRESS_RUNSTATE/current"
+
+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.
+
+## Configuration
+
+```
+PUBLISH_REPO_URL="https://github.com/mvanhorn/printing-press-library"
+PUBLISH_REPO_DIR="$PRESS_HOME/.publish-repo"
+PUBLISH_CONFIG="$PRESS_HOME/.publish-config.json"
+```
+
+## Step 1: Prerequisites
+
+Verify `gh` is authenticated:
+
+```bash
+gh auth status
+```
+
+If this fails, stop and tell the user: "GitHub CLI is not authenticated. Run `gh auth login` first."
+
+## Step 2: Resolve CLI Name
+
+Run:
+
+```bash
+printing-press library list --json
+```
+
+Parse the JSON output into a list of CLIs.
+
+**Name resolution order** (matches the score skill for consistency):
+
+1. **Exact match:** If the argument matches a `cli_name` exactly, use it
+2. **Suffix match:** If no exact match, try `<argument>-pp-cli`
+3. **Glob match:** If no suffix match, search for entries where `cli_name` contains the argument as a substring. Cap at 5 most-recent matches. If multiple matches, present them via AskUserQuestion and let the user pick
+4. **No match:** List all available CLIs and ask the user to pick or re-enter
+5. **No argument:** If invoked with no name, list all CLIs sorted by modification time and let the user pick
+
+When presenting matches, show the CLI name and modification time in a human-friendly format (e.g., "2 hours ago", "3 days ago").
+
+## Step 3: Determine Category
+
+Read `.printing-press.json` from the resolved CLI directory.
+
+**Category resolution order:**
+
+1. If the manifest has a `category` field, present it for confirmation:
+ > "Publishing as **<category>**. OK?"
+ Give the user the option to change it
+
+2. If no `category` but `catalog_entry` is present, look it up:
+ ```bash
+ printing-press catalog show <catalog_entry> --json
+ ```
+ Extract the category from the result. Present for confirmation
+
+3. If neither provides a category, present the full list via AskUserQuestion:
+ - developer-tools, monitoring, cloud, project-management
+ - productivity, social-and-messaging, sales-and-crm, marketing
+ - payments, auth, commerce, ai, media-and-entertainment, devices, other
+
+## Step 4: Validate
+
+Run:
+
+```bash
+printing-press publish validate --dir <cli-dir> --json
+```
+
+Parse the JSON result. Display each check result to the user:
+
+```
+Validating <cli-name>...
+ manifest PASS
+ go mod tidy PASS
+ go vet PASS
+ go build PASS
+ --help PASS
+ --version PASS
+ manuscripts WARN (no manuscripts found)
+```
+
+If `"passed": false`, report the failing checks and **stop**. Do not create a partial PR.
+
+Save the `help_output` field from the result — it's used in the PR description.
+
+## Step 5: Package
+
+Create a temporary staging directory and run:
+
+```bash
+printing-press publish package \
+ --dir <cli-dir> \
+ --category <category> \
+ --target <staging-dir> \
+ --json
+```
+
+Parse the JSON result. Note the `staged_dir`, `manuscripts_included`, and `run_id`.
+
+## Step 6: Managed Clone
+
+The publish skill manages its own clone of the library repo at `$PUBLISH_REPO_DIR`.
+
+### First-time setup
+
+If `$PUBLISH_REPO_DIR` does not exist:
+
+1. Detect push access:
+ ```bash
+ gh api repos/mvanhorn/printing-press-library --jq '.permissions.push'
+ ```
+
+2. Detect git protocol:
+ ```bash
+ ssh -T git@github.com 2>&1 | grep -q "successfully authenticated"
+ ```
+ If SSH works, use SSH URLs. Otherwise use HTTPS.
+
+3. Clone based on access:
+ - **Push access:** Clone directly
+ - **No push access:** Fork first with `gh repo fork mvanhorn/printing-press-library --clone=false`, then clone the fork and add `upstream` pointing at `mvanhorn/printing-press-library`
+
+4. Cache the config:
+ ```json
+ {
+ "repo_url": "https://github.com/mvanhorn/printing-press-library",
+ "access": "push",
+ "protocol": "ssh",
+ "clone_path": "~/printing-press/.publish-repo"
+ }
+ ```
+ Write to `$PUBLISH_CONFIG`.
+
+### Subsequent publishes
+
+Read `$PUBLISH_CONFIG`, then freshen. Use `git reset --hard` instead of `git pull` because this is a managed clone, not a user working tree — it should always match the canonical upstream exactly:
+
+```bash
+cd "$PUBLISH_REPO_DIR"
+git fetch origin
+git fetch upstream 2>/dev/null || true
+git checkout main
+if git rev-parse --verify upstream/main >/dev/null 2>&1; then
+ git reset --hard upstream/main
+else
+ git reset --hard origin/main
+fi
+```
+
+Also verify the clone is healthy:
+
+```bash
+git rev-parse --is-inside-work-tree
+```
+
+If this fails, the clone is corrupt. Remove `$PUBLISH_REPO_DIR` and re-run first-time setup.
+
+### Interrupted state recovery
+
+Before creating a new branch, check for uncommitted changes:
+
+```bash
+cd "$PUBLISH_REPO_DIR"
+git status --porcelain
+```
+
+If there are uncommitted changes, ask the user via AskUserQuestion:
+- "Reset and start fresh"
+- "Continue with existing changes"
+
+If reset, run `git checkout -- . && git clean -fd`.
+
+## Step 7: Branch, Commit, and PR
+
+### Create branch
+
+Check for an existing branch:
+
+```bash
+git branch --list "feat/<cli-name>"
+git ls-remote --heads origin "feat/<cli-name>"
+```
+
+If exists, ask via AskUserQuestion:
+- "Overwrite existing branch"
+- "Create timestamped variant (feat/<cli-name>-YYYYMMDD)"
+
+Create the branch (use `-B` to force-create when overwriting an existing branch):
+
+```bash
+# New branch:
+git checkout -b feat/<cli-name>
+
+# Overwrite existing:
+git checkout -B feat/<cli-name>
+```
+
+### Copy staged package
+
+```bash
+cp -r <staging-dir>/library/* "$PUBLISH_REPO_DIR/library/"
+```
+
+### Update registry.json
+
+Read `$PUBLISH_REPO_DIR/registry.json`, add or update the entry for this CLI:
+
+```json
+{
+ "cli_name": "<cli-name>",
+ "api_name": "<api-name>",
+ "category": "<category>",
+ "description": "<from manifest or empty>",
+ "printing_press_version": "<from manifest>",
+ "published_date": "<today YYYY-MM-DD>"
+}
+```
+
+Write back with `jq` or via the Write tool.
+
+### Commit and push
+
+```bash
+cd "$PUBLISH_REPO_DIR"
+git add library/ registry.json
+git commit -m "feat(<api-name>): add <cli-name>"
+git push -u origin feat/<cli-name>
+```
+
+If you chose "Overwrite existing branch" earlier, replace the push command with:
+
+```bash
+git push --force-with-lease -u origin feat/<cli-name>
+```
+
+### Create PR
+
+Build the PR description from:
+- The manifest (`description`, `api_name`, `category`, `printing_press_version`, `spec_url`)
+- The `help_output` captured in Step 4
+- The CLI's README (first 2-3 paragraphs, or note that README is missing)
+- Links to `.manuscripts/<run-id>/research/` and `.manuscripts/<run-id>/proofs/` within the PR branch
+- The validation results from Step 4
+- A Gaps section listing any missing manifest fields
+
+**PR description template:**
+
+```markdown
+## <cli-name>
+
+<description from manifest, or "No description available">
+
+**API:** <api_name> | **Category:** <category> | **Press version:** <printing_press_version>
+**Spec:** <spec_url or "Not specified">
+
+### CLI Shape
+
+\`\`\`bash
+$ <cli-name> --help
+<help_output from validation>
+\`\`\`
+
+### What This CLI Does
+
+<First 2-3 paragraphs from README.md in the CLI directory, or "README not found">
+
+### Manuscripts
+
+- [Research Brief](<link to library/<category>/<cli-name>/.manuscripts/<run-id>/research/>)
+- [Shipcheck Results](<link to library/<category>/<cli-name>/.manuscripts/<run-id>/proofs/>)
+
+### Validation Results
+
+| Check | Result |
+|-------|--------|
+| Manifest | PASS/FAIL |
+| go mod tidy | PASS/FAIL |
+| go vet | PASS/FAIL |
+| go build | PASS/FAIL |
+| --help | PASS/FAIL |
+| --version | PASS/FAIL |
+| Manuscripts | PRESENT/MISSING |
+
+### Gaps
+
+<List any missing manifest fields, or omit this section if everything is present>
+```
+
+Create the PR:
+
+```bash
+gh pr create \
+ --repo mvanhorn/printing-press-library \
+ --title "feat(<api-name>): add <cli-name>" \
+ --body "<constructed PR body>"
+```
+
+Display the PR URL prominently.
+
+## Error Handling
+
+- **`gh` not authenticated:** Detect in Step 1, tell user to run `gh auth login`
+- **CLI not found:** Show available CLIs in Step 2, let user pick
+- **Validation fails:** Show per-check results in Step 4, stop
+- **Repo unreachable:** Report clearly in Step 6
+- **Branch conflict:** Ask user in Step 7 (overwrite or timestamp)
+- **Push fails:** Report the error, suggest checking `gh auth status`
+- **Staging cleanup:** If any step after packaging (Steps 6-7) fails, remove the staging directory created in Step 5 before stopping. This prevents accumulation of full CLI copies in temp directories across retries
← ccf7b2ee fix(skills): make sniff gate reliable with CLI-driven browsi
·
back to Cli Printing Press
·
chore(cli): remove stale planning docs (#56) 035d0974 →