Files
James Russo e96ebd74de feat(skills): add changelog-video skill for repo-native CC + Codex discovery (#2552)
Packages Jake Moran's changelog-video pipeline (v1, validated end-to-end
by Home on the Jun 23-29 range) as a repo-native skill set that Claude
Code (.claude/skills/) and Codex CLI (.agents/skills/) auto-discover the
moment the repo is opened. No install step; run the skill against a
changelog markdown for a given git range and it produces a lint-clean,
seam-gate-green 1080x1080 MP4 (~45-60s, Annie VO, mock-UI visualizations,
caption rail) end-to-end.

Six skills added byte-identical in both mirror dirs:
- changelog-video (pipeline entry point)
- motion-doctrine (carries seam-stamp.mjs + seam-gate.mjs)
- cut-the-curve, captions-overlay, seam-craft, oversized-cursor

Layout:
- .claude/skills/  - Claude Code project-local auto-discover
- .agents/skills/  - Codex CLI project-local auto-discover (verified via
                     Magi's clean-home Codex 0.144.3 repro; NOT .codex/skills/)

Fonts, animated background (12 MB), house BGM (5 MB), lexicon, and
align-captions ship inside the skill dirs. .gitattributes routes only
.claude/skills/**/*.{mp4,mp3} + .agents/skills/**/*.{mp4,mp3} through
LFS — narrowly scoped so unrelated Player, Studio, registry, and
marketplace media stay put. HeyGen CLI auth is the one credential the
skill needs; Node >= 22, ffmpeg, and headless Chrome are documented
alongside in both READMEs.

.gitignore: rewrites .claude/ and .agents/ blocks to keep agent-installed
skill hygiene while re-including the six repo-native skill dirs plus
README.md.

CI:
- Extends changes.skills filter to match .claude/skills/**,
  .agents/skills/**, scripts/lint-skills.ts, and scripts/check-skill-mirror.mjs.
- New 'Skills: project-native lint + mirror' job runs the extended
  lint-skills.ts (schema-driven; required { name, description } + optional
  { license, allowed-tools, metadata }, name pattern check, description
  length check) plus a new check-skill-mirror.mjs byte-integrity script
  (24 mirrored files must match; README.md deliberately per-CLI).
- Wired into 'bun run lint' locally.

Frontmatter validator:
- Rejects unsupported top-level keys (catches category:-style drift).
- Requires name + description.
- Validates name pattern (^[a-z][a-z0-9-]{0,63}$) and description shape
  (non-empty, <=1024 chars).
- Missing frontmatter block itself is a first-class error.

Also strips unsupported top-level 'category:' frontmatter from Jake's
motion-doctrine and cut-the-curve SKILL.mds (both mirrors), rewrites the
TTS invocation from ~/.claude/skills/media-use/... to the tracked
skills/hyperframes-media/scripts/heygen-tts.mjs, swaps npx hyperframes@latest
for the repo-local CLI in the gate step, and fixes a lint issue in Jake's
seam-gate.mjs (ternary-for-side-effect -> if/else).

Validated end-to-end by Home on Jun 23-29 (MP4 posted in C0ACCNHLG3U
thread 1784181166.041319). Independently reviewed R1/R2/R3 by Magi.

Co-authored-by: Jake Moran <jake@heygen.com>
2026-07-16 17:29:19 -04:00

81 lines
2.5 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Verifies .claude/skills/ and .agents/skills/ are byte-identical mirrors.
//
// The two dirs deliver the same skill set to Claude Code and Codex CLI
// respectively (each CLI reads only its own path). They must stay in lockstep
// or one CLI will silently ship a stale skill. This check enforces that
// invariant at CI time.
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { createHash } from "node:crypto";
const REPO_ROOT = join(import.meta.dirname, "..");
const A = join(REPO_ROOT, ".claude", "skills");
const B = join(REPO_ROOT, ".agents", "skills");
// The two top-level READMEs deliberately differ (one addresses CC users, one
// addresses Codex CLI users). Skill CONTENT must mirror; per-CLI docs need not.
const MIRROR_EXCLUDE = new Set(["README.md"]);
function collectFile(dir, entry, base) {
if (!entry.isFile()) return [];
const rel = relative(base, join(dir, entry.name));
if (MIRROR_EXCLUDE.has(rel)) return [];
return [rel];
}
function walk(dir, base) {
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return [];
const entries = readdirSync(dir, { withFileTypes: true });
const out = entries.flatMap((entry) =>
entry.isDirectory() ? walk(join(dir, entry.name), base) : collectFile(dir, entry, base),
);
return out.sort();
}
function hashFile(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
const aFiles = walk(A, A);
const bFiles = walk(B, B);
const problems = [];
const aSet = new Set(aFiles);
const bSet = new Set(bFiles);
for (const rel of aFiles) {
if (!bSet.has(rel)) {
problems.push(`only in .claude/skills/: ${rel}`);
}
}
for (const rel of bFiles) {
if (!aSet.has(rel)) {
problems.push(`only in .agents/skills/: ${rel}`);
}
}
for (const rel of aFiles) {
if (!bSet.has(rel)) continue;
const aHash = hashFile(join(A, rel));
const bHash = hashFile(join(B, rel));
if (aHash !== bHash) {
problems.push(
`content differs: ${rel} (.claude=${aHash.slice(0, 8)} .agents=${bHash.slice(0, 8)})`,
);
}
}
if (problems.length > 0) {
console.error("Skill mirror out of sync between .claude/skills/ and .agents/skills/:\n");
for (const p of problems) console.error(` ${p}`);
console.error("\nRebuild the mirror: cp -r .claude/skills/. .agents/skills/ (or vice-versa)");
process.exit(1);
}
console.log(
`Skill mirror OK: ${aFiles.length} file(s) match byte-for-byte across .claude/skills/ and .agents/skills/.`,
);