#!/usr/bin/env node // agent-sync — installs the bundled skills and agent prompts into the canonical // user-level directory (~/.agents) and links the skills into every detected AI // agent's own directory (same architecture as the vercel-labs/skills CLI). // Agent prompts are copied to ~/.agents/agents (file links on windows require // privileges) and, for claude, inlined into the existing wrappers below their // frontmatter. // Selection is explicit (--all, --skills, --agents, --targets): without a // selection flag the tool prints its help. See HELP for the full surface. import { createHash } from 'node:crypto'; import { cpSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs'; import { homedir, platform } from 'node:os'; import { basename, dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; const EXIT_OK = 0; const EXIT_FAILURE = 1; const EXIT_USAGE = 2; const IS_WINDOWS = platform() === 'win32'; const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const SOURCE_SKILLS_DIR = join(PACKAGE_ROOT, 'skills'); const SOURCE_AGENTS_DIR = join(PACKAGE_ROOT, 'agents'); const SOURCE_SYSTEM_PROMPT = join(PACKAGE_ROOT, 'SYSTEM_PROMPT.md'); const CANONICAL_SKILLS_DIR = join(homedir(), '.agents', 'skills'); const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents'); const HELP = `agent-sync — installs bundled skills and agent prompts into the canonical ~/.agents directory and links them into the AI agents detected on the machine. Usage: agent-sync --all [--check] [--targets []] agent-sync --skills [] [--agents []] [--sys] [--check] [--targets []] agent-sync --list agent-sync --help A selection flag without a value covers its whole dimension: --skills alone selects every bundled skill, --agents alone every prompt, --sys the system prompt, --targets alone every detected agent. With a comma-separated value only the listed names are used. Flags combine: --skills commit --targets claude,pi syncs just the commit skill into Claude Code and pi. Without any selection flag agent-sync prints this help. The --sys dimension installs the repository-level SYSTEM_PROMPT.md as the global system prompt of every detected agent, renamed to each target's standard filename (CLAUDE.md, APPEND_SYSTEM.md, AGENTS.md, global_rules.md). It is always copied, never linked, and never stored in ~/.agents. The --agents dimension also inlines each prompt body into the existing Claude Code wrappers (~/.claude/agents/.md), keeping their frontmatter. --check dry-run, reports drift without writing anything (exit 1 on drift) Exit codes: ${EXIT_OK} ok or help, ${EXIT_FAILURE} drift (dry-run) or failure, ${EXIT_USAGE} usage error.`; // Resolves the Claude Code config dir, honoring CLAUDE_CONFIG_DIR when set. function claudeBase() { return process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude'); } // Resolves a fixed directory under the user home (lazy, re-evaluated per run). function homeDir(dir) { return () => join(homedir(), dir); } // Supported AI agents: base() proves installation when the directory exists, // skillsSub/agentsSub are the subdirectories receiving our links. Agent prompts // are linked only where the convention is verified (claude); pi gets a minimal // manifest instead (see writePiManifests) because its manifests carry // per-machine config such as model and thinking. // mergeBody: existing wrappers under agentsSub keep their frontmatter and get // the bundled prompt body inlined. // sys is the --sys destination relative to base(): the bundled SYSTEM_PROMPT.md // is copied there (renamed, plain copy, never linked). null = no file-based // global prompt (cursor keeps its user rules only in the app settings). // opencode and windsurf read their config outside the install dir, hence the // ~/.config/opencode and ~/.codeium/windsurf bases. const AGENT_REGISTRY = [ { name: 'claude', base: claudeBase, skillsSub: 'skills', agentsSub: 'agents', mergeBody: true, sys: { file: 'CLAUDE.md' } }, { name: 'pi', base: homeDir(join('.pi', 'agent')), skillsSub: 'skills', agentsSub: 'agents', sys: { file: 'APPEND_SYSTEM.md' } }, { name: 'codex', base: homeDir('.codex'), skillsSub: 'skills', sys: { file: 'AGENTS.md' } }, { name: 'opencode', base: homeDir(join('.config', 'opencode')), skillsSub: 'skills', sys: { file: 'AGENTS.md' } }, { name: 'cursor', base: homeDir('.cursor'), skillsSub: 'skills', sys: null }, { name: 'trae', base: homeDir('.trae'), skillsSub: 'skills', sys: null }, { name: 'windsurf', base: homeDir(join('.codeium', 'windsurf')), skillsSub: 'skills', sys: { file: join('memories', 'global_rules.md') } }, { name: 'aider-desk', base: homeDir('.aider-desk'), skillsSub: 'skills', sys: null }, ]; // Prints a usage error and exits with the usage exit code. function fail(message) { console.error(`${message}\nRun 'agent-sync --help' for usage.`); process.exit(EXIT_USAGE); } // Returns the names of the bundled skills (directories under skills/). function bundledSkillNames() { return readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); } // Returns the file names of the bundled agent prompts (*.md under agents/). function bundledAgentFiles() { return readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md')); } // Dimension flags hold three states: null (flag absent), true (whole // dimension) or an array of names. A repeated flag accumulates names, and a // valueless occurrence widens the dimension to everything. function mergeDimension(current, value) { if (current === true || value === null) return true; return [...(current ?? []), ...value]; } const DIMENSION_FLAGS = { '--skills': 'skills', '--agents': 'agents', '--targets': 'targets', '--sys': 'sys' }; // Parses the command line into a run plan (syntax only: names are validated // later against the bundled content). Selection flags are --all, --skills, // --agents, --targets and --sys; every other flag is a modifier. function parseArgs(argv) { const plan = { help: false, list: false, dryRun: false, all: false, skills: null, agents: null, targets: null, sys: null }; const optionalList = (index, flag) => { const value = argv[index + 1]; if (!value || value.startsWith('--')) return null; const names = value.split(',').map((name) => name.trim()).filter(Boolean); if (names.length === 0) fail(`error: ${flag} received an empty list of names`); return names; }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--help' || arg === '-h') plan.help = true; else if (arg === '--list') plan.list = true; else if (arg === '--check') plan.dryRun = true; else if (arg === '--all') plan.all = true; else if (DIMENSION_FLAGS[arg]) { const key = DIMENSION_FLAGS[arg]; const value = optionalList(i, arg); if (key === 'sys' && value !== null) fail('error: --sys takes no value'); plan[key] = mergeDimension(plan[key], value); if (value !== null) i += 1; } else fail(`error: unknown argument '${arg}'`); } return plan; } // Validates the requested names against the bundled content and the agent // registry, then resolves the effective selection. A dimension set to true // expands to everything; prompt names are given without extension (gv-dev) // and mapped to their bundled files (gv-dev.md). function resolveSelection(plan) { const skillNames = bundledSkillNames(); const agentFiles = bundledAgentFiles(); const registryNames = AGENT_REGISTRY.map((agent) => agent.name); const pickSkill = (name) => { if (!skillNames.includes(name)) fail(`error: unknown skill '${name}' (available: ${skillNames.join(', ')})`); return name; }; const pickAgent = (name) => { const file = agentFiles.find((candidate) => basename(candidate, '.md') === name); if (!file) fail(`error: unknown agent '${name}' (available: ${agentFiles.map((f) => basename(f, '.md')).join(', ')})`); return file; }; const pickTarget = (name) => { if (!registryNames.includes(name)) fail(`error: unknown target '${name}' (available: ${registryNames.join(', ')})`); return name; }; return { skills: plan.all || plan.skills === true ? skillNames : (plan.skills ?? []).map(pickSkill), agents: plan.all || plan.agents === true ? agentFiles : (plan.agents ?? []).map(pickAgent), targets: plan.targets === true || !plan.targets ? null : plan.targets.map(pickTarget), sys: Boolean(plan.all || plan.sys), }; } // Prints the bundled catalog plus the AI agents detected on this machine. function printCatalog() { const detected = AGENT_REGISTRY.filter((agent) => existsSync(agent.base())).map((agent) => agent.name); console.log(`skills: ${bundledSkillNames().join(', ')}`); console.log(`agents: ${bundledAgentFiles().map((file) => basename(file, '.md')).join(', ')}`); console.log(`targets: ${AGENT_REGISTRY.map((agent) => `${agent.name}${detected.includes(agent.name) ? '*' : ''}`).join(', ')} (* = detected)`); } // Returns the SHA-256 hex digest of a file, or null when the file does not exist. function fileHash(filePath) { if (!existsSync(filePath)) return null; return createHash('sha256').update(readFileSync(filePath)).digest('hex'); } // Returns a stable digest of a directory: sorted list of "relative path:hash" // for every file, so any content or structure change alters the result. function dirHash(dirPath) { if (!existsSync(dirPath)) return null; const parts = []; const walk = (dir, prefix) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) walk(full, `${prefix}${entry.name}/`); else parts.push(`${prefix}${entry.name}:${fileHash(full)}`); } }; walk(dirPath, ''); return createHash('sha256').update(parts.sort().join('\n')).digest('hex'); } // Returns the lstat of a path, or null when it does not exist (broken links included). function lstatOrNull(path) { try { return lstatSync(path); } catch { return null; } } // Tells whether the entry at linkPath is a symlink resolving to targetPath. function pointsTo(linkPath, targetPath) { try { return realpathSync(linkPath) === realpathSync(targetPath); } catch { return false; } } // Ensures the canonical agents directory exists as a real directory receiving // the copied prompt files. An earlier version linked the whole folder with a // junction: such a link to our own source is unwound here (removing the // junction never touches the source files). function ensureCanonicalAgentsDir() { const current = lstatOrNull(CANONICAL_AGENTS_DIR); if (!current) { if (DRY_RUN) return { label: 'agents', status: 'link' }; mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true }); return { label: 'agents', status: 'added' }; } if (current.isSymbolicLink()) { if (DRY_RUN) return { label: 'agents', status: 'relink' }; rmSync(CANONICAL_AGENTS_DIR, { recursive: true, force: true }); mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true }); return { label: 'agents', status: 'relinked' }; } return current.isDirectory() ? { label: 'agents', status: 'up-to-date' } : { label: 'agents', status: 'skipped' }; } // Copies the selected skills into the canonical directory and copies the // agent prompts into ~/.agents/agents (files on windows cannot be linked // without privileges, so agents use plain copies). Returns the list of actions. function syncCanonical(skills, agentFiles) { const actions = []; for (const name of skills) { const source = join(SOURCE_SKILLS_DIR, name); const dest = join(CANONICAL_SKILLS_DIR, name); const status = dirHash(source) === dirHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added'; if (status !== 'up-to-date' && !DRY_RUN) { rmSync(dest, { recursive: true, force: true }); cpSync(source, dest, { recursive: true }); } actions.push({ label: `skills/${name}`, status }); } for (const file of agentFiles) { const source = join(SOURCE_AGENTS_DIR, file); const dest = join(CANONICAL_AGENTS_DIR, file); const status = fileHash(source) === fileHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added'; if (status !== 'up-to-date' && !DRY_RUN) { copyFileSync(source, dest); } actions.push({ label: `agents/${file}`, status }); } return actions; } // Detects installed agents by probing their config directory, optionally // restricted to the requested target names. function detectAgents(targetNames) { return AGENT_REGISTRY .filter((agent) => (!targetNames || targetNames.includes(agent.name)) && existsSync(agent.base())) .map((agent) => ({ ...agent, baseDir: agent.base() })); } // Ensures linkPath points to targetPath via a symlink: junctions for // directories on windows (no privileges needed), file symlinks everywhere // else. There is no fallback: if the symlink cannot be created (EPERM without // Developer Mode/privileges, cross-filesystem restrictions) the sync aborts // with an explicit error — copies are never made. A pre-existing regular file // is a leftover from older copy/hardlink installs and is replaced by the // symlink; directories or foreign symlinks are skipped, never touched. function ensureLink(linkPath, targetPath) { const current = lstatOrNull(linkPath); if (current) { if (current.isSymbolicLink() && pointsTo(linkPath, targetPath)) return 'up-to-date'; if (current.isFile()) { if (DRY_RUN) return 'relink'; rmSync(linkPath, { force: true }); } else { return 'skipped'; } } if (DRY_RUN) return 'link'; mkdirSync(dirname(linkPath), { recursive: true }); const isDirectory = statSync(targetPath).isDirectory(); try { if (IS_WINDOWS && isDirectory) symlinkSync(targetPath, linkPath, 'junction'); else symlinkSync(relative(dirname(linkPath), targetPath), linkPath); return 'linked'; } catch (error) { throw new Error(`cannot create symlink '${linkPath}' (no fallback, no copy: ${error.message})`); } } // Links the selected skills (source package names only, so third-party // content in the canonical directory is never propagated) into one detected // agent's directories. Agent prompts are deliberately not linked here: they // are copied to the canonical ~/.agents/agents directory and, for claude, // inlined into the existing wrappers below their frontmatter (see mergeAgentBodies). function linkForAgent(agent, skills) { const actions = []; for (const name of skills) { actions.push({ label: `${agent.name}:skills/${name}`, status: ensureLink(join(agent.baseDir, agent.skillsSub, name), join(CANONICAL_SKILLS_DIR, name)), }); } return actions; } // Inlines each prompt body into the existing wrapper below its frontmatter (mergeBody agents only). function mergeAgentBodies(agent, agentFiles) { if (!agent.mergeBody) return []; const actions = []; for (const file of agentFiles) { const dest = join(agent.baseDir, agent.agentsSub, file); const label = `${agent.name}:agents/${file}`; // Wrappers carry per-machine config (model, hooks), so they are never created. if (!existsSync(dest)) { actions.push({ label, status: 'skipped' }); continue; } const current = readFileSync(dest, 'utf8'); const match = current.match(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/); if (!match) { actions.push({ label, status: 'skipped' }); continue; } const merged = `${match[0].trimEnd()}\n\n${readFileSync(join(SOURCE_AGENTS_DIR, file), 'utf8').trim()}\n`; const status = merged === current ? 'up-to-date' : 'merged'; if (status === 'merged' && !DRY_RUN) writeFileSync(dest, merged); actions.push({ label, status }); } return actions; } // Copies the bundled SYSTEM_PROMPT.md into every detected agent that supports // a global system prompt (sys mapping), renamed to its standard filename and // always as a plain copy (never linked, never through ~/.agents). function syncSystemPrompts(detected, systemPrompt) { const actions = []; const expectedHash = createHash('sha256').update(systemPrompt).digest('hex'); for (const agent of detected) { if (!agent.sys) continue; const dest = join(agent.base(), agent.sys.file); const status = expectedHash === fileHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added'; if (status !== 'up-to-date' && !DRY_RUN) { mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, systemPrompt); } actions.push({ label: `${agent.name}:sys/${basename(agent.sys.file)}`, status }); } return actions; } // Orchestrates the sync and prints a per-entry report with a final summary. function main() { if (PLAN.help) { console.log(HELP); process.exit(EXIT_OK); } if (!existsSync(SOURCE_SKILLS_DIR) || !existsSync(SOURCE_AGENTS_DIR)) { console.error(`error: bundled content not found under ${PACKAGE_ROOT}`); process.exit(EXIT_FAILURE); } const hasContent = PLAN.all || PLAN.skills || PLAN.agents || PLAN.sys; if ((PLAN.all || PLAN.sys) && !existsSync(SOURCE_SYSTEM_PROMPT)) { console.error(`error: SYSTEM_PROMPT.md not found under ${PACKAGE_ROOT}`); process.exit(EXIT_FAILURE); } if (PLAN.list && (hasContent || PLAN.targets)) fail('error: --list cannot be combined with selection flags'); if (PLAN.all && (PLAN.skills || PLAN.agents || PLAN.sys)) fail('error: --all cannot be combined with --skills, --agents or --sys'); if (PLAN.targets && !hasContent) fail('error: --targets requires --all, --skills, --agents or --sys'); if (PLAN.list) { printCatalog(); process.exit(EXIT_OK); } if (!hasContent) { console.log(HELP); process.exit(EXIT_OK); } const { skills, agents: agentFiles, targets, sys } = resolveSelection(PLAN); const detected = detectAgents(targets); if (targets && detected.length === 0) { fail(`error: none of the requested targets is installed (${targets.join(', ')})`); } if ((skills.length > 0 || PLAN.all) && !DRY_RUN) { mkdirSync(CANONICAL_SKILLS_DIR, { recursive: true }); } const report = [...(agentFiles.length > 0 ? [ensureCanonicalAgentsDir()] : []), ...syncCanonical(skills, agentFiles)]; if (sys) { report.push(...syncSystemPrompts(detected, readFileSync(SOURCE_SYSTEM_PROMPT, 'utf8'))); } for (const agent of detected) { report.push(...linkForAgent(agent, skills)); report.push(...mergeAgentBodies(agent, agentFiles)); } for (const { label, status } of report) { console.log(`${status.padEnd(12)} ${label}`); } console.log(`\nagents detected: ${detected.map((agent) => agent.name).join(', ') || 'none'}`); const drift = report.filter((entry) => entry.status !== 'up-to-date' && entry.status !== 'skipped').length; if (drift === 0) { console.log('Everything up to date.'); } else { console.log(DRY_RUN ? `${drift} entr${drift === 1 ? 'y' : 'ies'} out of sync (dry-run, nothing written).` : `${drift} entr${drift === 1 ? 'y' : 'ies'} synced.`); } if (DRY_RUN && drift > 0) { process.exit(EXIT_FAILURE); } } const PLAN = parseArgs(process.argv.slice(2)); const DRY_RUN = PLAN.dryRun; try { main(); } catch (error) { console.error(`error: ${error.message}`); process.exit(EXIT_FAILURE); }