#!/usr/bin/env node // agent-sync — installs the bundled skills and agent prompts into the canonical // user-level directory (~/.agents) and links them into every detected AI agent's // own directory (same architecture as the vercel-labs/skills CLI). // Usage: agent-sync [--check] // --check : dry-run, reports drift without writing anything (exit 1 on drift) import { createHash } from 'node:crypto'; import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, linkSync, writeFileSync, } from 'node:fs'; import { homedir, platform } from 'node:os'; import { basename, dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const IS_WINDOWS = platform() === 'win32'; const DRY_RUN = process.argv.slice(2).includes('--check'); 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 CANONICAL_SKILLS_DIR = join(homedir(), '.agents', 'skills'); const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents'); // 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. const AGENT_REGISTRY = [ { name: 'claude', base: claudeBase, skillsSub: 'skills', agentsSub: 'agents' }, { name: 'pi', base: homeDir(join('.pi', 'agent')), skillsSub: 'skills', agentsSub: 'agents' }, { name: 'codex', base: homeDir('.codex'), skillsSub: 'skills' }, { name: 'opencode', base: homeDir('.opencode'), skillsSub: 'skills' }, { name: 'cursor', base: homeDir('.cursor'), skillsSub: 'skills' }, { name: 'gemini', base: homeDir('.gemini'), skillsSub: 'skills' }, { name: 'trae', base: homeDir('.trae'), skillsSub: 'skills' }, { name: 'windsurf', base: homeDir('.windsurf'), skillsSub: 'skills' }, { name: 'aider-desk', base: homeDir('.aider-desk'), skillsSub: 'skills' }, ]; // 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; } } // Copies bundled skills and agents into the canonical directory, replacing // entries whose content differs. Returns the list of performed actions. function syncCanonical() { const actions = []; for (const entry of readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const source = join(SOURCE_SKILLS_DIR, entry.name); const dest = join(CANONICAL_SKILLS_DIR, entry.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/${entry.name}`, status }); } for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) { 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. function detectAgents() { return AGENT_REGISTRY .filter((agent) => existsSync(agent.base())) .map((agent) => ({ ...agent, baseDir: agent.base() })); } // Ensures linkPath points to targetPath. Existing entries are never modified: // a link already pointing at the target counts as up-to-date, anything else is // foreign and skipped. New links use the best mechanism available: relative // symlink (unix), junction for directories (windows, no privileges), then // hardlink and copy as windows file fallbacks. function ensureLink(linkPath, targetPath) { const current = lstatOrNull(linkPath); if (current) { if (current.isSymbolicLink() && pointsTo(linkPath, targetPath)) return 'up-to-date'; // Regular files produced by this tool's own windows fallbacks (hardlink, // copy) converge to up-to-date when their content matches the target. // Missing or non-file targets degrade to 'skipped': in dry-run the // canonical target may not be installed yet. if (current.isFile() && lstatOrNull(targetPath)?.isFile() && fileHash(linkPath) === fileHash(targetPath)) { return 'up-to-date'; } return 'skipped'; } if (DRY_RUN) return 'link'; mkdirSync(dirname(linkPath), { recursive: true }); const isDirectory = statSync(targetPath).isDirectory(); if (!IS_WINDOWS) { symlinkSync(relative(dirname(linkPath), targetPath), linkPath); return 'linked'; } if (isDirectory) { symlinkSync(targetPath, linkPath, 'junction'); return 'linked'; } try { symlinkSync(relative(dirname(linkPath), targetPath), linkPath, 'file'); return 'linked'; } catch { // Windows symlinks need Developer Mode: degrade to hardlink (same volume), // then to a plain copy so installation always succeeds. try { linkSync(targetPath, linkPath); return 'hardlinked'; } catch { copyFileSync(targetPath, linkPath); return 'copied'; } } } // Links every bundled skill and agent prompt (source package names only, so // third-party content in the canonical directory is never propagated) into one // detected agent's directories. function linkForAgent(agent) { const actions = []; for (const name of readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name)) { actions.push({ label: `${agent.name}:skills/${name}`, status: ensureLink(join(agent.baseDir, agent.skillsSub, name), join(CANONICAL_SKILLS_DIR, name)), }); } if (agent.agentsSub && agent.name !== 'pi') { for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) { actions.push({ label: `${agent.name}:agents/${file}`, status: ensureLink(join(agent.baseDir, agent.agentsSub, file), join(CANONICAL_AGENTS_DIR, file)), }); } } return actions; } // Creates a minimal pi manifest for each bundled agent prompt, only when absent: // existing manifests hold per-machine config (model, thinking, tools) that must // never be overwritten. Only package-sourced agents are considered, so // third-party prompts in the canonical directory never receive a manifest. function writePiManifests(piAgent) { const piAgentsDir = join(piAgent.baseDir, piAgent.agentsSub); const actions = []; for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) { const manifestPath = join(piAgentsDir, file); if (lstatOrNull(manifestPath)) { actions.push({ label: `pi-manifest:${file}`, status: 'up-to-date' }); continue; } if (!DRY_RUN) { const promptPath = join(CANONICAL_AGENTS_DIR, file).split(sep).join('/'); const heading = readFileSync(join(SOURCE_AGENTS_DIR, file), 'utf8') .split('\n') .find((line) => line.startsWith('# ')); const description = heading ? heading.replace(/^#+\s*/, '').trim() : 'Installed by agent-sync'; mkdirSync(piAgentsDir, { recursive: true }); writeFileSync(manifestPath, `---\nname: ${JSON.stringify(basename(file, '.md'))}\ndescription: ${JSON.stringify(description)}\nprompt: ${JSON.stringify(promptPath)}\n---\n`); } actions.push({ label: `pi-manifest:${file}`, status: 'manifest' }); } return actions; } // Orchestrates the sync and prints a per-entry report with a final summary. function main() { const args = process.argv.slice(2); if (args.some((arg) => arg !== '--check')) { console.error('Usage: agent-sync [--check]'); process.exit(2); } if (!existsSync(SOURCE_SKILLS_DIR) || !existsSync(SOURCE_AGENTS_DIR)) { console.error(`error: bundled content not found under ${PACKAGE_ROOT}`); process.exit(1); } if (!DRY_RUN) { mkdirSync(CANONICAL_SKILLS_DIR, { recursive: true }); mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true }); } const report = [...syncCanonical()]; const agents = detectAgents(); for (const agent of agents) { report.push(...linkForAgent(agent)); } const piAgent = agents.find((agent) => agent.name === 'pi'); if (piAgent) { report.push(...writePiManifests(piAgent)); } for (const { label, status } of report) { console.log(`${status.padEnd(12)} ${label}`); } console.log(`\nagents detected: ${agents.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(1); } } try { main(); } catch (error) { console.error(`error: ${error.message}`); process.exit(1); }