agent-sync.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  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. // Usage: agent-sync [--check]
  6. // --check : dry-run, reports drift without writing anything (exit 1 on drift)
  7. import { createHash } from 'node:crypto';
  8. import {
  9. copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync,
  10. readFileSync, realpathSync, rmSync, statSync, symlinkSync, linkSync, writeFileSync,
  11. } from 'node:fs';
  12. import { homedir, platform } from 'node:os';
  13. import { basename, dirname, join, relative, sep } from 'node:path';
  14. import { fileURLToPath } from 'node:url';
  15. const IS_WINDOWS = platform() === 'win32';
  16. const DRY_RUN = process.argv.slice(2).includes('--check');
  17. const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
  18. const SOURCE_SKILLS_DIR = join(PACKAGE_ROOT, 'skills');
  19. const SOURCE_AGENTS_DIR = join(PACKAGE_ROOT, 'agents');
  20. const CANONICAL_SKILLS_DIR = join(homedir(), '.agents', 'skills');
  21. const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents');
  22. // Resolves the Claude Code config dir, honoring CLAUDE_CONFIG_DIR when set.
  23. function claudeBase() {
  24. return process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude');
  25. }
  26. // Resolves a fixed directory under the user home (lazy, re-evaluated per run).
  27. function homeDir(dir) {
  28. return () => join(homedir(), dir);
  29. }
  30. // Supported AI agents: base() proves installation when the directory exists,
  31. // skillsSub/agentsSub are the subdirectories receiving our links. Agent prompts
  32. // are linked only where the convention is verified (claude); pi gets a minimal
  33. // manifest instead (see writePiManifests) because its manifests carry
  34. // per-machine config such as model and thinking.
  35. const AGENT_REGISTRY = [
  36. { name: 'claude', base: claudeBase, skillsSub: 'skills', agentsSub: 'agents' },
  37. { name: 'pi', base: homeDir(join('.pi', 'agent')), skillsSub: 'skills', agentsSub: 'agents' },
  38. { name: 'codex', base: homeDir('.codex'), skillsSub: 'skills' },
  39. { name: 'opencode', base: homeDir('.opencode'), skillsSub: 'skills' },
  40. { name: 'cursor', base: homeDir('.cursor'), skillsSub: 'skills' },
  41. { name: 'gemini', base: homeDir('.gemini'), skillsSub: 'skills' },
  42. { name: 'trae', base: homeDir('.trae'), skillsSub: 'skills' },
  43. { name: 'windsurf', base: homeDir('.windsurf'), skillsSub: 'skills' },
  44. { name: 'aider-desk', base: homeDir('.aider-desk'), skillsSub: 'skills' },
  45. ];
  46. // Returns the SHA-256 hex digest of a file, or null when the file does not exist.
  47. function fileHash(filePath) {
  48. if (!existsSync(filePath)) return null;
  49. return createHash('sha256').update(readFileSync(filePath)).digest('hex');
  50. }
  51. // Returns a stable digest of a directory: sorted list of "relative path:hash"
  52. // for every file, so any content or structure change alters the result.
  53. function dirHash(dirPath) {
  54. if (!existsSync(dirPath)) return null;
  55. const parts = [];
  56. const walk = (dir, prefix) => {
  57. for (const entry of readdirSync(dir, { withFileTypes: true })) {
  58. const full = join(dir, entry.name);
  59. if (entry.isDirectory()) walk(full, `${prefix}${entry.name}/`);
  60. else parts.push(`${prefix}${entry.name}:${fileHash(full)}`);
  61. }
  62. };
  63. walk(dirPath, '');
  64. return createHash('sha256').update(parts.sort().join('\n')).digest('hex');
  65. }
  66. // Returns the lstat of a path, or null when it does not exist (broken links included).
  67. function lstatOrNull(path) {
  68. try {
  69. return lstatSync(path);
  70. } catch {
  71. return null;
  72. }
  73. }
  74. // Tells whether the entry at linkPath is a symlink resolving to targetPath.
  75. function pointsTo(linkPath, targetPath) {
  76. try {
  77. return realpathSync(linkPath) === realpathSync(targetPath);
  78. } catch {
  79. return false;
  80. }
  81. }
  82. // Copies bundled skills and agents into the canonical directory, replacing
  83. // entries whose content differs. Returns the list of performed actions.
  84. function syncCanonical() {
  85. const actions = [];
  86. for (const entry of readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })) {
  87. if (!entry.isDirectory()) continue;
  88. const source = join(SOURCE_SKILLS_DIR, entry.name);
  89. const dest = join(CANONICAL_SKILLS_DIR, entry.name);
  90. const status = dirHash(source) === dirHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added';
  91. if (status !== 'up-to-date' && !DRY_RUN) {
  92. rmSync(dest, { recursive: true, force: true });
  93. cpSync(source, dest, { recursive: true });
  94. }
  95. actions.push({ label: `skills/${entry.name}`, status });
  96. }
  97. for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) {
  98. const source = join(SOURCE_AGENTS_DIR, file);
  99. const dest = join(CANONICAL_AGENTS_DIR, file);
  100. const status = fileHash(source) === fileHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added';
  101. if (status !== 'up-to-date' && !DRY_RUN) {
  102. copyFileSync(source, dest);
  103. }
  104. actions.push({ label: `agents/${file}`, status });
  105. }
  106. return actions;
  107. }
  108. // Detects installed agents by probing their config directory.
  109. function detectAgents() {
  110. return AGENT_REGISTRY
  111. .filter((agent) => existsSync(agent.base()))
  112. .map((agent) => ({ ...agent, baseDir: agent.base() }));
  113. }
  114. // Ensures linkPath points to targetPath. Existing entries are never modified:
  115. // a link already pointing at the target counts as up-to-date, anything else is
  116. // foreign and skipped. New links use the best mechanism available: relative
  117. // symlink (unix), junction for directories (windows, no privileges), then
  118. // hardlink and copy as windows file fallbacks.
  119. function ensureLink(linkPath, targetPath) {
  120. const current = lstatOrNull(linkPath);
  121. if (current) {
  122. if (current.isSymbolicLink() && pointsTo(linkPath, targetPath)) return 'up-to-date';
  123. // Regular files produced by this tool's own windows fallbacks (hardlink,
  124. // copy) converge to up-to-date when their content matches the target.
  125. // Missing or non-file targets degrade to 'skipped': in dry-run the
  126. // canonical target may not be installed yet.
  127. if (current.isFile() && lstatOrNull(targetPath)?.isFile() && fileHash(linkPath) === fileHash(targetPath)) {
  128. return 'up-to-date';
  129. }
  130. return 'skipped';
  131. }
  132. if (DRY_RUN) return 'link';
  133. mkdirSync(dirname(linkPath), { recursive: true });
  134. const isDirectory = statSync(targetPath).isDirectory();
  135. if (!IS_WINDOWS) {
  136. symlinkSync(relative(dirname(linkPath), targetPath), linkPath);
  137. return 'linked';
  138. }
  139. if (isDirectory) {
  140. symlinkSync(targetPath, linkPath, 'junction');
  141. return 'linked';
  142. }
  143. try {
  144. symlinkSync(relative(dirname(linkPath), targetPath), linkPath, 'file');
  145. return 'linked';
  146. } catch {
  147. // Windows symlinks need Developer Mode: degrade to hardlink (same volume),
  148. // then to a plain copy so installation always succeeds.
  149. try {
  150. linkSync(targetPath, linkPath);
  151. return 'hardlinked';
  152. } catch {
  153. copyFileSync(targetPath, linkPath);
  154. return 'copied';
  155. }
  156. }
  157. }
  158. // Links every bundled skill and agent prompt (source package names only, so
  159. // third-party content in the canonical directory is never propagated) into one
  160. // detected agent's directories.
  161. function linkForAgent(agent) {
  162. const actions = [];
  163. for (const name of readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })
  164. .filter((entry) => entry.isDirectory())
  165. .map((entry) => entry.name)) {
  166. actions.push({
  167. label: `${agent.name}:skills/${name}`,
  168. status: ensureLink(join(agent.baseDir, agent.skillsSub, name), join(CANONICAL_SKILLS_DIR, name)),
  169. });
  170. }
  171. if (agent.agentsSub && agent.name !== 'pi') {
  172. for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) {
  173. actions.push({
  174. label: `${agent.name}:agents/${file}`,
  175. status: ensureLink(join(agent.baseDir, agent.agentsSub, file), join(CANONICAL_AGENTS_DIR, file)),
  176. });
  177. }
  178. }
  179. return actions;
  180. }
  181. // Creates a minimal pi manifest for each bundled agent prompt, only when absent:
  182. // existing manifests hold per-machine config (model, thinking, tools) that must
  183. // never be overwritten. Only package-sourced agents are considered, so
  184. // third-party prompts in the canonical directory never receive a manifest.
  185. function writePiManifests(piAgent) {
  186. const piAgentsDir = join(piAgent.baseDir, piAgent.agentsSub);
  187. const actions = [];
  188. for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) {
  189. const manifestPath = join(piAgentsDir, file);
  190. if (lstatOrNull(manifestPath)) {
  191. actions.push({ label: `pi-manifest:${file}`, status: 'up-to-date' });
  192. continue;
  193. }
  194. if (!DRY_RUN) {
  195. const promptPath = join(CANONICAL_AGENTS_DIR, file).split(sep).join('/');
  196. const heading = readFileSync(join(SOURCE_AGENTS_DIR, file), 'utf8')
  197. .split('\n')
  198. .find((line) => line.startsWith('# '));
  199. const description = heading ? heading.replace(/^#+\s*/, '').trim() : 'Installed by agent-sync';
  200. mkdirSync(piAgentsDir, { recursive: true });
  201. writeFileSync(manifestPath, `---\nname: ${JSON.stringify(basename(file, '.md'))}\ndescription: ${JSON.stringify(description)}\nprompt: ${JSON.stringify(promptPath)}\n---\n`);
  202. }
  203. actions.push({ label: `pi-manifest:${file}`, status: 'manifest' });
  204. }
  205. return actions;
  206. }
  207. // Orchestrates the sync and prints a per-entry report with a final summary.
  208. function main() {
  209. const args = process.argv.slice(2);
  210. if (args.some((arg) => arg !== '--check')) {
  211. console.error('Usage: agent-sync [--check]');
  212. process.exit(2);
  213. }
  214. if (!existsSync(SOURCE_SKILLS_DIR) || !existsSync(SOURCE_AGENTS_DIR)) {
  215. console.error(`error: bundled content not found under ${PACKAGE_ROOT}`);
  216. process.exit(1);
  217. }
  218. if (!DRY_RUN) {
  219. mkdirSync(CANONICAL_SKILLS_DIR, { recursive: true });
  220. mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true });
  221. }
  222. const report = [...syncCanonical()];
  223. const agents = detectAgents();
  224. for (const agent of agents) {
  225. report.push(...linkForAgent(agent));
  226. }
  227. const piAgent = agents.find((agent) => agent.name === 'pi');
  228. if (piAgent) {
  229. report.push(...writePiManifests(piAgent));
  230. }
  231. for (const { label, status } of report) {
  232. console.log(`${status.padEnd(12)} ${label}`);
  233. }
  234. console.log(`\nagents detected: ${agents.map((agent) => agent.name).join(', ') || 'none'}`);
  235. const drift = report.filter((entry) => entry.status !== 'up-to-date' && entry.status !== 'skipped').length;
  236. if (drift === 0) {
  237. console.log('Everything up to date.');
  238. } else {
  239. console.log(DRY_RUN ? `${drift} entr${drift === 1 ? 'y' : 'ies'} out of sync (dry-run, nothing written).` : `${drift} entr${drift === 1 ? 'y' : 'ies'} synced.`);
  240. }
  241. if (DRY_RUN && drift > 0) {
  242. process.exit(1);
  243. }
  244. }
  245. try {
  246. main();
  247. } catch (error) {
  248. console.error(`error: ${error.message}`);
  249. process.exit(1);
  250. }