|
@@ -10,7 +10,7 @@
|
|
|
import { createHash } from 'node:crypto';
|
|
import { createHash } from 'node:crypto';
|
|
|
import {
|
|
import {
|
|
|
cpSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync,
|
|
cpSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync,
|
|
|
- readFileSync, realpathSync, rmSync, statSync, symlinkSync,
|
|
|
|
|
|
|
+ readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync,
|
|
|
} from 'node:fs';
|
|
} from 'node:fs';
|
|
|
import { homedir, platform } from 'node:os';
|
|
import { homedir, platform } from 'node:os';
|
|
|
import { basename, dirname, join, relative } from 'node:path';
|
|
import { basename, dirname, join, relative } from 'node:path';
|
|
@@ -25,26 +25,37 @@ const IS_WINDOWS = platform() === 'win32';
|
|
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
const SOURCE_SKILLS_DIR = join(PACKAGE_ROOT, 'skills');
|
|
const SOURCE_SKILLS_DIR = join(PACKAGE_ROOT, 'skills');
|
|
|
const SOURCE_AGENTS_DIR = join(PACKAGE_ROOT, 'agents');
|
|
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_SKILLS_DIR = join(homedir(), '.agents', 'skills');
|
|
|
const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents');
|
|
const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents');
|
|
|
|
|
|
|
|
|
|
+// Frontmatter prepended to SYSTEM_PROMPT.md for rule-based agents that require
|
|
|
|
|
+// an always-on wrapper (cursor .mdc, windsurf rules).
|
|
|
|
|
+const SYS_CURSOR_FRONTMATTER = '---\ndescription: oh-my-skills system prompt\nalwaysApply: true\n---\n\n';
|
|
|
|
|
+const SYS_WINDSURF_FRONTMATTER = '---\ntrigger: always_on\n---\n\n';
|
|
|
|
|
+
|
|
|
const HELP = `agent-sync โ installs bundled skills and agent prompts into the
|
|
const HELP = `agent-sync โ installs bundled skills and agent prompts into the
|
|
|
canonical ~/.agents directory and links them into the AI agents detected
|
|
canonical ~/.agents directory and links them into the AI agents detected
|
|
|
on the machine.
|
|
on the machine.
|
|
|
|
|
|
|
|
Usage:
|
|
Usage:
|
|
|
agent-sync --all [--check] [--targets [<names>]]
|
|
agent-sync --all [--check] [--targets [<names>]]
|
|
|
- agent-sync --skills [<names>] [--agents [<names>]] [--check] [--targets [<names>]]
|
|
|
|
|
|
|
+ agent-sync --skills [<names>] [--agents [<names>]] [--sys] [--check] [--targets [<names>]]
|
|
|
agent-sync --list
|
|
agent-sync --list
|
|
|
agent-sync --help
|
|
agent-sync --help
|
|
|
|
|
|
|
|
A selection flag without a value covers its whole dimension: --skills alone
|
|
A selection flag without a value covers its whole dimension: --skills alone
|
|
|
-selects every bundled skill, --agents alone every 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.
|
|
|
|
|
|
|
+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, *.mdc, ...).
|
|
|
|
|
+It is always copied, never linked, and never stored in ~/.agents.
|
|
|
|
|
|
|
|
--check dry-run, reports drift without writing anything (exit 1 on drift)
|
|
--check dry-run, reports drift without writing anything (exit 1 on drift)
|
|
|
|
|
|
|
@@ -65,15 +76,18 @@ function homeDir(dir) {
|
|
|
// are linked only where the convention is verified (claude); pi gets a minimal
|
|
// are linked only where the convention is verified (claude); pi gets a minimal
|
|
|
// manifest instead (see writePiManifests) because its manifests carry
|
|
// manifest instead (see writePiManifests) because its manifests carry
|
|
|
// per-machine config such as model and thinking.
|
|
// per-machine config such as model and thinking.
|
|
|
|
|
+// sys is the --sys destination relative to base(): the bundled SYSTEM_PROMPT.md
|
|
|
|
|
+// is copied there (renamed, plain copy, never linked) or wrapped with a
|
|
|
|
|
+// frontmatter when the target expects one. null = no system prompt support.
|
|
|
const AGENT_REGISTRY = [
|
|
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: 'trae', base: homeDir('.trae'), skillsSub: 'skills' },
|
|
|
|
|
- { name: 'windsurf', base: homeDir('.windsurf'), skillsSub: 'skills' },
|
|
|
|
|
- { name: 'aider-desk', base: homeDir('.aider-desk'), skillsSub: 'skills' },
|
|
|
|
|
|
|
+ { name: 'claude', base: claudeBase, skillsSub: 'skills', agentsSub: 'agents', 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('.opencode'), skillsSub: 'skills', sys: { file: 'AGENTS.md' } },
|
|
|
|
|
+ { name: 'cursor', base: homeDir('.cursor'), skillsSub: 'skills', sys: { file: join('rules', 'oh-my-skills.mdc'), frontmatter: SYS_CURSOR_FRONTMATTER } },
|
|
|
|
|
+ { name: 'trae', base: homeDir('.trae'), skillsSub: 'skills', sys: null },
|
|
|
|
|
+ { name: 'windsurf', base: homeDir('.windsurf'), skillsSub: 'skills', sys: { file: join('rules', 'oh-my-skills.md'), frontmatter: SYS_WINDSURF_FRONTMATTER } },
|
|
|
|
|
+ { name: 'aider-desk', base: homeDir('.aider-desk'), skillsSub: 'skills', sys: null },
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
// Prints a usage error and exits with the usage exit code.
|
|
// Prints a usage error and exits with the usage exit code.
|
|
@@ -102,13 +116,13 @@ function mergeDimension(current, value) {
|
|
|
return [...(current ?? []), ...value];
|
|
return [...(current ?? []), ...value];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const DIMENSION_FLAGS = { '--skills': 'skills', '--agents': 'agents', '--targets': 'targets' };
|
|
|
|
|
|
|
+const DIMENSION_FLAGS = { '--skills': 'skills', '--agents': 'agents', '--targets': 'targets', '--sys': 'sys' };
|
|
|
|
|
|
|
|
// Parses the command line into a run plan (syntax only: names are validated
|
|
// Parses the command line into a run plan (syntax only: names are validated
|
|
|
// later against the bundled content). Selection flags are --all, --skills,
|
|
// later against the bundled content). Selection flags are --all, --skills,
|
|
|
-// --agents and --targets; every other flag is a modifier.
|
|
|
|
|
|
|
+// --agents, --targets and --sys; every other flag is a modifier.
|
|
|
function parseArgs(argv) {
|
|
function parseArgs(argv) {
|
|
|
- const plan = { help: false, list: false, dryRun: false, all: false, skills: null, agents: null, targets: null };
|
|
|
|
|
|
|
+ const plan = { help: false, list: false, dryRun: false, all: false, skills: null, agents: null, targets: null, sys: null };
|
|
|
const optionalList = (index, flag) => {
|
|
const optionalList = (index, flag) => {
|
|
|
const value = argv[index + 1];
|
|
const value = argv[index + 1];
|
|
|
if (!value || value.startsWith('--')) return null;
|
|
if (!value || value.startsWith('--')) return null;
|
|
@@ -125,6 +139,7 @@ function parseArgs(argv) {
|
|
|
else if (DIMENSION_FLAGS[arg]) {
|
|
else if (DIMENSION_FLAGS[arg]) {
|
|
|
const key = DIMENSION_FLAGS[arg];
|
|
const key = DIMENSION_FLAGS[arg];
|
|
|
const value = optionalList(i, arg);
|
|
const value = optionalList(i, arg);
|
|
|
|
|
+ if (key === 'sys' && value !== null) fail('error: --sys takes no value');
|
|
|
plan[key] = mergeDimension(plan[key], value);
|
|
plan[key] = mergeDimension(plan[key], value);
|
|
|
if (value !== null) i += 1;
|
|
if (value !== null) i += 1;
|
|
|
}
|
|
}
|
|
@@ -158,6 +173,7 @@ function resolveSelection(plan) {
|
|
|
skills: plan.all || plan.skills === true ? skillNames : (plan.skills ?? []).map(pickSkill),
|
|
skills: plan.all || plan.skills === true ? skillNames : (plan.skills ?? []).map(pickSkill),
|
|
|
agents: plan.all || plan.agents === true ? agentFiles : (plan.agents ?? []).map(pickAgent),
|
|
agents: plan.all || plan.agents === true ? agentFiles : (plan.agents ?? []).map(pickAgent),
|
|
|
targets: plan.targets === true || !plan.targets ? null : plan.targets.map(pickTarget),
|
|
targets: plan.targets === true || !plan.targets ? null : plan.targets.map(pickTarget),
|
|
|
|
|
+ sys: Boolean(plan.all || plan.sys),
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -309,6 +325,27 @@ function linkForAgent(agent, skills) {
|
|
|
return actions;
|
|
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). Cursor and
|
|
|
|
|
+// windsurf receive the content wrapped in an always-on rule frontmatter.
|
|
|
|
|
+function syncSystemPrompts(detected, systemPrompt) {
|
|
|
|
|
+ const actions = [];
|
|
|
|
|
+ for (const agent of detected) {
|
|
|
|
|
+ if (!agent.sys) continue;
|
|
|
|
|
+ const expected = (agent.sys.frontmatter ?? '') + systemPrompt;
|
|
|
|
|
+ const dest = join(agent.base(), agent.sys.file);
|
|
|
|
|
+ const expectedHash = createHash('sha256').update(expected).digest('hex');
|
|
|
|
|
+ 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, expected);
|
|
|
|
|
+ }
|
|
|
|
|
+ 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.
|
|
// Orchestrates the sync and prints a per-entry report with a final summary.
|
|
|
function main() {
|
|
function main() {
|
|
|
if (PLAN.help) {
|
|
if (PLAN.help) {
|
|
@@ -319,10 +356,14 @@ function main() {
|
|
|
console.error(`error: bundled content not found under ${PACKAGE_ROOT}`);
|
|
console.error(`error: bundled content not found under ${PACKAGE_ROOT}`);
|
|
|
process.exit(EXIT_FAILURE);
|
|
process.exit(EXIT_FAILURE);
|
|
|
}
|
|
}
|
|
|
- const hasContent = PLAN.all || PLAN.skills || PLAN.agents;
|
|
|
|
|
|
|
+ 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.list && (hasContent || PLAN.targets)) fail('error: --list cannot be combined with selection flags');
|
|
|
- if (PLAN.all && (PLAN.skills || PLAN.agents)) fail('error: --all cannot be combined with --skills or --agents');
|
|
|
|
|
- if (PLAN.targets && !hasContent) fail('error: --targets requires --all, --skills or --agents');
|
|
|
|
|
|
|
+ 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) {
|
|
if (PLAN.list) {
|
|
|
printCatalog();
|
|
printCatalog();
|
|
|
process.exit(EXIT_OK);
|
|
process.exit(EXIT_OK);
|
|
@@ -331,17 +372,20 @@ function main() {
|
|
|
console.log(HELP);
|
|
console.log(HELP);
|
|
|
process.exit(EXIT_OK);
|
|
process.exit(EXIT_OK);
|
|
|
}
|
|
}
|
|
|
- const { skills, agents: agentFiles, targets } = resolveSelection(PLAN);
|
|
|
|
|
|
|
+ const { skills, agents: agentFiles, targets, sys } = resolveSelection(PLAN);
|
|
|
|
|
|
|
|
const detected = detectAgents(targets);
|
|
const detected = detectAgents(targets);
|
|
|
if (targets && detected.length === 0) {
|
|
if (targets && detected.length === 0) {
|
|
|
fail(`error: none of the requested targets is installed (${targets.join(', ')})`);
|
|
fail(`error: none of the requested targets is installed (${targets.join(', ')})`);
|
|
|
}
|
|
}
|
|
|
- if (!DRY_RUN) {
|
|
|
|
|
|
|
+ if ((skills.length > 0 || PLAN.all) && !DRY_RUN) {
|
|
|
mkdirSync(CANONICAL_SKILLS_DIR, { recursive: true });
|
|
mkdirSync(CANONICAL_SKILLS_DIR, { recursive: true });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const report = [ensureCanonicalAgentsDir(), ...syncCanonical(skills, agentFiles)];
|
|
|
|
|
|
|
+ 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) {
|
|
for (const agent of detected) {
|
|
|
report.push(...linkForAgent(agent, skills));
|
|
report.push(...linkForAgent(agent, skills));
|
|
|
}
|
|
}
|