Files
sim/scripts/sync-skills.ts
T
Theodore LiandClaude Opus 4.8 66d1e61beb feat(skills): canonicalize skills to a single source with generated .claude/.cursor projections (#5609)
* improvement(cleanup-skill): parallelize analysis, apply fixes sequentially

* improvement(cleanup-skill): add comment-reduction pass; mirror 6 missing skills into .claude/commands

* fix(cleanup-skill): substitute parsed scope into analysis passes instead of literal <scope>

* fix(cleanup-skill): parse fix token anywhere; preserve pass labels through convergence for ordered apply

* fix(cleanup-skill): apply Step 1 proposals content-anchored, re-derive when a prior pass invalidated the snippet

* fix(babysit-skill): correct garbled --reverse explanation across all three copies

* fix(skills): propagate parallel cleanup to cursor/agents copies; disambiguate babysit /ship refs in claude copy

* fix(skills): port url-state + comment passes to cursor/agents; clarify converge pass-label ordering

* feat(skills): canonicalize skills under .agents/skills with generated .claude/.cursor projections

Establish .agents/skills/<name>/SKILL.md as the single source of truth (latest
content reconciled per skill from the three drifted copies), and generate the
.claude/commands and .cursor/commands projections from it via scripts/sync-skills.ts.
Adds skills:sync/skills:check, a CI gate, a pre-commit regen hook, and CONTRIBUTING docs.
Structurally fixes prior drift (e.g. abbreviated .claude ship -> full ship).

* fix(skills): strip leaked XML tags from skill tails; clarify lint:check has no per-file target

Removes stray </content>/</invoke> markup that leaked into add-block,
add-connector, add-hosted-key canonical skills, and reword the cleanup
skill's lint step to note bun run lint:check runs repo-wide via turbo
(no per-path API). Projections regenerated via skills:sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjefwescJoHZ6zcc3C17FR

* fix(add-block-skill): restore unknown-output stop in Final Validation

Re-add the "if any tool outputs are still unknown, tell the user instead
of guessing block outputs" step that was dropped when Final Validation
step 5 became the BlockMeta template check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjefwescJoHZ6zcc3C17FR

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:12:11 -04:00

127 lines
4.4 KiB
TypeScript

/**
* Generate the per-tool skill/command projections from the canonical skill
* sources so the three copies can never drift again.
*
* Canonical source of truth: `.agents/skills/<name>/SKILL.md`
* frontmatter: `name`, `description`, optional `argument-hint`; then the body.
*
* Projections (generated — do not hand-edit):
* - `.claude/commands/<name>.md` — frontmatter `description` (+ `argument-hint`), then the body.
* - `.cursor/commands/<name>.md` — no frontmatter, body only.
*
* The body is copied verbatim; cross-skill references intentionally point at the
* canonical `.agents/skills/<x>/SKILL.md` from every projection, so a reader is
* always sent to the single source of truth.
*
* Usage:
* bun run scripts/sync-skills.ts # write projections
* bun run scripts/sync-skills.ts --check # fail (exit 1) if any projection is stale
*/
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SCRIPT_DIR, '..')
const CANONICAL_DIR = resolve(ROOT, '.agents/skills')
const CLAUDE_DIR = resolve(ROOT, '.claude/commands')
const CURSOR_DIR = resolve(ROOT, '.cursor/commands')
interface Skill {
name: string
frontmatter: string[]
body: string
}
/** Split a SKILL.md into its ordered frontmatter lines and the body. */
function parseSkill(name: string, raw: string): Skill {
if (!raw.startsWith('---\n')) {
throw new Error(`${name}: SKILL.md must start with a '---' frontmatter block`)
}
const end = raw.indexOf('\n---\n', 4)
if (end === -1) throw new Error(`${name}: unterminated frontmatter block`)
const frontmatter = raw.slice(4, end).split('\n')
const body = raw
.slice(end + 5)
.replace(/^\n+/, '')
.trimEnd()
return { name, frontmatter, body }
}
/** Return the first frontmatter line whose key matches, verbatim, or undefined. */
function line(skill: Skill, key: string): string | undefined {
return skill.frontmatter.find((l) => l.startsWith(`${key}:`))
}
function claudeProjection(skill: Skill): string {
const desc = line(skill, 'description')
if (!desc) throw new Error(`${skill.name}: missing 'description' in frontmatter`)
const hint = line(skill, 'argument-hint')
const fm = [desc, ...(hint ? [hint] : [])]
return `---\n${fm.join('\n')}\n---\n\n${skill.body}\n`
}
function cursorProjection(skill: Skill): string {
return `${skill.body}\n`
}
async function loadCanonicalSkills(): Promise<Skill[]> {
const entries = await readdir(CANONICAL_DIR, { withFileTypes: true })
const names = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort()
const skills: Skill[] = []
for (const name of names) {
const raw = await readFile(resolve(CANONICAL_DIR, name, 'SKILL.md'), 'utf8')
const skill = parseSkill(name, raw)
const nameLine = line(skill, 'name')
if (nameLine !== `name: ${name}`) {
throw new Error(`${name}: frontmatter 'name' must equal the directory name`)
}
skills.push(skill)
}
return skills
}
async function main() {
const check = process.argv.includes('--check')
const skills = await loadCanonicalSkills()
const targets = skills.flatMap((skill) => [
{ path: resolve(CLAUDE_DIR, `${skill.name}.md`), content: claudeProjection(skill) },
{ path: resolve(CURSOR_DIR, `${skill.name}.md`), content: cursorProjection(skill) },
])
const stale: string[] = []
for (const { path, content } of targets) {
const current = await readFile(path, 'utf8').catch(() => null)
if (current === content) continue
stale.push(path.replace(`${ROOT}/`, ''))
if (!check) await writeFile(path, content)
}
if (check) {
if (stale.length > 0) {
console.error(
`✗ ${stale.length} skill projection(s) are stale — run \`bun run skills:sync\`:\n` +
stale.map((p) => ` ${p}`).join('\n')
)
process.exit(1)
}
console.log(`✓ ${skills.length} skills in sync across .claude/commands and .cursor/commands`)
return
}
console.log(
stale.length === 0
? `✓ ${skills.length} skills already in sync — nothing to write`
: `✓ regenerated ${stale.length} projection(s) from ${skills.length} canonical skills`
)
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err)
process.exit(1)
})