---
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`
