| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445 |
- #!/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 [<names>]]
- agent-sync --skills [<names>] [--agents [<names>]] [--sys] [--check] [--targets [<names>]]
- 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/<name>.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);
- }
|