← back to Cli Printing Press
fix(cli): emit ClawHub-compliant nested YAML for SKILL.md metadata (#609)
4897c593a34fd3cb6f155aad0aec3480e791b857 · 2026-05-05 00:53:17 -0700 · Trevin Chow
* fix(cli): emit ClawHub-compliant nested YAML for SKILL.md metadata
Convert the SKILL.md frontmatter `metadata:` field from a single-line
JSON-encoded string to nested YAML that conforms to ClawHub's
SkillInstallSpec schema (verified against packages/schema/src/schemas.ts
in openclaw/clawhub).
Two corrections, one shape:
- Shape: JSON-string blob is now nested YAML so parsers can introspect
metadata.openclaw.requires.bins as structured data.
- Schema: kind: shell -> kind: go (only brew|node|go|uv valid in the
ArkType enum); drop undocumented command/id/label fields; emit module:
with the Go package path (no @latest).
Adds frontmatter-shape tests that validate the rendered metadata against
the ClawHub install schema. Refreshes the generate-golden-api SKILL.md
golden to match the intentional output change.
Plan: docs/plans/2026-05-04-001-fix-skill-md-metadata-nested-yaml-plan.md
* feat(cli): add migrate-skill-metadata tool for SKILL.md backfill
One-time migration tool that walks library/*/*/SKILL.md and
cli-skills/pp-*/SKILL.md, finds legacy single-line JSON-string
`metadata:` entries, and rewrites them as ClawHub-compliant nested YAML
matching the new template emission.
Strategy is line-targeted text replacement, not yaml.v3 round-trip, so
descriptions and other frontmatter fields stay byte-identical. Only the
metadata: line is touched. Files with no metadata field at all (the
two instacart cases) get a synthesized block from sibling
.printing-press.json provenance using the actual library directory
name (slug-only directories are common; deriving the path from cli_name
alone would point at non-existent dirs).
Includes --strict (default on) to refuse legacy entries that don't
match the expected `kind: shell + go install <module>@latest` shape,
--dry-run for preview, atomic writes that preserve original file mode,
EvalSymlinks-based path-traversal protection, and a discovery-time
report (migrated/synthesized/skipped/errored counts).
Tests cover happy paths, idempotency, dry-run no-op, synthesis from
both library and cli-skills entry points, body-with-JSON-content
preservation, malformed-JSON error path, strict-mode rejection of
non-shell kinds and non-go-install commands, the --strict=false
bypass, and the symlink-escape branch of the path-traversal guard.
* fix(cli): emit slug-only library directory in install URLs (#610)
Template was emitting `library/<cat>/<name>-pp-cli/cmd/<name>-pp-cli`
in three places: the new metadata.openclaw.install[].module field, the
CLI Installation `go install` URL, and the MCP Server Installation
`go install` URL. Surveyed `printing-press-library/library/`: every one
of 44 published directories uses the slug-only convention
(`library/<cat>/<name>/cmd/<name>-pp-cli`), zero binary-suffix dirs.
README.md.tmpl and the regenmerge testdata go.mod fixtures already use
slug-only paths, so this aligns skill.md.tmpl with both the template
suite and the published library reality.
Updates the existing assertion at skill_test.go:57 (which encoded the
binary-suffix pattern), the new metadata-shape tests, and refreshes the
generate-golden-api SKILL.md golden — all three lines flip from
binary-suffix to slug-only with no other changes.
Closes #610. Identified by ce-adversarial-reviewer during PR #609 code
review and accepted as a Known Residual; this commit fixes it inline
since the diff is small and the same PR reviewers already validated
the rest of the template change.
Files touched
A docs/plans/2026-05-04-001-fix-skill-md-metadata-nested-yaml-plan.mdM internal/generator/skill_test.goM internal/generator/templates/skill.md.tmplM testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.mdA tools/migrate-skill-metadata/main.goA tools/migrate-skill-metadata/main_test.go
Diff
commit 4897c593a34fd3cb6f155aad0aec3480e791b857
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue May 5 00:53:17 2026 -0700
fix(cli): emit ClawHub-compliant nested YAML for SKILL.md metadata (#609)
* fix(cli): emit ClawHub-compliant nested YAML for SKILL.md metadata
Convert the SKILL.md frontmatter `metadata:` field from a single-line
JSON-encoded string to nested YAML that conforms to ClawHub's
SkillInstallSpec schema (verified against packages/schema/src/schemas.ts
in openclaw/clawhub).
Two corrections, one shape:
- Shape: JSON-string blob is now nested YAML so parsers can introspect
metadata.openclaw.requires.bins as structured data.
- Schema: kind: shell -> kind: go (only brew|node|go|uv valid in the
ArkType enum); drop undocumented command/id/label fields; emit module:
with the Go package path (no @latest).
Adds frontmatter-shape tests that validate the rendered metadata against
the ClawHub install schema. Refreshes the generate-golden-api SKILL.md
golden to match the intentional output change.
Plan: docs/plans/2026-05-04-001-fix-skill-md-metadata-nested-yaml-plan.md
* feat(cli): add migrate-skill-metadata tool for SKILL.md backfill
One-time migration tool that walks library/*/*/SKILL.md and
cli-skills/pp-*/SKILL.md, finds legacy single-line JSON-string
`metadata:` entries, and rewrites them as ClawHub-compliant nested YAML
matching the new template emission.
Strategy is line-targeted text replacement, not yaml.v3 round-trip, so
descriptions and other frontmatter fields stay byte-identical. Only the
metadata: line is touched. Files with no metadata field at all (the
two instacart cases) get a synthesized block from sibling
.printing-press.json provenance using the actual library directory
name (slug-only directories are common; deriving the path from cli_name
alone would point at non-existent dirs).
Includes --strict (default on) to refuse legacy entries that don't
match the expected `kind: shell + go install <module>@latest` shape,
--dry-run for preview, atomic writes that preserve original file mode,
EvalSymlinks-based path-traversal protection, and a discovery-time
report (migrated/synthesized/skipped/errored counts).
Tests cover happy paths, idempotency, dry-run no-op, synthesis from
both library and cli-skills entry points, body-with-JSON-content
preservation, malformed-JSON error path, strict-mode rejection of
non-shell kinds and non-go-install commands, the --strict=false
bypass, and the symlink-escape branch of the path-traversal guard.
* fix(cli): emit slug-only library directory in install URLs (#610)
Template was emitting `library/<cat>/<name>-pp-cli/cmd/<name>-pp-cli`
in three places: the new metadata.openclaw.install[].module field, the
CLI Installation `go install` URL, and the MCP Server Installation
`go install` URL. Surveyed `printing-press-library/library/`: every one
of 44 published directories uses the slug-only convention
(`library/<cat>/<name>/cmd/<name>-pp-cli`), zero binary-suffix dirs.
README.md.tmpl and the regenmerge testdata go.mod fixtures already use
slug-only paths, so this aligns skill.md.tmpl with both the template
suite and the published library reality.
Updates the existing assertion at skill_test.go:57 (which encoded the
binary-suffix pattern), the new metadata-shape tests, and refreshes the
generate-golden-api SKILL.md golden — all three lines flip from
binary-suffix to slug-only with no other changes.
Closes #610. Identified by ce-adversarial-reviewer during PR #609 code
review and accepted as a Known Residual; this commit fixes it inline
since the diff is small and the same PR reviewers already validated
the rest of the template change.
---
...4-001-fix-skill-md-metadata-nested-yaml-plan.md | 380 ++++++++++++++
internal/generator/skill_test.go | 95 +++-
internal/generator/templates/skill.md.tmpl | 14 +-
.../printing-press-golden/SKILL.md | 14 +-
tools/migrate-skill-metadata/main.go | 523 ++++++++++++++++++++
tools/migrate-skill-metadata/main_test.go | 548 +++++++++++++++++++++
6 files changed, 1566 insertions(+), 8 deletions(-)
diff --git a/docs/plans/2026-05-04-001-fix-skill-md-metadata-nested-yaml-plan.md b/docs/plans/2026-05-04-001-fix-skill-md-metadata-nested-yaml-plan.md
new file mode 100644
index 00000000..402b9641
--- /dev/null
+++ b/docs/plans/2026-05-04-001-fix-skill-md-metadata-nested-yaml-plan.md
@@ -0,0 +1,380 @@
+---
+title: Convert SKILL.md metadata to ClawHub-compliant nested YAML
+type: fix
+status: active
+date: 2026-05-04
+---
+
+# Convert SKILL.md metadata to ClawHub-compliant nested YAML
+
+## Summary
+
+Convert the `metadata:` frontmatter field in every printed-CLI `SKILL.md` from a single-line JSON-encoded string into nested YAML that conforms to the ClawHub `SkillInstallSpec` schema. The fix is two-fold: shape (JSON-string → nested YAML) AND content (`kind: shell` + `command:` → `kind: go` + `module:`, drop unused `id`/`label`). Touches the generator template in this repo, the mirror generator in `printing-press-library`, a one-time backfill across 89 existing SKILL.md files (including 2 that lack `metadata:` entirely), and 2 golden fixtures.
+
+---
+
+## Problem Frame
+
+Every printed CLI's `SKILL.md` frontmatter today emits `metadata:` as a JSON-encoded string:
+
+```yaml
+metadata: '{"openclaw":{"requires":{"bins":["dub-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/.../dub-pp-cli@latest","bins":["dub-pp-cli"],"label":"Install via go install"}]}}'
+```
+
+Two problems:
+
+1. **Wrong shape.** YAML parsers see the value as a single string scalar; consumers cannot introspect `metadata.openclaw.requires.bins` as structured data without a second JSON parse pass.
+2. **Wrong content per ClawHub's strict schema.** The canonical `SkillInstallSpec` (verified directly against `packages/schema/src/schemas.ts` in the openclaw/clawhub repo) accepts `kind ∈ {brew, node, go, uv}` only — `kind: shell` is rejected. The schema also has no `command:` field; for `kind: go` the install path goes in `module:`. ArkType validates strictly, so unknown fields are rejected, not silently ignored.
+
+The shape was wrong on its own merits regardless of any specific consumer. The schema-content correction is a follow-on consequence of "fix it to actually be valid." ClawHub publishing is a near-term motivating consumer but is intentionally out-of-scope here; correcting the metadata to be schema-valid de-risks any future publishing plan.
+
+---
+
+## Requirements
+
+- R1. Every printed CLI's `SKILL.md` frontmatter `metadata:` field is nested YAML, not a JSON-encoded string.
+- R2. The semantic content of `metadata.openclaw` survives migration: `requires.bins`, `requires.env` and `primaryEnv` (for api_key auth flavor), and at least one `install[]` entry naming the same binary as today.
+- R3. After backfill, every `library/*/*/SKILL.md` and `cli-skills/pp-*/SKILL.md` file in the public library has a valid `metadata.openclaw` block — including the two `instacart` files that have no `metadata:` field today.
+- R4. Future CLIs generated by the printing-press emit the new shape natively (template-driven).
+- R5. The mirror generator (`tools/generate-skills/main.go`) emits the new shape for synthesized-fallback paths and stays byte-stable when re-running over migrated upstream content.
+- R6. Existing automated checks pass post-migration: `go test ./...`, `golangci-lint run ./...`, `scripts/golden.sh verify` (after intentional fixture update), `verify-skills.yml`, `verify-manifests.yml`.
+- R7. Emitted `metadata.openclaw.install[]` entries comply with ClawHub's `SkillInstallSpec` schema: `kind ∈ {brew, node, go, uv}`, no undocumented fields. For Go installs, use `kind: go` with `module:` carrying the Go package path.
+
+---
+
+## Scope Boundaries
+
+- Not adding a `version:` field to frontmatter — defer until a consumer demonstrably requires it. (ClawHub spec is ambiguous; no current consumer fails without it.)
+- Not adding a `homepage:` field — defer.
+- Not adding the ClawHub publish workflow, no trust-gate / CODEOWNERS / environment-approval design — separate plan.
+- Not unifying the existing per-CLI version drift across `--version` ldflag, `manifest.json` `version`, and `printing_press_version` — separate concern.
+- Not migrating `library/*/*/manifest.json` or `tools-manifest.json` — different files, different consumers.
+- Not regenerating any printed CLI from its source spec — the migration is a surgical frontmatter rewrite, not a full re-emission.
+- Not introducing multi-platform install entries (brew/uv/node alongside go). All current CLIs are Go binaries; the migration emits a single `kind: go` install entry per CLI. Multi-platform support is a future template extension.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- **Generator template (this repo):** `internal/generator/templates/skill.md.tmpl` line 6 emits the JSON-string `metadata:` today.
+- **Render path (this repo):** `internal/generator/generator.go:1202` dispatches the template; data builder `readmeData()` at lines 669-694; data type `*readmeTemplateData` at lines 624-649 embeds `*spec.APISpec` and exposes `.Name`, `.Category`, `.Auth`, etc. that the template currently uses to construct the install URL.
+- **Funcmap (this repo):** `internal/generator/generator.go:173-410`. `yamlDoubleQuoted` at lines 402-409 is the only YAML-aware helper. No nested-YAML emitter exists; the new template hand-writes the block.
+- **Mirror generator (public library):** `tools/generate-skills/main.go` `buildOpenClawMetadata` at lines 467-524 in `mvanhorn/printing-press-library` returns single-line escaped JSON for `metadata:`. The mirror's three-tier precedence (upstream-copy / enriched-synthesis / registry-only-synthesis) at lines 181, 248-272 governs which path produces each `cli-skills/pp-*/SKILL.md`. `injectStaleBuildFallback` at lines 283-308 has a stale comment claiming "metadata is always on line 6" that becomes wrong post-migration.
+- **Mirror template (public library):** `tools/generate-skills/skill-template.md` lines 1-9 currently expects single-line metadata.
+- **Verifier (public library):** `.github/scripts/verify-skill/verify_skill.py` does **not** parse YAML — regex-based flag and recipe extraction only. Confirmed shape-tolerant by independent grep.
+- **Provenance source for `bins`:** `pipeline.CLIManifest` at `internal/pipeline/climanifest.go:29-76` carries `cli_name` (the authoritative binary name). Older CLIs like `agent-capture` use bare names; newer use `<slug>-pp-cli`. Migration must read `cli_name` rather than deriving from slug.
+- **ClawHub install schema (authoritative):** `packages/schema/src/schemas.ts` line 364-373 in `openclaw/clawhub`:
+ ```typescript
+ export const SkillInstallSpecSchema = type({
+ id: "string?",
+ kind: '"brew"|"node"|"go"|"uv"',
+ label: "string?",
+ bins: "string[]?",
+ formula: "string?", // brew
+ tap: "string?", // brew
+ package: "string?", // node, uv
+ module: "string?", // go
+ });
+ ```
+ ArkType validates strictly. Unknown fields are rejected; `id` and `label` are optional and free-form. Docs example omits both. We omit them too.
+
+### Pre-Migration Survey
+
+Direct scan of `~/Code/printing-press-library/` confirmed:
+- **89 SKILL.md files total** — 44 under `library/*/*/` and 45 under `cli-skills/pp-*/`.
+- **43 of 44** library files use the identical pattern: `kind: shell + id: go + command: "go install github.com/mvanhorn/..."`.
+- **0 variance** in install pattern across all CLIs that have a `metadata:` field. Single mechanical mapping covers them.
+- **2 oddballs:** `library/commerce/instacart/SKILL.md` and `cli-skills/pp-instacart/SKILL.md` have **no `metadata:` field at all** (hand-authored frontmatter that omitted it). Migration script must synthesize one for these from `.printing-press.json` `cli_name`.
+
+The 89 count and instacart situation can change if new CLIs land before migration runs. The migration script reports its discovered counts at runtime; verification gates in U5 use those reported numbers, not hardcoded ones.
+
+### Institutional Learnings
+
+- `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md` — published library CLIs are immutable outputs; in-place mutation conflicts with regen-into-runstate-then-republish principle. Mitigated here: the migration is a pure frontmatter rewrite (preserves all other content, no re-emission), closer to "documentation update" than "mutate the output." The principle still suggests treating the migration as one-time, idempotent, and verified.
+- `docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md` — migration helpers should follow snapshot-compare-restore for verification steps that create temporary artifacts.
+- `docs/solutions/security-issues/filepath-join-traversal-with-user-input-2026-03-29.md` — when accepting a directory argument, validate the resolved absolute path stays under the supplied root.
+- No prior precedent for SKILL.md format migrations or per-CLI artifact backfills in `docs/solutions/`. Retro candidate after this lands.
+
+### External References
+
+- ClawHub skill format spec (markdown): `https://github.com/openclaw/clawhub/blob/main/docs/skill-format.md` — high-level documentation.
+- ClawHub schema source (authoritative): `https://github.com/openclaw/clawhub/blob/main/packages/schema/src/schemas.ts` — strict ArkType definitions, source of truth for what gets accepted.
+
+---
+
+## Key Technical Decisions
+
+- **Migration strategy is line-targeted text replacement, NOT yaml.v3 round-trip.** A full frontmatter round-trip with `gopkg.in/yaml.v3` reorders top-level keys alphabetically and rewrites quoting on `description:`/`argument-hint:`/`allowed-tools:` (verified by experiment). The migration script regex-matches the single `metadata: '...'` line, parses the embedded JSON, generates a multi-line YAML replacement block at canonical indentation, and replaces just that line. All other frontmatter content stays byte-identical. This sidesteps quoting and ordering drift entirely. For the two instacart files that have no `metadata:` line, the script appends a synthesized block in the right slot of the frontmatter.
+- **`metadata.openclaw.install[]` shape: minimum-required fields per ClawHub schema.** Drop `id` and `label` (optional, free-form, no semantic value for our single-install-option skills; ClawHub docs example omits both). Map `kind: shell` → `kind: go`. Drop `command:` (not in schema). Add `module:` (the Go package path, derived from the existing `command:` value by stripping `go install ` prefix and `@latest` suffix). Keep `bins`. Result per CLI:
+ ```yaml
+ metadata:
+ openclaw:
+ requires:
+ bins:
+ - dub-pp-cli
+ install:
+ - kind: go
+ bins: [dub-pp-cli]
+ module: github.com/mvanhorn/printing-press-library/library/other/dub/cmd/dub-pp-cli
+ ```
+ Auth-flavored CLIs additionally carry `requires.env` and `primaryEnv` exactly as today.
+- **Generator template (U1) and mirror generator (U3) emit IDENTICAL bytes for identical semantic content.** Both produce the same canonical YAML block — same indentation (2 spaces), same key ordering (`kind`, `bins`, `module`), same scalar style. U5's verification re-runs the mirror generator over migrated upstream files and asserts no diff. Without this, `generate-skills.yml` would re-fire post-merge and create a follow-up commit overwriting the migration with the generator's emission.
+- **Two-PR sequencing across repos.** PR 1 (this repo): U1 + U2 + U4. PR 2 (public library): U3 + U5, opened only after PR 1 merges. U5 explicitly invokes the migration script committed in PR 1 via an absolute path during local execution; the script binary doesn't need to live in the public library repo.
+- **Backfill via standalone Go program**, not a printing-press subcommand. The script runs once, then sits. A subcommand is more discoverable; standalone keeps the printing-press surface clean. Revisit if reused.
+- **Accept golden fixture diffs as intentional.** Two SKILL.md goldens (`testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md` and `testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/SKILL.md`) get refreshed via `scripts/golden.sh update`. Diffs are expected to be the metadata block shape only.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- Is `version:` required by ClawHub? — Spec ambiguous; not relevant to this plan, deferred entirely.
+- What's `id` for in `SkillInstallSpec`? — Optional, free-form, unused by the schema. Dropped from emission.
+- Does `verify_skill.py` parse YAML? — No, regex-only. Shape-tolerant.
+- Should the migration use yaml.v3 round-trip? — No. Line-targeted regex replacement only, to preserve all non-metadata frontmatter byte-equal.
+- How to handle instacart's missing `metadata:`? — Synthesize from `.printing-press.json` `cli_name` using the same template every other CLI uses.
+- Where does the migration script live? — `tools/migrate-skill-metadata/` in this (machine) repo. Path-argument-driven.
+
+### Deferred to Implementation
+
+- Exact regex for matching the JSON-string `metadata:` line — write iteratively against fixtures; needs to handle the apostrophe-quoted JSON value robustly.
+- Whether to preserve any existing comments in frontmatter — research did not find any; if any are discovered during dry-run, halt and decide before applying.
+
+---
+
+## Implementation Units
+
+- U1. **Update generator template to emit ClawHub-compliant nested YAML**
+
+**Goal:** Change `internal/generator/templates/skill.md.tmpl` so the `metadata:` block emits as nested YAML with `kind: go` + `module:` (no `id`, `label`, `command`, `kind: shell`). Future generations natively produce schema-compliant frontmatter.
+
+**Requirements:** R1, R4, R7
+
+**Dependencies:** None.
+
+**Files:**
+- Modify: `internal/generator/templates/skill.md.tmpl`
+- Test: `internal/generator/skill_test.go`
+
+**Approach:**
+- Replace the line-6 single-line `metadata: '{...}'` block with a multi-line nested YAML block.
+- Compute the Go module path inline from `.Name` and `.Category` (same fields the install URL is built from today). The module path is the install URL minus the `@latest` suffix — i.e., `github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}-pp-cli/cmd/{{.Name}}-pp-cli`.
+- Emit `kind: go` literally. Drop `id`, `label`, `command`.
+- Preserve conditional emission of `requires.env` and `primaryEnv` for `auth_type=api_key`.
+- Use canonical indentation (2 spaces per level) and key order (`kind` → `bins` → `module`) matching what U3 will emit.
+
+**Patterns to follow:**
+- Existing template style at `skill.md.tmpl` (Go `text/template`, manual indentation).
+- `yamlDoubleQuoted` helper at `generator.go:402-409` for any interpolated string that may contain YAML control characters (none of the current values do, but conservative).
+
+**Test scenarios:**
+- Happy path (api_key auth): render with a fixture spec carrying `cli_name=foo-pp-cli` and api_key auth; assert frontmatter parses with `gopkg.in/yaml.v3` such that `metadata.openclaw.install[0].kind == "go"`, `metadata.openclaw.install[0].module == "github.com/.../foo-pp-cli/cmd/foo-pp-cli"`, `metadata.openclaw.install[0].bins == ["foo-pp-cli"]`, `metadata.openclaw.requires.env == ["FOO_TOKEN"]`, `metadata.openclaw.primaryEnv == "FOO_TOKEN"`.
+- Edge case (no auth): assert `requires.env` and `primaryEnv` are absent (not empty strings).
+- Edge case (`agent-capture` shape): render with `cli_name="agent-capture"` (no `-pp-cli` suffix); assert `bins[0] == "agent-capture"` and `module` ends with `/cmd/agent-capture`.
+- Edge case (empty `Category`): assert install path defaults to `other`.
+- Negative: assert no `command:`, `id:`, or `kind: shell` appears anywhere in the output.
+
+**Verification:**
+- `go test ./internal/generator/...` passes.
+- The rendered SKILL.md, parsed by yaml.v3 and validated against the ClawHub `SkillInstallSpec` shape (in test code), conforms.
+
+---
+
+- U2. **Refresh SKILL.md golden fixtures**
+
+**Goal:** Update the two SKILL.md golden fixtures to reflect U1's intentional shape and content change.
+
+**Requirements:** R6
+
+**Dependencies:** U1
+
+**Files:**
+- Modify: `testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md`
+- Modify: `testdata/golden/expected/dogfood-novel-doc-sync/fixture/cli/SKILL.md`
+
+**Approach:**
+- After U1 lands, run `scripts/golden.sh update`.
+- Inspect both diffs side-by-side; confirm only the `metadata:` block changed (semantic content equivalent: same bins, same auth env, equivalent install — `kind: go` + `module:` replaces `kind: shell` + `command:`).
+- Document the diffs in the PR description per AGENTS.md golden-update protocol.
+
+**Test scenarios:**
+- Test expectation: none — fixture-update unit. Correctness covered by U1's tests + manual diff review.
+
+**Verification:**
+- `scripts/golden.sh verify` passes.
+- Manual diff review confirms only the metadata block region changed.
+
+---
+
+- U3. **Update mirror generator and stale comment in `printing-press-library`**
+
+**Target repo:** `mvanhorn/printing-press-library` (separate PR; lands alongside U5).
+
+**Goal:** Change the mirror generator's synthesized-fallback path to emit nested YAML with the same canonical bytes U1 emits, and update the stale `injectStaleBuildFallback` comment.
+
+**Requirements:** R5, R6, R7
+
+**Dependencies:** U1 (so the verbatim-copy path produces the new shape; mirror generator must produce the same shape for synthesis path).
+
+**Files:**
+- Modify: `tools/generate-skills/main.go` — function `buildOpenClawMetadata` at lines 467-524, plus the explanatory comment in `injectStaleBuildFallback` at lines 283-308 (currently says "metadata is always on line 6 of the frontmatter").
+- Modify: `tools/generate-skills/skill-template.md` if it inlines the metadata field shape directly.
+- Test: `tools/generate-skills/main_test.go`
+
+**Approach:**
+- Replace the JSON-string return path in `buildOpenClawMetadata` with a pre-rendered indented YAML string at canonical indentation. Use the same key order and indent as U1's template.
+- Update `skill-template.md` to expect the new shape if it inlines `metadata:`.
+- Update the `injectStaleBuildFallback` comment to reflect that `metadata:` is now a multi-line nested YAML block. Verify the regex still functions (it anchors on `^(\s*)go install` which won't match ` module: github.com/...` — no `go install` substring on the metadata side post-migration).
+- Confirm `buildOpenClawMetadata` produces byte-identical output to U1's template for equivalent semantic input. This is the critical idempotency invariant for U5.
+
+**Patterns to follow:**
+- Existing test style in `main_test.go` (read fixture inputs, compare expected vs actual output strings).
+
+**Test scenarios:**
+- Happy path: synthesize a fallback skill for a registry-only entry; assert frontmatter parses with `metadata.openclaw.install[0].kind == "go"`, `module:` matching the expected go-install URL minus `@latest`, `bins:` matching the cli_name.
+- Edge case (`agent-capture` shape): synthesize for a CLI whose `cli_name` is a bare name; assert `bins[0] == "agent-capture"` and `module` ends with `/cmd/agent-capture`.
+- Idempotency: emit metadata for a CLI; render U1's template for the same CLI's spec; assert byte-equal output.
+- Integration: post-migration, copy from `library/*/*/SKILL.md` to `cli-skills/pp-*/SKILL.md`; assert byte-equality (modulo `injectStaleBuildFallback` install lines below the metadata block).
+
+**Verification:**
+- `go test ./tools/generate-skills/...` passes.
+- After U5's migration: re-running `go run ./tools/generate-skills/main.go` produces no diff against the migrated `cli-skills/`.
+
+---
+
+- U4. **One-time migration script: line-targeted text replacement plus instacart synthesis**
+
+**Goal:** Build a Go program that walks a target tree and rewrites every existing SKILL.md's `metadata:` frontmatter from JSON-string to ClawHub-compliant nested YAML. Performs schema correction (kind+module mapping) and synthesizes metadata for files that have none.
+
+**Requirements:** (enables U5; final-state requirements R1, R2, R3, R7 land when U5 executes this script)
+
+**Dependencies:** None at planning time (can be authored in parallel with U1).
+
+**Files:**
+- Create: `tools/migrate-skill-metadata/main.go`
+- Create: `tools/migrate-skill-metadata/main_test.go`
+- Create: `tools/migrate-skill-metadata/testdata/` (input/output fixtures covering edge cases)
+
+**Approach:**
+- Accept a path argument (root of public library repo). Discover targets via glob: `library/*/*/SKILL.md` and `cli-skills/pp-*/SKILL.md`.
+- For each file, read into memory. Identify the frontmatter region (between the first two `---` markers).
+- **Line-targeted replacement (no yaml.v3 round-trip of the full frontmatter):**
+ - Look for a line matching the regex `^metadata: '(.+)'$` inside the frontmatter region.
+ - If matched: parse the captured group as JSON. Apply schema corrections to each `install[]` entry — set `kind: "go"`, drop `id`/`label`/`command`, derive `module` by stripping `go install ` prefix and `@latest` suffix from the original `command` value. Generate the replacement multi-line YAML block at canonical indentation matching U1/U3 byte-shape. Replace the single matched line with the multi-line block. All other frontmatter and body content stays byte-identical.
+ - If no `metadata:` line exists (instacart case): read sibling `.printing-press.json` to get `cli_name`, derive the module path from `cli_name` and the file's `library/<category>/<slug>/` path (or `cli-skills/pp-<slug>/` for mirrors — both can resolve to the same module URL). Synthesize the same canonical YAML block. Insert it into the frontmatter at a deterministic location (after `allowed-tools:` line, or before the closing `---`).
+- Idempotent: if the line already matches the post-migration shape (no JSON-string match, no missing-metadata case), skip without writing.
+- Atomic write: write to `<path>.tmp` then rename, mirroring `internal/pipeline/climanifest.go`.
+- `--dry-run` flag: print what would change without writing.
+- `--strict` flag (default on): refuse to migrate any file whose JSON parses but doesn't match the expected `kind: shell + command: "go install github.com/.../@latest"` shape, forcing human review of oddballs. Loosen with `--strict=false`.
+- Reports counts to stdout: migrated (JSON-string conversion), synthesized (instacart-style), skipped (already nested), errored (with paths).
+- Path-traversal precaution: validate every discovered file's resolved absolute path stays under the path argument's resolved root.
+
+**Patterns to follow:**
+- `internal/pipeline/climanifest.go` for atomic file writes (tmp + rename).
+- `encoding/json` for parsing the embedded JSON string.
+- Standard Go `flag` package for CLI flags.
+- `regexp` for matching the `metadata:` line; deliberately avoid yaml.v3 for the rewrite path.
+
+**Test scenarios:**
+- Happy path (api_key auth): input file with JSON-string `metadata:` containing `requires.env`, `primaryEnv`, and one `kind: shell` install entry → output file has nested YAML with `kind: go`, `module:`, no `id`/`label`/`command`/`kind: shell`; semantic content equivalent; non-frontmatter content byte-identical.
+- Happy path (no auth): input with only `requires.bins` and one install entry → output has same shape, no `requires.env` or `primaryEnv`.
+- Happy path (`agent-capture` shape): input with bare-name `bins: ["agent-capture"]` and `command: "go install .../agent-capture@latest"` → output has `bins: [agent-capture]` and correctly-derived `module:`.
+- Synthesis path (instacart): input file with no `metadata:` line, sibling `.printing-press.json` with `cli_name: instacart-pp-cli` → output has full nested YAML metadata block at the deterministic insertion point; non-frontmatter content byte-identical.
+- Idempotency: input file already in post-migration shape → no write occurs; reports as skipped.
+- Preservation: input with multi-line description, blank lines in frontmatter, mixed-quote interior, descriptions containing backticks/apostrophes/ampersands → output preserves these byte-exactly except for the metadata region.
+- Boundary (negative space): a file whose body contains JSON-shaped strings (e.g., a recipe code block with `{...}`) → only the frontmatter `metadata:` line is touched; body untouched.
+- Error path (malformed JSON): input where `metadata:` value is not valid JSON → reports error with file path; in apply mode exits non-zero; in `--dry-run` continues to next file.
+- Error path (`--strict` mismatch): input with valid JSON but unexpected install shape (e.g., `kind: brew`) → in `--strict` mode, refuse and report; in `--strict=false`, skip without writing and report as needs-human-review.
+- Error path (missing `.printing-press.json` for synthesis): instacart-style file but no provenance → report error, do not synthesize.
+- Boundary (`--dry-run`): no writes; prints diff summary.
+- Boundary (path traversal): if a discovered file's resolved path is outside the supplied root, refuse and report.
+
+**Verification:**
+- `go test ./tools/migrate-skill-metadata/...` passes.
+- Running on a fixture tree → re-running on the same tree is a no-op.
+- Spot-check 5 random migrated files: parse with `gopkg.in/yaml.v3`, assert the `metadata.openclaw.install[]` entry validates against the ClawHub `SkillInstallSpec` shape.
+
+---
+
+- U5. **Execute migration in the public library and align mirror**
+
+**Target repo:** `mvanhorn/printing-press-library` (same PR as U3).
+
+**Goal:** Run U4's script against the live public library tree and commit the results in a single PR alongside U3's mirror generator update. Verify mirror-generator idempotency post-migration.
+
+**Requirements:** R1, R2, R3, R6
+
+**Dependencies:** U3, U4. PR 1 (machine repo with U4) must merge before this PR opens — the script binary is invoked from the machine-repo path locally during U5's execution.
+
+**Files:**
+- Modify (script-driven): `library/*/*/SKILL.md` (~44 files at planning time; check actual count at run time) in `mvanhorn/printing-press-library`
+- Modify (script-driven): `cli-skills/pp-*/SKILL.md` (~45 files at planning time)
+- Modify (script-driven, synthesis path): `library/commerce/instacart/SKILL.md`, `cli-skills/pp-instacart/SKILL.md`
+
+**Approach:**
+- Run `go run /path/to/cli-printing-press/tools/migrate-skill-metadata/main.go --dry-run /path/to/printing-press-library`. Review the printed summary: `migrated: N, synthesized: 2, skipped: 0, errored: 0`. Sanity-check N matches the discovered file count minus the synthesized count.
+- Run without `--dry-run` to apply.
+- Validate every modified file with `gopkg.in/yaml.v3` parse + ClawHub-shape assertion (script can include a `--validate` flag that re-reads its own output).
+- Run `go run ./tools/generate-skills/main.go` (the mirror generator from U3) on the post-migration tree. **Assert no diff is produced.** This is the byte-stability check — if the mirror generator produces different bytes than the migration script for the same CLI, U3 and U4 disagree and the merged PR will create a `[skip ci]` regen commit overwriting the migration. The PR cannot land until this assertion passes.
+- Run public library `verify-skills.yml` and `verify-manifests.yml` workflows locally if practical; confirm pass.
+- Commit in two steps in the PR: (1) the migration commit (script-generated), (2) any subsequent re-run of `tools/generate-skills/main.go` if it produces a diff (it should not — if it does, U3 needs more work before the PR can land).
+
+**Test scenarios:**
+- Test expectation: none for this unit — execution + validation of U3 and U4. Correctness covered by U4's tests and the post-execution verification below.
+
+**Verification:**
+- All discovered SKILL.md files have valid `metadata.openclaw` post-migration; `gopkg.in/yaml.v3` parse succeeds on every file; ClawHub `SkillInstallSpec` shape validation succeeds on every install entry.
+- 5 random spot-checks across categories: `metadata.openclaw.install[0].module` matches the expected Go package path; `kind == "go"`; no `command` / `id` / `label` fields present.
+- Two instacart files have a valid synthesized `metadata.openclaw` block.
+- Mirror generator post-migration is byte-stable (no diff on re-run).
+- Public library `verify-skills.yml` passes against the migration commit.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph:** No runtime callers in printed CLIs read SKILL.md frontmatter. Downstream consumers (ClawHub, agent hosts, MCP-aware plugin hosts) gain ability to introspect; nothing breaks.
+- **Error propagation:** Migration script must fail loudly on malformed input rather than silently corrupt files. `--dry-run` is the safety net. `--strict` mode catches structurally-unexpected inputs that the JSON-parse alone won't reject.
+- **State lifecycle risks:** Migration is a single pass with atomic per-file writes; no async, no caches. Ctrl-C mid-walk leaves the tree in a mixed state — re-running the script (idempotent) cleans up.
+- **API surface parity:** The two parallel files per CLI (`library/*/*/SKILL.md` and `cli-skills/pp-*/SKILL.md`) must end up consistent. U3 keeps them aligned post-migration; U5's idempotency check enforces it.
+- **Integration coverage:** Full chain (template → generator → published → mirrored) needs an end-to-end check post-migration. Generate a fresh test CLI through the machine, copy to library, run mirror, assert all three SKILL.md files have schema-valid nested YAML.
+- **Unchanged invariants:** `name`, `description`, `argument-hint`, `allowed-tools` frontmatter fields unchanged. Body content unchanged. `manifest.json`, `tools-manifest.json`, `.printing-press.json` unchanged. `verify_skill.py` behavior unchanged (regex-only, shape-tolerant).
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Migration corrupts a SKILL.md (loses content, mangles description) | `--dry-run` first; unit-test fixtures with edge-case content; spot-check 5 random files post-migration; line-targeted replacement (no yaml.v3 round-trip) sidesteps the major corruption vectors. |
+| U1 and U3 emit different bytes for identical semantic content | Idempotency check baked into U5 verification: re-run mirror generator, assert no diff. Cross-test in U3 that emits identical to U1 for matched fixtures. |
+| New CLIs land between U1's release and U5's run with old shape | Migration is idempotent and re-runnable. If a straggler appears, run the script again. |
+| Verifier (`verify_skill.py`) breaks unexpectedly on new shape | Pre-validated by independent grep — no YAML parsing in the verifier. Run locally before opening the public library PR. |
+| Golden fixture update obscures real regressions | Inspect both diffs manually; document in the PR per AGENTS.md golden-update protocol. |
+| File counts grow between planning and execution | Plan uses 44 + 45 = 89 as the snapshot; the script reports actual discovered counts at run time. Verification gates use those numbers, not hardcoded ones. |
+| New CLI added with `kind: shell` shape after U1 ships | Only happens if a regression sneaks the old emission back into the template. U1's negative tests assert `kind: shell` never appears in rendered output; U2 goldens enforce. |
+| `instacart` synthesis derives wrong module path | Pre-migration manual check: the script reports the synthesized module path for instacart in `--dry-run`; eyeball it against `.printing-press.json` `cli_name` and the directory structure before applying. |
+
+---
+
+## Documentation / Operational Notes
+
+- Two PRs land in sequence:
+ 1. Machine repo (`cli-printing-press`): U1, U2, U4 — template change + golden refresh + migration script. Suggested PR title: `fix(cli): emit ClawHub-compliant nested YAML for SKILL.md metadata`.
+ 2. Public library repo (`printing-press-library`): U3, U5 — mirror generator update + migration commit. Opened only after PR 1 merges. Suggested PR title: `fix(skills): migrate SKILL.md metadata to ClawHub-compliant nested YAML`.
+- Both PR descriptions should call out the intentional fixture diff (machine repo) and the bulk migration commit (public library repo).
+- No SKILL.md prose edits required — the template change *is* the SKILL.md change for newly-generated CLIs.
+- After migration lands, capture a learning in `docs/solutions/best-practices/` covering: (1) the line-targeted-replacement pattern as the safe alternative to yaml.v3 round-trip for frontmatter migrations, (2) the byte-stability requirement between generator and migration tool, (3) the ClawHub schema source-of-truth at `packages/schema/src/schemas.ts` rather than the markdown docs.
+
+---
+
+## Sources & References
+
+- ClawHub skill format spec (markdown): https://github.com/openclaw/clawhub/blob/main/docs/skill-format.md
+- ClawHub schema source (authoritative): https://github.com/openclaw/clawhub/blob/main/packages/schema/src/schemas.ts (`SkillInstallSpecSchema` at lines 364-373)
+- Related code (this repo): `internal/generator/templates/skill.md.tmpl`, `internal/generator/generator.go:173-410,624-694,1202-1234`, `internal/pipeline/climanifest.go:29-76`
+- Related code (public library): `tools/generate-skills/main.go:283-308,467-524`, `tools/generate-skills/skill-template.md`, `.github/scripts/verify-skill/verify_skill.py`
+- Related learnings: `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout-2026-03-28.md`, `docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md`, `docs/solutions/security-issues/filepath-join-traversal-with-user-input-2026-03-29.md`
diff --git a/internal/generator/skill_test.go b/internal/generator/skill_test.go
index 049842a0..427ad57a 100644
--- a/internal/generator/skill_test.go
+++ b/internal/generator/skill_test.go
@@ -54,8 +54,8 @@ func TestSkillRendersFrontmatterAndCapabilities(t *testing.T) {
"frontmatter description should incorporate headline")
assert.True(t, strings.Contains(content, "`quote AAPL`"),
"frontmatter description should list domain-specific trigger phrases verbatim (backtick-delimited)")
- assert.True(t, strings.Contains(content, "library/commerce/finance-pp-cli"),
- "openclaw install manifest should use the API's category")
+ assert.True(t, strings.Contains(content, "library/commerce/finance/cmd/finance-pp-cli"),
+ "openclaw install manifest should use the API's category and slug-only directory")
// Body
assert.True(t, strings.Contains(content, "## When to Use This CLI"),
@@ -337,6 +337,97 @@ func TestSkillRendersExtraCommands(t *testing.T) {
"extra command with multi-arg signature should render verbatim")
}
+// TestSkillFrontmatterMetadataIsClawHubCompliantNestedYAML asserts that the
+// emitted metadata block parses as nested YAML conforming to ClawHub's
+// SkillInstallSpec schema (kind: go, module:, no kind: shell, no command:,
+// no id:, no label:). The shape was verified directly against
+// packages/schema/src/schemas.ts in the openclaw/clawhub repo.
+func TestSkillFrontmatterMetadataIsClawHubCompliantNestedYAML(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("widget")
+ apiSpec.Category = "commerce"
+ outputDir := filepath.Join(t.TempDir(), "widget-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+ require.NoError(t, err)
+ content := string(skill)
+
+ // Extract frontmatter and parse as YAML.
+ require.True(t, strings.HasPrefix(content, "---\n"))
+ end := strings.Index(content[4:], "\n---\n")
+ require.NotEqual(t, -1, end)
+ body := strings.TrimSuffix(strings.TrimPrefix(content[:4+end+5], "---\n"), "---\n")
+
+ var parsed struct {
+ Metadata struct {
+ Openclaw struct {
+ Requires struct {
+ Bins []string `yaml:"bins"`
+ } `yaml:"requires"`
+ Install []struct {
+ Kind string `yaml:"kind"`
+ Bins []string `yaml:"bins"`
+ Module string `yaml:"module"`
+ // Fields that MUST NOT appear:
+ Command string `yaml:"command"`
+ ID string `yaml:"id"`
+ Label string `yaml:"label"`
+ } `yaml:"install"`
+ } `yaml:"openclaw"`
+ } `yaml:"metadata"`
+ }
+ require.NoError(t, yaml.Unmarshal([]byte(body), &parsed),
+ "frontmatter must parse as nested YAML; content was:\n%s", body)
+
+ // Schema-compliance assertions.
+ assert.Equal(t, []string{"widget-pp-cli"}, parsed.Metadata.Openclaw.Requires.Bins,
+ "requires.bins should carry the CLI binary name")
+ require.Len(t, parsed.Metadata.Openclaw.Install, 1, "exactly one install entry expected")
+ entry := parsed.Metadata.Openclaw.Install[0]
+ assert.Equal(t, "go", entry.Kind, "kind must be 'go' (ClawHub schema enum: brew|node|go|uv)")
+ assert.Equal(t, []string{"widget-pp-cli"}, entry.Bins)
+ assert.Equal(t,
+ "github.com/mvanhorn/printing-press-library/library/commerce/widget/cmd/widget-pp-cli",
+ entry.Module,
+ "module must be the slug-only directory path matching the published library convention; cmd subdir uses the binary name")
+ assert.Empty(t, entry.Command, "command field must not be emitted (not in ClawHub schema)")
+ assert.Empty(t, entry.ID, "id field must not be emitted (optional, no semantic value here)")
+ assert.Empty(t, entry.Label, "label field must not be emitted (optional, no semantic value here)")
+
+ // Negative assertions on raw text — catch any regression to the old shape.
+ assert.NotContains(t, content, `kind: shell`,
+ "kind: shell is invalid per ClawHub schema; must never appear")
+ assert.NotContains(t, content, `kind: "shell"`,
+ "kind: shell is invalid per ClawHub schema; must never appear")
+ assert.NotContains(t, content, `"command":`,
+ "command field is not in ClawHub schema; must never appear in metadata")
+ assert.NotContains(t, content, `\"openclaw\":`,
+ "metadata must not be a JSON-string blob anymore")
+}
+
+// TestSkillFrontmatterMetadataDefaultsCategoryToOther asserts that when the
+// spec has no Category set, the install module path falls back to 'other'.
+func TestSkillFrontmatterMetadataDefaultsCategoryToOther(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("uncategorized")
+ apiSpec.Category = ""
+ outputDir := filepath.Join(t.TempDir(), "uncategorized-pp-cli")
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+ require.NoError(t, err)
+ content := string(skill)
+
+ assert.Contains(t, content,
+ "module: github.com/mvanhorn/printing-press-library/library/other/uncategorized/cmd/uncategorized-pp-cli",
+ "empty Category should default to 'other' in install module path")
+}
+
// TestSkillNoExtraCommandsIsBackwardCompatible asserts the template emits
// no Hand-written commands subsection when ExtraCommands is absent. This
// preserves the rendering of every existing CLI that has no extra_commands
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index 07b95437..0509b2a5 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -3,7 +3,15 @@ name: pp-{{.Name}}
description: "{{if and .Narrative .Narrative.Headline}}{{yamlDoubleQuoted .Narrative.Headline}}{{else}}Printing Press CLI for {{yamlDoubleQuoted .ProseName}}. {{yamlDoubleQuoted (oneline .Description)}}{{end}}{{if and .Narrative .Narrative.TriggerPhrases}} Trigger phrases: {{range $i, $p := .Narrative.TriggerPhrases}}{{if $i}}, {{end}}`{{yamlDoubleQuoted $p}}`{{end}}.{{end}}"
argument-hint: "<command> [args] | install cli|mcp"
allowed-tools: "Read Bash"
-metadata: '{"openclaw":{"requires":{"bins":["{{.Name}}-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}-pp-cli/cmd/{{.Name}}-pp-cli@latest","bins":["{{.Name}}-pp-cli"],"label":"Install via go install"}]}}'
+metadata:
+ openclaw:
+ requires:
+ bins:
+ - {{.Name}}-pp-cli
+ install:
+ - kind: go
+ bins: [{{.Name}}-pp-cli]
+ module: github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}/cmd/{{.Name}}-pp-cli
---
# {{.ProseName}} — Printing Press CLI
@@ -336,7 +344,7 @@ Parse `$ARGUMENTS`:
1. Check Go is installed: `go version` (requires {{if .UsesBrowserHTTPTransport}}Go 1.25+{{else}}Go 1.23+{{end}})
2. Install:
```bash
- go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}-pp-cli/cmd/{{.Name}}-pp-cli@latest
+ go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}/cmd/{{.Name}}-pp-cli@latest
```
3. Verify: `{{.Name}}-pp-cli --version`
4. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
@@ -345,7 +353,7 @@ Parse `$ARGUMENTS`:
1. Install the MCP server:
```bash
- go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}-pp-cli/cmd/{{.Name}}-pp-mcp@latest
+ go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}/cmd/{{.Name}}-pp-mcp@latest
```
2. Register with Claude Code:
```bash
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
index 51acfdee..5c368b91 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
@@ -3,7 +3,15 @@ name: pp-printing-press-golden
description: "Printing Press CLI for Printing Press Golden. Purpose-built fixture for golden generation coverage."
argument-hint: "<command> [args] | install cli|mcp"
allowed-tools: "Read Bash"
-metadata: '{"openclaw":{"requires":{"bins":["printing-press-golden-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/mvanhorn/printing-press-library/library/other/printing-press-golden-pp-cli/cmd/printing-press-golden-pp-cli@latest","bins":["printing-press-golden-pp-cli"],"label":"Install via go install"}]}}'
+metadata:
+ openclaw:
+ requires:
+ bins:
+ - printing-press-golden-pp-cli
+ install:
+ - kind: go
+ bins: [printing-press-golden-pp-cli]
+ module: github.com/mvanhorn/printing-press-library/library/other/printing-press-golden/cmd/printing-press-golden-pp-cli
---
# Printing Press Golden — Printing Press CLI
@@ -144,7 +152,7 @@ Parse `$ARGUMENTS`:
1. Check Go is installed: `go version` (requires Go 1.23+)
2. Install:
```bash
- go install github.com/mvanhorn/printing-press-library/library/other/printing-press-golden-pp-cli/cmd/printing-press-golden-pp-cli@latest
+ go install github.com/mvanhorn/printing-press-library/library/other/printing-press-golden/cmd/printing-press-golden-pp-cli@latest
```
3. Verify: `printing-press-golden-pp-cli --version`
4. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
@@ -153,7 +161,7 @@ Parse `$ARGUMENTS`:
1. Install the MCP server:
```bash
- go install github.com/mvanhorn/printing-press-library/library/other/printing-press-golden-pp-cli/cmd/printing-press-golden-pp-mcp@latest
+ go install github.com/mvanhorn/printing-press-library/library/other/printing-press-golden/cmd/printing-press-golden-pp-mcp@latest
```
2. Register with Claude Code:
```bash
diff --git a/tools/migrate-skill-metadata/main.go b/tools/migrate-skill-metadata/main.go
new file mode 100644
index 00000000..6b60d72b
--- /dev/null
+++ b/tools/migrate-skill-metadata/main.go
@@ -0,0 +1,523 @@
+// Migrate SKILL.md frontmatter metadata: from JSON-encoded string to
+// ClawHub-compliant nested YAML. One-time migration tool.
+//
+// Operates on a public-library tree containing library/*/*/SKILL.md and
+// cli-skills/pp-*/SKILL.md files. For each file:
+//
+// - If the frontmatter contains a single-line `metadata: '{"openclaw":...}'`,
+// parse the embedded JSON, apply schema corrections (kind: shell -> go,
+// drop command/id/label, derive module from command), and replace that
+// line with a multi-line nested YAML block at canonical indentation.
+//
+// - If the frontmatter has no `metadata:` line at all (the instacart case),
+// synthesize a block from the sibling `.printing-press.json` provenance
+// (library files) or from the corresponding library entry (cli-skills
+// mirrors) and insert it before the closing `---`.
+//
+// - If the frontmatter is already in the nested-YAML form, skip without
+// writing (idempotent).
+//
+// All non-metadata frontmatter content stays byte-identical: this tool does
+// no yaml.v3 round-trip of the full frontmatter, only line-targeted text
+// replacement of the metadata region.
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+func main() {
+ dryRun := flag.Bool("dry-run", false, "Print what would change without writing")
+ strict := flag.Bool("strict", true, "Refuse files whose JSON doesn't match the expected kind: shell + go-install shape")
+ verbose := flag.Bool("verbose", false, "Print per-file action lines")
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "usage: migrate-skill-metadata [flags] <library-root>\n\n")
+ fmt.Fprintf(os.Stderr, " library-root path to a tree containing library/*/*/SKILL.md\n")
+ fmt.Fprintf(os.Stderr, " and cli-skills/pp-*/SKILL.md\n\n")
+ flag.PrintDefaults()
+ }
+ flag.Parse()
+
+ if flag.NArg() != 1 {
+ flag.Usage()
+ os.Exit(2)
+ }
+
+ root := flag.Arg(0)
+ absRoot, err := filepath.Abs(root)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: cannot resolve root path: %v\n", err)
+ os.Exit(1)
+ }
+ info, err := os.Stat(absRoot)
+ if err != nil || !info.IsDir() {
+ fmt.Fprintf(os.Stderr, "error: %s is not a directory\n", absRoot)
+ os.Exit(1)
+ }
+
+ report, err := run(absRoot, *dryRun, *strict, *verbose)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+
+ report.print(os.Stdout, *dryRun)
+ if report.errored > 0 {
+ os.Exit(1)
+ }
+}
+
+type report struct {
+ migrated int // JSON-string -> nested YAML
+ synthesized int // no metadata: line -> synthesized block
+ skipped int // already nested
+ errored int // refused or failed
+ errors []string // file paths with reason
+}
+
+func (r *report) print(w *os.File, dryRun bool) {
+ verb := "applied"
+ if dryRun {
+ verb = "would apply"
+ }
+ fmt.Fprintf(w, "Migration summary (%s):\n", verb)
+ fmt.Fprintf(w, " migrated: %d\n", r.migrated)
+ fmt.Fprintf(w, " synthesized: %d\n", r.synthesized)
+ fmt.Fprintf(w, " skipped: %d (already in nested YAML form)\n", r.skipped)
+ fmt.Fprintf(w, " errored: %d\n", r.errored)
+ for _, e := range r.errors {
+ fmt.Fprintf(w, " %s\n", e)
+ }
+}
+
+func run(absRoot string, dryRun, strict, verbose bool) (*report, error) {
+ files, err := discoverSkillFiles(absRoot)
+ if err != nil {
+ return nil, err
+ }
+ if verbose {
+ fmt.Printf("Discovered %d SKILL.md files under %s\n", len(files), absRoot)
+ }
+
+ r := &report{}
+ for _, path := range files {
+ // Path-traversal guard: every discovered path must resolve under absRoot
+ // even after symlink evaluation. filepath.Abs alone Cleans `..` segments
+ // but does not follow symlinks; a SKILL.md symlinked to a target outside
+ // the root would slip through the prefix check without EvalSymlinks.
+ abs, err := filepath.EvalSymlinks(path)
+ if err != nil {
+ r.errored++
+ r.errors = append(r.errors, fmt.Sprintf("%s: cannot resolve path: %v", path, err))
+ continue
+ }
+ abs, err = filepath.Abs(abs)
+ if err != nil {
+ r.errored++
+ r.errors = append(r.errors, fmt.Sprintf("%s: cannot absolutize path: %v", path, err))
+ continue
+ }
+ if !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) {
+ r.errored++
+ r.errors = append(r.errors, fmt.Sprintf("%s: resolved path escapes root", path))
+ continue
+ }
+
+ action, err := migrateFile(abs, absRoot, strict, dryRun)
+ switch {
+ case err != nil:
+ r.errored++
+ r.errors = append(r.errors, fmt.Sprintf("%s: %v", relpath(abs, absRoot), err))
+ case action == "migrated":
+ r.migrated++
+ case action == "synthesized":
+ r.synthesized++
+ case action == "skipped":
+ r.skipped++
+ }
+ if verbose && err == nil {
+ fmt.Printf(" %s: %s\n", relpath(abs, absRoot), action)
+ }
+ }
+ return r, nil
+}
+
+func relpath(abs, root string) string {
+ rel, err := filepath.Rel(root, abs)
+ if err != nil {
+ return abs
+ }
+ return rel
+}
+
+// discoverSkillFiles returns absolute paths to all SKILL.md files under
+// library/*/*/ and cli-skills/pp-*/ inside the root. Output is sorted for
+// deterministic processing.
+func discoverSkillFiles(absRoot string) ([]string, error) {
+ var out []string
+ libraryGlob := filepath.Join(absRoot, "library", "*", "*", "SKILL.md")
+ libMatches, err := filepath.Glob(libraryGlob)
+ if err != nil {
+ return nil, fmt.Errorf("globbing library: %w", err)
+ }
+ out = append(out, libMatches...)
+
+ skillsGlob := filepath.Join(absRoot, "cli-skills", "pp-*", "SKILL.md")
+ skillsMatches, err := filepath.Glob(skillsGlob)
+ if err != nil {
+ return nil, fmt.Errorf("globbing cli-skills: %w", err)
+ }
+ out = append(out, skillsMatches...)
+
+ sort.Strings(out)
+ return out, nil
+}
+
+// migrateFile applies the migration to one SKILL.md, returning the action
+// taken: "migrated", "synthesized", "skipped", or "" plus an error.
+func migrateFile(absPath, absRoot string, strict, dryRun bool) (string, error) {
+ content, err := os.ReadFile(absPath)
+ if err != nil {
+ return "", fmt.Errorf("read: %w", err)
+ }
+
+ frontStart, frontEnd, err := frontmatterBounds(content)
+ if err != nil {
+ return "", err
+ }
+ front := string(content[frontStart:frontEnd])
+
+ jsonLineRe := regexp.MustCompile(`(?m)^metadata: '(.+)'\s*$`)
+ if m := jsonLineRe.FindStringSubmatchIndex(front); m != nil {
+ // Idempotency check: if the line already starts with a multi-line
+ // nested form, FindStringSubmatchIndex would not have matched. So
+ // matching here means we have the legacy JSON-string form.
+ jsonValue := front[m[2]:m[3]]
+ // Translate the match indices, which are relative to `front`, into
+ // the original content's coordinate space for splicing.
+ lineStart := frontStart + m[0]
+ lineEnd := frontStart + m[1]
+
+ newBlock, err := transformMetadataJSON(jsonValue, strict)
+ if err != nil {
+ return "", fmt.Errorf("transform: %w", err)
+ }
+
+ updated := append([]byte{}, content[:lineStart]...)
+ updated = append(updated, []byte(newBlock)...)
+ updated = append(updated, content[lineEnd:]...)
+ if !dryRun {
+ if err := atomicWrite(absPath, updated); err != nil {
+ return "", err
+ }
+ }
+ return "migrated", nil
+ }
+
+ // Idempotency: if the frontmatter already has a `metadata:` block
+ // (multi-line nested YAML form), the regex won't match and we can skip.
+ if strings.Contains(front, "\nmetadata:\n") || strings.HasPrefix(front, "metadata:\n") {
+ return "skipped", nil
+ }
+
+ // No metadata field at all -- synthesis path.
+ cliName, category, dirName, err := lookupProvenance(absPath, absRoot)
+ if err != nil {
+ return "", fmt.Errorf("synthesis lookup: %w", err)
+ }
+ block := buildMetadataBlock(cliName, category, dirName, nil, "")
+ updated := append([]byte{}, content[:frontEnd]...)
+ updated = append(updated, []byte(block)...)
+ updated = append(updated, content[frontEnd:]...)
+ if !dryRun {
+ if err := atomicWrite(absPath, updated); err != nil {
+ return "", err
+ }
+ }
+ return "synthesized", nil
+}
+
+// frontmatterBounds returns the byte offsets of the start and end of the
+// frontmatter region (between the first two `---` markers). The start
+// includes the opening line, the end is the byte position of the closing
+// `---` line (not past its newline).
+func frontmatterBounds(content []byte) (int, int, error) {
+ if !strings.HasPrefix(string(content), "---\n") {
+ return 0, 0, fmt.Errorf("file does not begin with frontmatter delimiter")
+ }
+ rest := content[4:]
+ idx := strings.Index(string(rest), "\n---\n")
+ if idx == -1 {
+ return 0, 0, fmt.Errorf("frontmatter has no closing delimiter")
+ }
+ // frontmatter region: from byte 0 (start of opening `---`) through end of
+ // the line just before the closing `---`. We return the inner bounds:
+ // start = 4 (past opening `---\n`), end = 4 + idx + 1 (past the newline
+ // before the closing `---`).
+ return 4, 4 + idx + 1, nil
+}
+
+// installEntryJSON mirrors the legacy `install[]` entry shape we expect to
+// find in current SKILL.md files. We read kind, command, and bins; id and
+// label are silently ignored by encoding/json's unknown-field default since
+// the migration drops them anyway.
+type installEntryJSON struct {
+ Kind string `json:"kind"`
+ Command string `json:"command,omitempty"`
+ Bins []string `json:"bins,omitempty"`
+}
+
+// openclawJSON is the embedded JSON shape we read out of the legacy
+// `metadata:` string.
+type openclawJSON struct {
+ Requires struct {
+ Bins []string `json:"bins,omitempty"`
+ Env []string `json:"env,omitempty"`
+ } `json:"requires"`
+ PrimaryEnv string `json:"primaryEnv,omitempty"`
+ Install []installEntryJSON `json:"install,omitempty"`
+}
+
+// transformMetadataJSON parses a legacy metadata JSON string and emits the
+// canonical multi-line nested-YAML block. The block ends with a newline so
+// it slots in cleanly where the original single line lived.
+func transformMetadataJSON(jsonStr string, strict bool) (string, error) {
+ var meta struct {
+ Openclaw openclawJSON `json:"openclaw"`
+ }
+ if err := json.Unmarshal([]byte(jsonStr), &meta); err != nil {
+ return "", fmt.Errorf("parse JSON: %w", err)
+ }
+ if len(meta.Openclaw.Requires.Bins) == 0 {
+ return "", fmt.Errorf("openclaw.requires.bins missing")
+ }
+ if len(meta.Openclaw.Install) == 0 {
+ return "", fmt.Errorf("openclaw.install[] missing")
+ }
+
+ // We only emit the first install entry's binary + module. Today every
+ // printed CLI has exactly one install option (go install), so multi-entry
+ // support is not yet exercised; revisit if multi-platform installs land.
+ first := meta.Openclaw.Install[0]
+ if strict {
+ if first.Kind != "shell" {
+ return "", fmt.Errorf("install[0].kind=%q (strict mode expects 'shell')", first.Kind)
+ }
+ if !strings.HasPrefix(first.Command, "go install ") {
+ return "", fmt.Errorf("install[0].command does not start with 'go install '")
+ }
+ }
+
+ module, err := deriveModule(first.Command)
+ if err != nil {
+ return "", fmt.Errorf("install[0]: %w", err)
+ }
+ bins := first.Bins
+ if len(bins) == 0 {
+ bins = meta.Openclaw.Requires.Bins
+ }
+ cliName := bins[0]
+ return emitMetadataBlock(cliName, module, meta.Openclaw.Requires.Env, meta.Openclaw.PrimaryEnv), nil
+}
+
+// deriveModule strips "go install " prefix and "@latest" or "@<version>"
+// suffix from a command string and returns the bare module path.
+func deriveModule(command string) (string, error) {
+ const prefix = "go install "
+ if !strings.HasPrefix(command, prefix) {
+ return "", fmt.Errorf("command does not start with 'go install '")
+ }
+ rest := strings.TrimPrefix(command, prefix)
+ if at := strings.LastIndex(rest, "@"); at >= 0 {
+ rest = rest[:at]
+ }
+ if rest == "" {
+ return "", fmt.Errorf("module path empty after stripping prefix/version")
+ }
+ return rest, nil
+}
+
+// buildMetadataBlock is the synthesis-path emitter: derives the module
+// from category + library directory name + cliName before delegating to
+// emitMetadataBlock. Used when a SKILL.md has no metadata field at all and
+// we synthesize one from .printing-press.json provenance.
+//
+// dirName is the slug-keyed directory the CLI lives in under library/<category>/.
+// It can differ from cliName when the directory uses the slug-only convention
+// (e.g., library/commerce/instacart/ vs cli_name "instacart-pp-cli") or the
+// older binary-suffix convention (e.g., library/commerce/dominos-pp-cli/).
+// Module path follows library/<category>/<dirName>/cmd/<cliName>.
+func buildMetadataBlock(cliName, category, dirName string, env []string, primaryEnv string) string {
+ if category == "" {
+ category = "other"
+ }
+ if dirName == "" {
+ dirName = cliName
+ }
+ module := "github.com/mvanhorn/printing-press-library/library/" + category +
+ "/" + dirName + "/cmd/" + cliName
+ return emitMetadataBlock(cliName, module, env, primaryEnv)
+}
+
+// emitMetadataBlock returns the canonical multi-line YAML metadata block.
+// Indentation uses 2 spaces. The block always ends with a newline so it can
+// splice cleanly into a frontmatter. When env or primaryEnv are supplied,
+// they are emitted under openclaw in the order: requires.bins, requires.env,
+// primaryEnv, install.
+//
+// The single source of canonical formatting -- both the migration path
+// (transformMetadataJSON) and synthesis path (buildMetadataBlock) route
+// through here so the byte-shape stays identical.
+func emitMetadataBlock(cliName, module string, env []string, primaryEnv string) string {
+ var b strings.Builder
+ b.WriteString("metadata:\n")
+ b.WriteString(" openclaw:\n")
+ b.WriteString(" requires:\n")
+ b.WriteString(" bins:\n")
+ b.WriteString(" - ")
+ b.WriteString(cliName)
+ b.WriteString("\n")
+ if len(env) > 0 {
+ b.WriteString(" env:\n")
+ for _, e := range env {
+ b.WriteString(" - ")
+ b.WriteString(e)
+ b.WriteString("\n")
+ }
+ }
+ if primaryEnv != "" {
+ b.WriteString(" primaryEnv: ")
+ b.WriteString(primaryEnv)
+ b.WriteString("\n")
+ }
+ b.WriteString(" install:\n")
+ b.WriteString(" - kind: go\n")
+ b.WriteString(" bins: [")
+ b.WriteString(cliName)
+ b.WriteString("]\n")
+ b.WriteString(" module: ")
+ b.WriteString(module)
+ b.WriteString("\n")
+ return b.String()
+}
+
+// lookupProvenance resolves cli_name, category, and the library directory
+// basename for synthesis.
+//
+// For library/*/<dir>/SKILL.md, reads the sibling .printing-press.json and
+// uses <dir> as the library directory basename.
+// For cli-skills/pp-<slug>/SKILL.md, scans library/*/*/.printing-press.json
+// for a directory matching <slug> or <slug>-pp-cli and uses that
+// directory's basename. The basename can differ from cli_name (slug-only
+// convention vs. older binary-suffix convention), so the module path must
+// be derived from the actual filesystem location.
+func lookupProvenance(skillPath, absRoot string) (cliName, category, dirName string, err error) {
+ dir := filepath.Dir(skillPath)
+ manifestPath := filepath.Join(dir, ".printing-press.json")
+ data, readErr := os.ReadFile(manifestPath)
+ if readErr == nil {
+ cn, cat, perr := parseProvenance(data)
+ if perr != nil {
+ return "", "", "", perr
+ }
+ return cn, cat, filepath.Base(dir), nil
+ }
+ // Only fall through to cli-skills mirror lookup when the sibling manifest
+ // genuinely doesn't exist. Other read errors (permission denied, EIO, EISDIR)
+ // would otherwise produce a misleading 'not a pp-* directory' error from the
+ // fallthrough path.
+ if !errors.Is(readErr, fs.ErrNotExist) {
+ return "", "", "", fmt.Errorf("read sibling provenance: %w", readErr)
+ }
+ // cli-skills mirror: derive slug, scan library/.
+ base := filepath.Base(dir)
+ slug := strings.TrimPrefix(base, "pp-")
+ if slug == base {
+ return "", "", "", fmt.Errorf("no sibling .printing-press.json and not a pp-* directory")
+ }
+ candidates := []string{
+ filepath.Join(absRoot, "library", "*", slug, ".printing-press.json"),
+ filepath.Join(absRoot, "library", "*", slug+"-pp-cli", ".printing-press.json"),
+ }
+ for _, pattern := range candidates {
+ // filepath.Glob errors on ErrBadPattern only; the patterns above are
+ // static and known-good, so an error here means the OS layer is broken
+ // rather than the input. Surface it instead of silently skipping.
+ matches, globErr := filepath.Glob(pattern)
+ if globErr != nil {
+ return "", "", "", fmt.Errorf("glob %s: %w", pattern, globErr)
+ }
+ for _, m := range matches {
+ data, err := os.ReadFile(m)
+ if err != nil {
+ continue
+ }
+ cn, cat, perr := parseProvenance(data)
+ if perr != nil {
+ return "", "", "", perr
+ }
+ return cn, cat, filepath.Base(filepath.Dir(m)), nil
+ }
+ }
+ return "", "", "", fmt.Errorf("could not resolve provenance for cli-skills mirror %s", base)
+}
+
+func parseProvenance(data []byte) (cliName, category string, err error) {
+ var pp struct {
+ CLIName string `json:"cli_name"`
+ Category string `json:"category"`
+ }
+ if err := json.Unmarshal(data, &pp); err != nil {
+ return "", "", fmt.Errorf("parse .printing-press.json: %w", err)
+ }
+ if pp.CLIName == "" {
+ return "", "", fmt.Errorf(".printing-press.json missing cli_name")
+ }
+ return pp.CLIName, pp.Category, nil
+}
+
+// atomicWrite writes data to path via tmp + rename. Preserves the original
+// file's mode so a 0644 SKILL.md doesn't silently become 0600 (CreateTemp's
+// default) after rename. fsyncs the data before close so a crash between
+// rename and dirty-page flush can't leave a zero-length file behind.
+func atomicWrite(path string, data []byte) error {
+ origInfo, err := os.Stat(path)
+ if err != nil {
+ return fmt.Errorf("stat original: %w", err)
+ }
+ dir := filepath.Dir(path)
+ tmp, err := os.CreateTemp(dir, ".migrate-skill-metadata-*.tmp")
+ if err != nil {
+ return fmt.Errorf("create tmp: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer func() { _ = os.Remove(tmpName) }()
+ if _, err := tmp.Write(data); err != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("write tmp: %w", err)
+ }
+ if err := tmp.Chmod(origInfo.Mode().Perm()); err != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("chmod tmp: %w", err)
+ }
+ if err := tmp.Sync(); err != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("fsync tmp: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("close tmp: %w", err)
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ return fmt.Errorf("rename tmp: %w", err)
+ }
+ return nil
+}
diff --git a/tools/migrate-skill-metadata/main_test.go b/tools/migrate-skill-metadata/main_test.go
new file mode 100644
index 00000000..30e383d9
--- /dev/null
+++ b/tools/migrate-skill-metadata/main_test.go
@@ -0,0 +1,548 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// --- Unit tests for derivation helpers ---
+
+func TestDeriveModule(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ want string
+ wantErr bool
+ }{
+ {"latest tag", "go install github.com/x/y/cmd/z@latest", "github.com/x/y/cmd/z", false},
+ {"explicit version", "go install github.com/x/y/cmd/z@v1.2.3", "github.com/x/y/cmd/z", false},
+ {"no version tag", "go install github.com/x/y/cmd/z", "github.com/x/y/cmd/z", false},
+ {"missing prefix", "github.com/x/y/cmd/z@latest", "", true},
+ {"empty after strip", "go install @latest", "", true},
+ {"path with @ in it", "go install github.com/x/y@email.com/cmd/z@latest", "github.com/x/y@email.com/cmd/z", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := deriveModule(tc.input)
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
+ }
+ if got != tc.want {
+ t.Errorf("got %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestEmitMetadataBlock(t *testing.T) {
+ want := "metadata:\n" +
+ " openclaw:\n" +
+ " requires:\n" +
+ " bins:\n" +
+ " - foo-pp-cli\n" +
+ " install:\n" +
+ " - kind: go\n" +
+ " bins: [foo-pp-cli]\n" +
+ " module: github.com/example/foo/cmd/foo-pp-cli\n"
+ got := emitMetadataBlock("foo-pp-cli", "github.com/example/foo/cmd/foo-pp-cli", nil, "")
+ if got != want {
+ t.Errorf("emitMetadataBlock no-auth mismatch:\nwant:\n%s\ngot:\n%s", want, got)
+ }
+}
+
+func TestEmitMetadataBlockWithAuth(t *testing.T) {
+ want := "metadata:\n" +
+ " openclaw:\n" +
+ " requires:\n" +
+ " bins:\n" +
+ " - foo-pp-cli\n" +
+ " env:\n" +
+ " - FOO_TOKEN\n" +
+ " - FOO_OPTIONAL\n" +
+ " primaryEnv: FOO_TOKEN\n" +
+ " install:\n" +
+ " - kind: go\n" +
+ " bins: [foo-pp-cli]\n" +
+ " module: github.com/example/foo/cmd/foo-pp-cli\n"
+ got := emitMetadataBlock("foo-pp-cli", "github.com/example/foo/cmd/foo-pp-cli",
+ []string{"FOO_TOKEN", "FOO_OPTIONAL"}, "FOO_TOKEN")
+ if got != want {
+ t.Errorf("emitMetadataBlock api_key mismatch:\nwant:\n%s\ngot:\n%s", want, got)
+ }
+}
+
+func TestBuildMetadataBlockDefaultsCategoryToOther(t *testing.T) {
+ got := buildMetadataBlock("foo-pp-cli", "", "foo", nil, "")
+ if !strings.Contains(got, "library/other/foo/cmd/foo-pp-cli") {
+ t.Errorf("expected fallback to 'other' category in module path; got:\n%s", got)
+ }
+}
+
+func TestBuildMetadataBlockWithCategoryAndSlugDir(t *testing.T) {
+ // Slug-only directory convention: directory is "dub", binary is "dub-pp-cli".
+ got := buildMetadataBlock("dub-pp-cli", "marketing", "dub", nil, "")
+ if !strings.Contains(got, "library/marketing/dub/cmd/dub-pp-cli") {
+ t.Errorf("expected slug-only directory in module path; got:\n%s", got)
+ }
+}
+
+func TestBuildMetadataBlockBinarySuffixDirConvention(t *testing.T) {
+ // Older binary-suffix convention: directory is "fedex-pp-cli".
+ got := buildMetadataBlock("fedex-pp-cli", "commerce", "fedex-pp-cli", nil, "")
+ if !strings.Contains(got, "library/commerce/fedex-pp-cli/cmd/fedex-pp-cli") {
+ t.Errorf("expected binary-suffix directory in module path; got:\n%s", got)
+ }
+}
+
+func TestBuildMetadataBlockEmptyDirNameFallsBackToCliName(t *testing.T) {
+ // Defensive fallback when caller passes empty dirName.
+ got := buildMetadataBlock("x-pp-cli", "other", "", nil, "")
+ if !strings.Contains(got, "library/other/x-pp-cli/cmd/x-pp-cli") {
+ t.Errorf("expected fallback to cliName when dirName empty; got:\n%s", got)
+ }
+}
+
+// --- transformMetadataJSON tests ---
+
+func TestTransformMetadataJSON_HappyPath(t *testing.T) {
+ jsonStr := `{"openclaw":{"requires":{"bins":["dub-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/mvanhorn/printing-press-library/library/other/dub/cmd/dub-pp-cli@latest","bins":["dub-pp-cli"],"label":"Install via go install"}]}}`
+ got, err := transformMetadataJSON(jsonStr, true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(got, "kind: go") {
+ t.Errorf("output missing kind: go; got:\n%s", got)
+ }
+ if !strings.Contains(got, "module: github.com/mvanhorn/printing-press-library/library/other/dub/cmd/dub-pp-cli") {
+ t.Errorf("output missing expected module path; got:\n%s", got)
+ }
+ if strings.Contains(got, "kind: shell") {
+ t.Errorf("output should not contain kind: shell; got:\n%s", got)
+ }
+ if strings.Contains(got, "command:") {
+ t.Errorf("output should not contain command:; got:\n%s", got)
+ }
+ if strings.Contains(got, "id:") {
+ t.Errorf("output should not contain id:; got:\n%s", got)
+ }
+ if strings.Contains(got, "label:") {
+ t.Errorf("output should not contain label:; got:\n%s", got)
+ }
+ if strings.Contains(got, "@latest") {
+ t.Errorf("module should have @latest stripped; got:\n%s", got)
+ }
+}
+
+func TestTransformMetadataJSON_WithAuthEnv(t *testing.T) {
+ jsonStr := `{"openclaw":{"requires":{"bins":["kalshi-pp-cli"],"env":["KALSHI_TOKEN"]},"primaryEnv":"KALSHI_TOKEN","install":[{"id":"go","kind":"shell","command":"go install github.com/mvanhorn/printing-press-library/library/payments/kalshi/cmd/kalshi-pp-cli@latest","bins":["kalshi-pp-cli"],"label":"Install via go install"}]}}`
+ got, err := transformMetadataJSON(jsonStr, true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(got, "env:\n - KALSHI_TOKEN") {
+ t.Errorf("output missing env list; got:\n%s", got)
+ }
+ if !strings.Contains(got, "primaryEnv: KALSHI_TOKEN") {
+ t.Errorf("output missing primaryEnv; got:\n%s", got)
+ }
+}
+
+func TestTransformMetadataJSON_AgentCaptureBareName(t *testing.T) {
+ jsonStr := `{"openclaw":{"requires":{"bins":["agent-capture"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/mvanhorn/printing-press-library/library/developer-tools/agent-capture/cmd/agent-capture@latest","bins":["agent-capture"],"label":"Install via go install"}]}}`
+ got, err := transformMetadataJSON(jsonStr, true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(got, "bins: [agent-capture]") {
+ t.Errorf("output missing bare bins; got:\n%s", got)
+ }
+ if !strings.Contains(got, "/cmd/agent-capture\n") {
+ t.Errorf("output module should end with /cmd/agent-capture; got:\n%s", got)
+ }
+}
+
+func TestTransformMetadataJSON_StrictRejectsBrewKind(t *testing.T) {
+ jsonStr := `{"openclaw":{"requires":{"bins":["foo"]},"install":[{"kind":"brew","formula":"foo","bins":["foo"]}]}}`
+ _, err := transformMetadataJSON(jsonStr, true)
+ if err == nil {
+ t.Fatalf("expected strict mode to reject kind: brew")
+ }
+ if !strings.Contains(err.Error(), "brew") {
+ t.Errorf("error message should mention the rejected kind; got: %v", err)
+ }
+}
+
+func TestTransformMetadataJSON_StrictRejectsNonGoInstallCommand(t *testing.T) {
+ jsonStr := `{"openclaw":{"requires":{"bins":["foo"]},"install":[{"kind":"shell","command":"curl -L https://x/y | sh","bins":["foo"]}]}}`
+ _, err := transformMetadataJSON(jsonStr, true)
+ if err == nil {
+ t.Fatalf("expected strict mode to reject non-go-install command")
+ }
+}
+
+func TestTransformMetadataJSON_MalformedJSON(t *testing.T) {
+ _, err := transformMetadataJSON(`{not valid json`, true)
+ if err == nil {
+ t.Fatalf("expected error on malformed JSON")
+ }
+ if !strings.Contains(err.Error(), "parse JSON") {
+ t.Errorf("error should mention JSON parse failure; got: %v", err)
+ }
+}
+
+func TestTransformMetadataJSON_MissingBins(t *testing.T) {
+ _, err := transformMetadataJSON(`{"openclaw":{"requires":{},"install":[{"kind":"shell","command":"go install x@latest","bins":["x"]}]}}`, true)
+ if err == nil {
+ t.Fatalf("expected error when requires.bins missing")
+ }
+}
+
+// --- frontmatterBounds tests ---
+
+func TestFrontmatterBounds(t *testing.T) {
+ content := []byte("---\nname: foo\ndescription: bar\n---\n\nbody")
+ start, end, err := frontmatterBounds(content)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ got := string(content[start:end])
+ want := "name: foo\ndescription: bar\n"
+ if got != want {
+ t.Errorf("frontmatter region mismatch:\nwant: %q\ngot: %q", want, got)
+ }
+}
+
+func TestFrontmatterBoundsRejectsMissingOpening(t *testing.T) {
+ _, _, err := frontmatterBounds([]byte("name: foo\n---\nbody"))
+ if err == nil {
+ t.Fatalf("expected error on missing opening delimiter")
+ }
+}
+
+func TestFrontmatterBoundsRejectsMissingClosing(t *testing.T) {
+ _, _, err := frontmatterBounds([]byte("---\nname: foo\nbody"))
+ if err == nil {
+ t.Fatalf("expected error on missing closing delimiter")
+ }
+}
+
+// --- migrateFile end-to-end tests ---
+
+func TestMigrateFile_LegacyJSONStringConversion(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "library", "other", "dub")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ skill := dir + "/SKILL.md"
+ original := `---
+name: pp-dub
+description: "Dub CLI."
+argument-hint: "<command> [args] | install cli|mcp"
+allowed-tools: "Read Bash"
+metadata: '{"openclaw":{"requires":{"bins":["dub-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/mvanhorn/printing-press-library/library/other/dub/cmd/dub-pp-cli@latest","bins":["dub-pp-cli"],"label":"Install via go install"}]}}'
+---
+
+# Body content with backticks ` + "`x`" + ` and quotes "y" stays untouched.
+`
+ if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ action, err := migrateFile(skill, root, true, false)
+ if err != nil {
+ t.Fatalf("migrateFile error: %v", err)
+ }
+ if action != "migrated" {
+ t.Errorf("expected action=migrated, got %s", action)
+ }
+ got, err := os.ReadFile(skill)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(got), "kind: go") {
+ t.Error("migrated file should contain kind: go")
+ }
+ if strings.Contains(string(got), `'{"openclaw":`) {
+ t.Error("migrated file should no longer contain JSON-string metadata")
+ }
+ // Body byte-equal: every line of the original after the metadata line
+ // must still be present verbatim.
+ if !strings.Contains(string(got), "# Body content with backticks `x` and quotes \"y\" stays untouched.") {
+ t.Error("body content with special chars should be preserved byte-equal")
+ }
+ // The other frontmatter fields stay byte-equal too.
+ if !strings.Contains(string(got), `description: "Dub CLI."`) {
+ t.Error("description with double quotes should be preserved byte-equal")
+ }
+}
+
+func TestMigrateFile_Idempotent(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "library", "other", "dub")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ skill := dir + "/SKILL.md"
+ alreadyMigrated := `---
+name: pp-dub
+description: "Dub CLI."
+argument-hint: "<command> [args]"
+allowed-tools: "Read Bash"
+metadata:
+ openclaw:
+ requires:
+ bins:
+ - dub-pp-cli
+ install:
+ - kind: go
+ bins: [dub-pp-cli]
+ module: github.com/x/y/cmd/dub-pp-cli
+---
+
+body
+`
+ if err := os.WriteFile(skill, []byte(alreadyMigrated), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ action, err := migrateFile(skill, root, true, false)
+ if err != nil {
+ t.Fatalf("migrateFile error: %v", err)
+ }
+ if action != "skipped" {
+ t.Errorf("expected action=skipped on already-nested file, got %s", action)
+ }
+ got, _ := os.ReadFile(skill)
+ if string(got) != alreadyMigrated {
+ t.Error("file should be byte-identical after idempotent skip")
+ }
+}
+
+func TestMigrateFile_DryRunDoesNotWrite(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "library", "other", "dub")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ skill := dir + "/SKILL.md"
+ original := `---
+name: pp-dub
+description: "x"
+argument-hint: "<command>"
+allowed-tools: "Read Bash"
+metadata: '{"openclaw":{"requires":{"bins":["dub-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/x/y/cmd/dub-pp-cli@latest","bins":["dub-pp-cli"],"label":"Install via go install"}]}}'
+---
+
+body
+`
+ if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ action, err := migrateFile(skill, root, true, true) // dryRun=true
+ if err != nil {
+ t.Fatalf("migrateFile error: %v", err)
+ }
+ if action != "migrated" {
+ t.Errorf("expected action=migrated even in dry-run, got %s", action)
+ }
+ got, _ := os.ReadFile(skill)
+ if string(got) != original {
+ t.Error("dry-run should not modify the file")
+ }
+}
+
+func TestMigrateFile_SynthesisPath(t *testing.T) {
+ root := t.TempDir()
+ libDir := filepath.Join(root, "library", "commerce", "instacart")
+ if err := os.MkdirAll(libDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ provenance := `{"cli_name": "instacart-pp-cli", "category": "commerce"}`
+ if err := os.WriteFile(libDir+"/.printing-press.json", []byte(provenance), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ original := `---
+name: pp-instacart
+description: "Instacart CLI."
+argument-hint: "<command>"
+allowed-tools: "Read Bash"
+---
+
+body
+`
+ skill := libDir + "/SKILL.md"
+ if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ action, err := migrateFile(skill, root, true, false)
+ if err != nil {
+ t.Fatalf("migrateFile error: %v", err)
+ }
+ if action != "synthesized" {
+ t.Errorf("expected action=synthesized, got %s", action)
+ }
+ got, _ := os.ReadFile(skill)
+ if !strings.Contains(string(got), "metadata:\n openclaw:") {
+ t.Error("synthesized file should have nested metadata block")
+ }
+ // Module path uses the slug-only directory ("instacart"), not the cli_name.
+ if !strings.Contains(string(got), "module: github.com/mvanhorn/printing-press-library/library/commerce/instacart/cmd/instacart-pp-cli") {
+ t.Errorf("synthesized module path wrong; got:\n%s", string(got))
+ }
+ if !strings.Contains(string(got), "name: pp-instacart") {
+ t.Error("original frontmatter fields should be preserved")
+ }
+ if !strings.Contains(string(got), "\n\nbody\n") {
+ t.Error("body content should be preserved")
+ }
+}
+
+func TestMigrateFile_SynthesisCliSkillsMirror(t *testing.T) {
+ root := t.TempDir()
+ // Create the library entry that the cli-skills mirror points back to.
+ libDir := filepath.Join(root, "library", "commerce", "instacart")
+ if err := os.MkdirAll(libDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ provenance := `{"cli_name": "instacart-pp-cli", "category": "commerce"}`
+ if err := os.WriteFile(libDir+"/.printing-press.json", []byte(provenance), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ mirrorDir := filepath.Join(root, "cli-skills", "pp-instacart")
+ if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ original := `---
+name: pp-instacart
+description: "Instacart CLI mirror."
+argument-hint: "<command>"
+allowed-tools: "Read Bash"
+---
+
+body
+`
+ skill := mirrorDir + "/SKILL.md"
+ if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ action, err := migrateFile(skill, root, true, false)
+ if err != nil {
+ t.Fatalf("migrateFile error: %v", err)
+ }
+ if action != "synthesized" {
+ t.Errorf("expected action=synthesized for cli-skills mirror, got %s", action)
+ }
+ got, _ := os.ReadFile(skill)
+ // Module path uses the library directory basename ("instacart"), not the cli_name or pp-instacart.
+ if !strings.Contains(string(got), "module: github.com/mvanhorn/printing-press-library/library/commerce/instacart/cmd/instacart-pp-cli") {
+ t.Errorf("mirror synthesis should resolve via library lookup; got:\n%s", string(got))
+ }
+}
+
+func TestMigrateFile_SynthesisFailsWithoutProvenance(t *testing.T) {
+ root := t.TempDir()
+ libDir := filepath.Join(root, "library", "commerce", "orphan")
+ if err := os.MkdirAll(libDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ original := `---
+name: pp-orphan
+description: "no metadata, no provenance"
+argument-hint: "<command>"
+allowed-tools: "Read Bash"
+---
+
+body
+`
+ skill := libDir + "/SKILL.md"
+ if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ _, err := migrateFile(skill, root, true, false)
+ if err == nil {
+ t.Fatalf("expected error when synthesis cannot find provenance")
+ }
+}
+
+// TestRun_RejectsSymlinkEscapingRoot covers the path-traversal guard. A
+// SKILL.md inside the root that's a symlink to a file outside the root
+// must fail the EvalSymlinks-then-prefix check. This test fixture also
+// exercises the run() orchestrator's error-aggregation path.
+func TestRun_RejectsSymlinkEscapingRoot(t *testing.T) {
+ root := t.TempDir()
+ outside := t.TempDir()
+ outsideSkill := outside + "/SKILL.md"
+ if err := os.WriteFile(outsideSkill, []byte("---\nname: out\n---\nbody\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ libDir := filepath.Join(root, "library", "evil", "x")
+ if err := os.MkdirAll(libDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ insideSymlink := libDir + "/SKILL.md"
+ if err := os.Symlink(outsideSkill, insideSymlink); err != nil {
+ t.Skipf("symlink not supported on this filesystem: %v", err)
+ }
+
+ report, err := run(root, true, true, false)
+ if err != nil {
+ t.Fatalf("run returned error: %v", err)
+ }
+ if report.errored == 0 {
+ t.Errorf("expected at least one errored file, got none")
+ }
+ foundEscape := false
+ for _, msg := range report.errors {
+ if strings.Contains(msg, "escapes root") {
+ foundEscape = true
+ break
+ }
+ }
+ if !foundEscape {
+ t.Errorf("expected an 'escapes root' error in report; got: %v", report.errors)
+ }
+}
+
+func TestMigrateFile_BodyJSONShapeIsNotTouched(t *testing.T) {
+ // Negative-space test: body content contains JSON-shaped strings; only
+ // the frontmatter `metadata:` line should be touched.
+ root := t.TempDir()
+ dir := filepath.Join(root, "library", "other", "x")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ skill := dir + "/SKILL.md"
+ original := `---
+name: pp-x
+description: "x"
+argument-hint: "<command>"
+allowed-tools: "Read Bash"
+metadata: '{"openclaw":{"requires":{"bins":["x-pp-cli"]},"install":[{"id":"go","kind":"shell","command":"go install github.com/x/y/cmd/x-pp-cli@latest","bins":["x-pp-cli"],"label":"Install via go install"}]}}'
+---
+
+# A recipe block
+
+` + "```bash" + `
+echo '{"foo": "bar", "kind": "shell"}'
+` + "```" + `
+
+End.
+`
+ if err := os.WriteFile(skill, []byte(original), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := migrateFile(skill, root, true, false); err != nil {
+ t.Fatalf("migrateFile error: %v", err)
+ }
+ got, _ := os.ReadFile(skill)
+ // The body's `kind: shell` (inside the bash block) must NOT be changed.
+ if !strings.Contains(string(got), `"kind": "shell"`) {
+ t.Error("body's literal JSON shape must be preserved byte-equal; do not touch body content")
+ }
+ // The frontmatter must use kind: go now.
+ if !strings.Contains(string(got), " - kind: go\n") {
+ t.Error("frontmatter should be migrated")
+ }
+}
← 6281f2be fix(cli): route sync --json events to stdout (#608)
·
back to Cli Printing Press
·
fix(cli): always derive install module from filesystem in mi 1b0dc70a →