|
|
@@ -0,0 +1,270 @@
|
|
|
+#!/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);
|
|
|
+}
|