#!/usr/bin/env node /** * Sync `skills/` from an obra/superpowers checkout, re-applying this port's * DSH adaptations. * * Vendored from LayneChai/superpowers-dsh `scripts/sync-from-upstream.mjs` * (MIT), adapted for claude-harness: * * - no `package.json` binding: the upstream checkout and the destination * come from argv (``, `--out`), not from the package; * - the DSH-only override is copied from `dsh/bundle/dsh-tools.md` over the * synced tree as `using-superpowers/references/dsh-tools.md` (resolved * from `import.meta.url`, never from cwd); * - the upstream version is recorded in `/.sync-meta.json` together * with the pinned ref, instead of being compared to package.json; * - the destination is replaced by a staging tree only after every * assertion passed, so a broken upstream never leaves a half-synced * `--out` behind. * * The upstream skills are the source of truth for what a skill says. This * port ships a small, explicit, asserted set of deltas: * * 1. `dsh/bundle/dsh-tools.md` replaces the per-harness reference files * upstream ships for Claude Code, Codex, Pi, Antigravity, Hermes and * Muse. * 2. The transforms below rewrite upstream text that names another harness * and drop the Claude-Code-only subagent stop gate. * 3. Every remaining `superpowers:` namespace prefix is stripped: the DSH * skill registry addresses skills by bare name. * * A missing anchor fails the run loudly: upstream rewrote text this port * depends on, so a human must decide what the DSH wording becomes. * * Usage: * node dsh/sync/sync-from-upstream.mjs [--out ] [--ref ] * * Defaults: `--out /.sync-out`, `--ref unknown`. * Staging tree: `.tmp-`; it is removed on any failure and only * renamed onto `` after all assertions pass. * * @module dsh/sync/sync-from-upstream */ import { rmSync } from 'node:fs' import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises' import { dirname, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' const scriptDir = dirname(fileURLToPath(import.meta.url)) /** The single source of the DSH tool mapping: `dsh/sync/..` = `dsh/`. */ const dshToolsOverride = resolve(scriptDir, '..', 'bundle', 'dsh-tools.md') /** Path of the override inside the synced skill tree. */ const OVERRIDE_TARGET = 'using-superpowers/references/dsh-tools.md' const UPSTREAM_SKILLS = [ 'brainstorming', 'diagnosing-superpowers', 'dispatching-parallel-agents', 'executing-plans', 'finishing-a-development-branch', 'receiving-code-review', 'requesting-code-review', 'subagent-driven-development', 'systematic-debugging', 'test-driven-development', 'using-git-worktrees', 'using-superpowers', 'verification-before-completion', 'writing-plans', 'writing-skills' ] /** Per-harness reference files upstream v7.0.1 ships that dsh-tools.md replaces. */ const SKIP = [ 'using-superpowers/references/antigravity-tools.md', 'using-superpowers/references/claude-code-tools.md', 'using-superpowers/references/codex-tools.md', 'using-superpowers/references/gemini-tools.md', 'using-superpowers/references/hermes-tools.md', 'using-superpowers/references/muse-tools.md', 'using-superpowers/references/pi-tools.md' ] /** Optional manifest of a reduced checkout (test fixtures); absent upstream. */ const FIXTURE_MANIFEST = '.sync-fixture.json' /** * Explicit rewrites applied before the namespace strip. Each entry must match * exactly `expect` times, or the sync aborts. */ const TRANSFORMS = [ { name: 'using-superpowers: drop the upstream subagent stop gate', file: 'using-superpowers/SKILL.md', find: '\nIf you were dispatched as a subagent to execute a specific task, ignore this skill.\n\n\n', replace: '' }, { name: 'using-superpowers: DSH platform reference replaces the harness list', file: 'using-superpowers/SKILL.md', find: [ 'If your harness appears here, read its reference file for special instructions:', '', '- Claude Code: `references/claude-code-tools.md`', '- Codex: `references/codex-tools.md`', '- Pi: `references/pi-tools.md`', '- Antigravity: `references/antigravity-tools.md`', '- Hermes Agent: `references/hermes-tools.md`', '- Muse: `references/muse-tools.md`' ].join('\n'), replace: [ 'This package is the DeepSeek Harness port. Its platform reference is', '`references/dsh-tools.md`: the Claude-Code → DSH tool mapping plus the', 'DSH-specific mechanisms these skills rely on (`skill`, `subagent` /', '`subagent_fork`, `workflow`, `goal`, and the Windows `pwsh` notes).', '', '## Invoking skills on DSH', '', 'Call the `skill` tool with the exact skill name from the catalog before', 'acting on a task that names or clearly matches that skill:', '', '- Load applicable skills FIRST, then follow their full instructions.', '- Skills are addressable by bare name: `brainstorming`, `writing-plans`,', ' `systematic-debugging`, `test-driven-development`, `using-git-worktrees`,', ' `subagent-driven-development`, `dispatching-parallel-agents`, and the rest.', '- `using-superpowers` itself is discoverable the same way.' ].join('\n') } ] const TEXT_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.js', '.cjs', '.mjs', '.ts', '.sh', '.dot', '.html']) const USAGE = 'usage: node dsh/sync/sync-from-upstream.mjs [--out ] [--ref ]' /** Staging tree, set once the destination is known. */ let staging = null function fail(message) { if (staging) rmSync(staging, { recursive: true, force: true }) console.error(`\n[FAIL] ${message}\n`) process.exit(1) } function usage(message) { if (message) console.error(`[FAIL] ${message}\n`) console.error(USAGE) process.exit(message ? 2 : 0) } /** @returns {{upstream: string, out: string, ref: string}} */ function parseArgs(argv) { let upstream = null let out = null let ref = null for (let i = 0; i < argv.length; i++) { const arg = argv[i] if (arg === '--out' || arg === '--ref') { const value = argv[++i] if (!value) usage(`missing value for ${arg}`) if (arg === '--out') out = value else ref = value } else if (arg === '--help' || arg === '-h') { usage() } else if (arg.startsWith('--')) { usage(`unknown option ${arg}`) } else if (upstream === null) { upstream = arg } else { usage(`unexpected argument ${arg}`) } } if (!upstream) usage('missing ') const upstreamRoot = resolve(upstream) return { upstream: upstreamRoot, out: out === null ? join(upstreamRoot, '.sync-out') : resolve(out), ref: ref === null ? 'unknown' : ref } } /** @returns {Promise} repo-relative file paths under `root`. */ async function walk(root, current = root) { const out = [] for (const entry of await readdir(current, { withFileTypes: true })) { const full = join(current, entry.name) if (entry.isDirectory()) out.push(...(await walk(root, full))) else out.push(relative(root, full).split(sep).join('/')) } return out } function isTextFile(rel) { const dot = rel.lastIndexOf('.') const slash = rel.lastIndexOf('/') if (dot <= slash) return true return TEXT_EXTENSIONS.has(rel.slice(dot)) } /** Apply every transform whose `file` matches, asserting the anchor exists. */ function applyTransforms(rel, text, applied) { for (const t of TRANSFORMS) { if (t.file !== rel) continue if (t.find === t.replace) continue const count = text.split(t.find).length - 1 if (count === 0) fail(`anchor missing in ${rel} for "${t.name}"\n find: ${JSON.stringify(t.find.slice(0, 120))}`) if (count > 1) fail(`anchor matched ${count} times in ${rel} for "${t.name}"`) text = text.replace(t.find, t.replace) applied.add(t.name) } return text } /** * A real obra/superpowers checkout ships the 15 skills above; the minimal test * fixture ships two and declares them in `.sync-fixture.json`. The skill-set * assertion runs in both cases, against the declared set when one exists. * * @returns {Promise<{expected: string[], source: string}>} */ async function expectedSkillNames(upstreamRoot) { try { const manifest = JSON.parse(await readFile(join(upstreamRoot, FIXTURE_MANIFEST), 'utf8')) if (Array.isArray(manifest.expectedSkills) && manifest.expectedSkills.length > 0) { return { expected: manifest.expectedSkills, source: FIXTURE_MANIFEST } } } catch { /* real checkouts have no fixture manifest */ } return { expected: UPSTREAM_SKILLS, source: 'UPSTREAM_SKILLS' } } async function main() { const args = parseArgs(process.argv.slice(2)) const upstreamRoot = args.upstream const upstreamSkills = join(upstreamRoot, 'skills') const upstreamStats = await stat(upstreamSkills).catch(() => null) if (!upstreamStats?.isDirectory()) { fail(`no skills/ directory in ${upstreamRoot}`) } // A new (or removed) upstream skill must be a deliberate decision: it // changes the skill catalog, the README tables, and possibly the DSH // adaptations. const { expected, source } = await expectedSkillNames(upstreamRoot) const upstreamSkillDirs = (await readdir(upstreamSkills, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort() const expectedSorted = [...expected].sort() if (upstreamSkillDirs.join('\n') !== expectedSorted.join('\n')) { const added = upstreamSkillDirs.filter((name) => !expectedSorted.includes(name)) const removed = expectedSorted.filter((name) => !upstreamSkillDirs.includes(name)) fail( `the upstream skill set changed (expected ${expectedSorted.length} skills from ${source}) — decide before syncing:\n` + (added.length > 0 ? ` added upstream: ${added.join(', ')}\n` : '') + (removed.length > 0 ? ` removed upstream: ${removed.join(', ')}\n` : '') + ' Update UPSTREAM_SKILLS in this script and the README skill tables.' ) } // Report the upstream version so the destination can record it. let upstreamVersion = 'unknown' try { const manifest = JSON.parse(await readFile(join(upstreamRoot, '.claude-plugin', 'plugin.json'), 'utf8')) upstreamVersion = manifest.version ?? 'unknown' } catch { /* version is informational only */ } const overrideStats = await stat(dshToolsOverride).catch(() => null) if (!overrideStats?.isFile()) { fail(`missing DSH override ${dshToolsOverride} (dsh/bundle/dsh-tools.md)`) } staging = `${args.out}.tmp-${process.pid}` await rm(staging, { recursive: true, force: true }) await mkdir(staging, { recursive: true }) const applied = new Set() let copied = 0 let stripped = 0 for (const rel of await walk(upstreamSkills)) { if (SKIP.includes(rel)) continue const sourceFile = join(upstreamSkills, rel) const target = join(staging, rel) await mkdir(dirname(target), { recursive: true }) if (!isTextFile(rel)) { await cp(sourceFile, target) copied++ continue } let text = await readFile(sourceFile, 'utf8') text = applyTransforms(rel, text, applied) const namespaced = text.split('superpowers:').length - 1 if (namespaced > 0) { text = text.replaceAll('superpowers:', '') stripped += namespaced } await writeFile(target, text, 'utf8') copied++ } // The DSH-only file replaces upstream text wholesale. const overrideTarget = join(staging, OVERRIDE_TARGET) await mkdir(dirname(overrideTarget), { recursive: true }) await cp(dshToolsOverride, overrideTarget) copied++ // Every transform that names a file must have fired; one that silently // stopped matching would drop a DSH adaptation from the next release's sync. const unfired = TRANSFORMS.filter((t) => t.find !== t.replace && !applied.has(t.name)) if (unfired.length > 0) { fail(`transform(s) never matched a copied file: ${unfired.map((t) => `${t.name} (${t.file})`).join('; ')}`) } await writeFile( join(staging, '.sync-meta.json'), JSON.stringify({ ref: args.ref, version: upstreamVersion }, null, 2) + '\n', 'utf8' ) // Everything applied: swap the staging tree in. await mkdir(dirname(args.out), { recursive: true }) await rm(args.out, { recursive: true, force: true }) await rename(staging, args.out) staging = null console.log(`synced ${copied} files from obra/superpowers ${upstreamVersion} (ref ${args.ref}) into ${args.out}`) console.log(` namespace prefixes stripped: ${stripped}`) console.log(` transforms applied: ${applied.size}`) console.log(` dsh override: ${relative(process.cwd(), dshToolsOverride) || dshToolsOverride} -> ${OVERRIDE_TARGET}`) } main().catch((error) => { if (staging) rmSync(staging, { recursive: true, force: true }) console.error(`\n[FAIL] unexpected error: ${error?.stack ?? error}\n`) process.exit(1) })