agent-sync.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. #!/usr/bin/env node
  2. // agent-sync — installs the bundled skills and agent prompts into the canonical
  3. // user-level directory (~/.agents) and links them into every detected AI agent's
  4. // own directory (same architecture as the vercel-labs/skills CLI).
  5. // Selection is explicit (--all, --skills, --agents, --targets): without a
  6. // selection flag the tool prints its help. See HELP for the full surface.
  7. import { createHash } from 'node:crypto';
  8. import {
  9. cpSync, existsSync, lstatSync, mkdirSync, readdirSync,
  10. readFileSync, realpathSync, rmSync, statSync, symlinkSync,
  11. } from 'node:fs';
  12. import { homedir, platform } from 'node:os';
  13. import { basename, dirname, join, relative } from 'node:path';
  14. import { fileURLToPath } from 'node:url';
  15. const EXIT_OK = 0;
  16. const EXIT_FAILURE = 1;
  17. const EXIT_USAGE = 2;
  18. const IS_WINDOWS = platform() === 'win32';
  19. const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  20. const SOURCE_SKILLS_DIR = join(PACKAGE_ROOT, 'skills');
  21. const SOURCE_AGENTS_DIR = join(PACKAGE_ROOT, 'agents');
  22. const CANONICAL_SKILLS_DIR = join(homedir(), '.agents', 'skills');
  23. const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents');
  24. const HELP = `agent-sync — installs bundled skills and agent prompts into the
  25. canonical ~/.agents directory and links them into the AI agents detected
  26. on the machine.
  27. Usage:
  28. agent-sync --all [--check] [--targets [<names>]]
  29. agent-sync --skills [<names>] [--agents [<names>]] [--check] [--targets [<names>]]
  30. agent-sync --list
  31. agent-sync --help
  32. A selection flag without a value covers its whole dimension: --skills alone
  33. selects every bundled skill, --agents alone every prompt, --targets alone
  34. every detected agent. With a comma-separated value only the listed names are
  35. used. Flags combine: --skills commit --targets claude,pi syncs just the
  36. commit skill into Claude Code and pi. Without any selection flag agent-sync
  37. prints this help.
  38. --check dry-run, reports drift without writing anything (exit 1 on drift)
  39. Exit codes: ${EXIT_OK} ok or help, ${EXIT_FAILURE} drift (dry-run) or failure, ${EXIT_USAGE} usage error.`;
  40. // Resolves the Claude Code config dir, honoring CLAUDE_CONFIG_DIR when set.
  41. function claudeBase() {
  42. return process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude');
  43. }
  44. // Resolves a fixed directory under the user home (lazy, re-evaluated per run).
  45. function homeDir(dir) {
  46. return () => join(homedir(), dir);
  47. }
  48. // Supported AI agents: base() proves installation when the directory exists,
  49. // skillsSub/agentsSub are the subdirectories receiving our links. Agent prompts
  50. // are linked only where the convention is verified (claude); pi gets a minimal
  51. // manifest instead (see writePiManifests) because its manifests carry
  52. // per-machine config such as model and thinking.
  53. const AGENT_REGISTRY = [
  54. { name: 'claude', base: claudeBase, skillsSub: 'skills', agentsSub: 'agents' },
  55. { name: 'pi', base: homeDir(join('.pi', 'agent')), skillsSub: 'skills', agentsSub: 'agents' },
  56. { name: 'codex', base: homeDir('.codex'), skillsSub: 'skills' },
  57. { name: 'opencode', base: homeDir('.opencode'), skillsSub: 'skills' },
  58. { name: 'cursor', base: homeDir('.cursor'), skillsSub: 'skills' },
  59. { name: 'gemini', base: homeDir('.gemini'), skillsSub: 'skills' },
  60. { name: 'trae', base: homeDir('.trae'), skillsSub: 'skills' },
  61. { name: 'windsurf', base: homeDir('.windsurf'), skillsSub: 'skills' },
  62. { name: 'aider-desk', base: homeDir('.aider-desk'), skillsSub: 'skills' },
  63. ];
  64. // Prints a usage error and exits with the usage exit code.
  65. function fail(message) {
  66. console.error(`${message}\nRun 'agent-sync --help' for usage.`);
  67. process.exit(EXIT_USAGE);
  68. }
  69. // Returns the names of the bundled skills (directories under skills/).
  70. function bundledSkillNames() {
  71. return readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })
  72. .filter((entry) => entry.isDirectory())
  73. .map((entry) => entry.name);
  74. }
  75. // Returns the file names of the bundled agent prompts (*.md under agents/).
  76. function bundledAgentFiles() {
  77. return readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'));
  78. }
  79. // Dimension flags hold three states: null (flag absent), true (whole
  80. // dimension) or an array of names. A repeated flag accumulates names, and a
  81. // valueless occurrence widens the dimension to everything.
  82. function mergeDimension(current, value) {
  83. if (current === true || value === null) return true;
  84. return [...(current ?? []), ...value];
  85. }
  86. const DIMENSION_FLAGS = { '--skills': 'skills', '--agents': 'agents', '--targets': 'targets' };
  87. // Parses the command line into a run plan (syntax only: names are validated
  88. // later against the bundled content). Selection flags are --all, --skills,
  89. // --agents and --targets; every other flag is a modifier.
  90. function parseArgs(argv) {
  91. const plan = { help: false, list: false, dryRun: false, all: false, skills: null, agents: null, targets: null };
  92. const optionalList = (index, flag) => {
  93. const value = argv[index + 1];
  94. if (!value || value.startsWith('--')) return null;
  95. const names = value.split(',').map((name) => name.trim()).filter(Boolean);
  96. if (names.length === 0) fail(`error: ${flag} received an empty list of names`);
  97. return names;
  98. };
  99. for (let i = 0; i < argv.length; i += 1) {
  100. const arg = argv[i];
  101. if (arg === '--help' || arg === '-h') plan.help = true;
  102. else if (arg === '--list') plan.list = true;
  103. else if (arg === '--check') plan.dryRun = true;
  104. else if (arg === '--all') plan.all = true;
  105. else if (DIMENSION_FLAGS[arg]) {
  106. const key = DIMENSION_FLAGS[arg];
  107. const value = optionalList(i, arg);
  108. plan[key] = mergeDimension(plan[key], value);
  109. if (value !== null) i += 1;
  110. }
  111. else fail(`error: unknown argument '${arg}'`);
  112. }
  113. return plan;
  114. }
  115. // Validates the requested names against the bundled content and the agent
  116. // registry, then resolves the effective selection. A dimension set to true
  117. // expands to everything; prompt names are given without extension (gv-dev)
  118. // and mapped to their bundled files (gv-dev.md).
  119. function resolveSelection(plan) {
  120. const skillNames = bundledSkillNames();
  121. const agentFiles = bundledAgentFiles();
  122. const registryNames = AGENT_REGISTRY.map((agent) => agent.name);
  123. const pickSkill = (name) => {
  124. if (!skillNames.includes(name)) fail(`error: unknown skill '${name}' (available: ${skillNames.join(', ')})`);
  125. return name;
  126. };
  127. const pickAgent = (name) => {
  128. const file = agentFiles.find((candidate) => basename(candidate, '.md') === name);
  129. if (!file) fail(`error: unknown agent '${name}' (available: ${agentFiles.map((f) => basename(f, '.md')).join(', ')})`);
  130. return file;
  131. };
  132. const pickTarget = (name) => {
  133. if (!registryNames.includes(name)) fail(`error: unknown target '${name}' (available: ${registryNames.join(', ')})`);
  134. return name;
  135. };
  136. return {
  137. skills: plan.all || plan.skills === true ? skillNames : (plan.skills ?? []).map(pickSkill),
  138. agents: plan.all || plan.agents === true ? agentFiles : (plan.agents ?? []).map(pickAgent),
  139. targets: plan.targets === true || !plan.targets ? null : plan.targets.map(pickTarget),
  140. };
  141. }
  142. // Prints the bundled catalog plus the AI agents detected on this machine.
  143. function printCatalog() {
  144. const detected = AGENT_REGISTRY.filter((agent) => existsSync(agent.base())).map((agent) => agent.name);
  145. console.log(`skills: ${bundledSkillNames().join(', ')}`);
  146. console.log(`agents: ${bundledAgentFiles().map((file) => basename(file, '.md')).join(', ')}`);
  147. console.log(`targets: ${AGENT_REGISTRY.map((agent) => `${agent.name}${detected.includes(agent.name) ? '*' : ''}`).join(', ')} (* = detected)`);
  148. }
  149. // Returns the SHA-256 hex digest of a file, or null when the file does not exist.
  150. function fileHash(filePath) {
  151. if (!existsSync(filePath)) return null;
  152. return createHash('sha256').update(readFileSync(filePath)).digest('hex');
  153. }
  154. // Returns a stable digest of a directory: sorted list of "relative path:hash"
  155. // for every file, so any content or structure change alters the result.
  156. function dirHash(dirPath) {
  157. if (!existsSync(dirPath)) return null;
  158. const parts = [];
  159. const walk = (dir, prefix) => {
  160. for (const entry of readdirSync(dir, { withFileTypes: true })) {
  161. const full = join(dir, entry.name);
  162. if (entry.isDirectory()) walk(full, `${prefix}${entry.name}/`);
  163. else parts.push(`${prefix}${entry.name}:${fileHash(full)}`);
  164. }
  165. };
  166. walk(dirPath, '');
  167. return createHash('sha256').update(parts.sort().join('\n')).digest('hex');
  168. }
  169. // Returns the lstat of a path, or null when it does not exist (broken links included).
  170. function lstatOrNull(path) {
  171. try {
  172. return lstatSync(path);
  173. } catch {
  174. return null;
  175. }
  176. }
  177. // Tells whether the entry at linkPath is a symlink resolving to targetPath.
  178. function pointsTo(linkPath, targetPath) {
  179. try {
  180. return realpathSync(linkPath) === realpathSync(targetPath);
  181. } catch {
  182. return false;
  183. }
  184. }
  185. // Ensures the canonical agents directory exists as a real directory, so the
  186. // individual .md links have a place to live. An earlier version linked the
  187. // whole folder with a junction: such a link to our own source is unwound
  188. // here (removing the junction never touches the source files).
  189. function ensureCanonicalAgentsDir() {
  190. const current = lstatOrNull(CANONICAL_AGENTS_DIR);
  191. if (!current) {
  192. if (DRY_RUN) return { label: 'agents', status: 'link' };
  193. mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true });
  194. return { label: 'agents', status: 'added' };
  195. }
  196. if (current.isSymbolicLink()) {
  197. if (!pointsTo(CANONICAL_AGENTS_DIR, SOURCE_AGENTS_DIR)) return { label: 'agents', status: 'skipped' };
  198. if (DRY_RUN) return { label: 'agents', status: 'relink' };
  199. rmSync(CANONICAL_AGENTS_DIR, { recursive: true, force: true });
  200. mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true });
  201. return { label: 'agents', status: 'relinked' };
  202. }
  203. return current.isDirectory() ? { label: 'agents', status: 'up-to-date' } : { label: 'agents', status: 'skipped' };
  204. }
  205. // Copies the selected skills into the canonical directory and links the agent
  206. // prompt files individually (no copies, ever). Returns the list of actions.
  207. function syncCanonical(skills, agentFiles) {
  208. const actions = [];
  209. for (const name of skills) {
  210. const source = join(SOURCE_SKILLS_DIR, name);
  211. const dest = join(CANONICAL_SKILLS_DIR, name);
  212. const status = dirHash(source) === dirHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added';
  213. if (status !== 'up-to-date' && !DRY_RUN) {
  214. rmSync(dest, { recursive: true, force: true });
  215. cpSync(source, dest, { recursive: true });
  216. }
  217. actions.push({ label: `skills/${name}`, status });
  218. }
  219. for (const file of agentFiles) {
  220. actions.push({ label: `agents/${file}`, status: ensureLink(join(CANONICAL_AGENTS_DIR, file), join(SOURCE_AGENTS_DIR, file)) });
  221. }
  222. return actions;
  223. }
  224. // Detects installed agents by probing their config directory, optionally
  225. // restricted to the requested target names.
  226. function detectAgents(targetNames) {
  227. return AGENT_REGISTRY
  228. .filter((agent) => (!targetNames || targetNames.includes(agent.name)) && existsSync(agent.base()))
  229. .map((agent) => ({ ...agent, baseDir: agent.base() }));
  230. }
  231. // Ensures linkPath points to targetPath via a symlink: junctions for
  232. // directories on windows (no privileges needed), file symlinks everywhere
  233. // else. There is no fallback: if the symlink cannot be created (EPERM without
  234. // Developer Mode/privileges, cross-filesystem restrictions) the sync aborts
  235. // with an explicit error — copies are never made. A pre-existing regular file
  236. // is a leftover from older copy/hardlink installs and is replaced by the
  237. // symlink; directories or foreign symlinks are skipped, never touched.
  238. function ensureLink(linkPath, targetPath) {
  239. const current = lstatOrNull(linkPath);
  240. if (current) {
  241. if (current.isSymbolicLink() && pointsTo(linkPath, targetPath)) return 'up-to-date';
  242. if (current.isFile()) {
  243. if (DRY_RUN) return 'relink';
  244. rmSync(linkPath, { force: true });
  245. } else {
  246. return 'skipped';
  247. }
  248. }
  249. if (DRY_RUN) return 'link';
  250. mkdirSync(dirname(linkPath), { recursive: true });
  251. const isDirectory = statSync(targetPath).isDirectory();
  252. try {
  253. if (IS_WINDOWS && isDirectory) symlinkSync(targetPath, linkPath, 'junction');
  254. else symlinkSync(relative(dirname(linkPath), targetPath), linkPath);
  255. return 'linked';
  256. } catch (error) {
  257. throw new Error(`cannot create symlink '${linkPath}' (no fallback, no copy: ${error.message})`);
  258. }
  259. }
  260. // Links the selected skills (source package names only, so third-party
  261. // content in the canonical directory is never propagated) into one detected
  262. // agent's directories. Agent prompts are deliberately not linked here: they
  263. // are managed only inside the canonical ~/.agents/agents directory.
  264. function linkForAgent(agent, skills) {
  265. const actions = [];
  266. for (const name of skills) {
  267. actions.push({
  268. label: `${agent.name}:skills/${name}`,
  269. status: ensureLink(join(agent.baseDir, agent.skillsSub, name), join(CANONICAL_SKILLS_DIR, name)),
  270. });
  271. }
  272. return actions;
  273. }
  274. // Orchestrates the sync and prints a per-entry report with a final summary.
  275. function main() {
  276. if (PLAN.help) {
  277. console.log(HELP);
  278. process.exit(EXIT_OK);
  279. }
  280. if (!existsSync(SOURCE_SKILLS_DIR) || !existsSync(SOURCE_AGENTS_DIR)) {
  281. console.error(`error: bundled content not found under ${PACKAGE_ROOT}`);
  282. process.exit(EXIT_FAILURE);
  283. }
  284. const hasContent = PLAN.all || PLAN.skills || PLAN.agents;
  285. if (PLAN.list && (hasContent || PLAN.targets)) fail('error: --list cannot be combined with selection flags');
  286. if (PLAN.all && (PLAN.skills || PLAN.agents)) fail('error: --all cannot be combined with --skills or --agents');
  287. if (PLAN.targets && !hasContent) fail('error: --targets requires --all, --skills or --agents');
  288. if (PLAN.list) {
  289. printCatalog();
  290. process.exit(EXIT_OK);
  291. }
  292. if (!hasContent) {
  293. console.log(HELP);
  294. process.exit(EXIT_OK);
  295. }
  296. const { skills, agents: agentFiles, targets } = resolveSelection(PLAN);
  297. const detected = detectAgents(targets);
  298. if (targets && detected.length === 0) {
  299. fail(`error: none of the requested targets is installed (${targets.join(', ')})`);
  300. }
  301. if (!DRY_RUN) {
  302. mkdirSync(CANONICAL_SKILLS_DIR, { recursive: true });
  303. }
  304. const report = [ensureCanonicalAgentsDir(), ...syncCanonical(skills, agentFiles)];
  305. for (const agent of detected) {
  306. report.push(...linkForAgent(agent, skills));
  307. }
  308. for (const { label, status } of report) {
  309. console.log(`${status.padEnd(12)} ${label}`);
  310. }
  311. console.log(`\nagents detected: ${detected.map((agent) => agent.name).join(', ') || 'none'}`);
  312. const drift = report.filter((entry) => entry.status !== 'up-to-date' && entry.status !== 'skipped').length;
  313. if (drift === 0) {
  314. console.log('Everything up to date.');
  315. } else {
  316. console.log(DRY_RUN ? `${drift} entr${drift === 1 ? 'y' : 'ies'} out of sync (dry-run, nothing written).` : `${drift} entr${drift === 1 ? 'y' : 'ies'} synced.`);
  317. }
  318. if (DRY_RUN && drift > 0) {
  319. process.exit(EXIT_FAILURE);
  320. }
  321. }
  322. const PLAN = parseArgs(process.argv.slice(2));
  323. const DRY_RUN = PLAN.dryRun;
  324. try {
  325. main();
  326. } catch (error) {
  327. console.error(`error: ${error.message}`);
  328. process.exit(EXIT_FAILURE);
  329. }