|
|
@@ -2,20 +2,23 @@
|
|
|
// 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)
|
|
|
+// Selection is explicit (--all, --skills, --agents, --targets): without a
|
|
|
+// selection flag the tool prints its help. See HELP for the full surface.
|
|
|
|
|
|
import { createHash } from 'node:crypto';
|
|
|
import {
|
|
|
- copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync,
|
|
|
- readFileSync, realpathSync, rmSync, statSync, symlinkSync, linkSync, writeFileSync,
|
|
|
+ cpSync, existsSync, lstatSync, mkdirSync, readdirSync,
|
|
|
+ readFileSync, realpathSync, rmSync, statSync, symlinkSync, linkSync,
|
|
|
} from 'node:fs';
|
|
|
import { homedir, platform } from 'node:os';
|
|
|
-import { basename, dirname, join, relative, sep } from 'node:path';
|
|
|
+import { basename, dirname, join, relative } from 'node:path';
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
|
+const EXIT_OK = 0;
|
|
|
+const EXIT_FAILURE = 1;
|
|
|
+const EXIT_USAGE = 2;
|
|
|
+
|
|
|
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');
|
|
|
@@ -24,6 +27,27 @@ const SOURCE_AGENTS_DIR = join(PACKAGE_ROOT, 'agents');
|
|
|
const CANONICAL_SKILLS_DIR = join(homedir(), '.agents', 'skills');
|
|
|
const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents');
|
|
|
|
|
|
+const HELP = `agent-sync — installs bundled skills and agent prompts into the
|
|
|
+canonical ~/.agents directory and links them into the AI agents detected
|
|
|
+on the machine.
|
|
|
+
|
|
|
+Usage:
|
|
|
+ agent-sync --all [--check] [--targets [<names>]]
|
|
|
+ agent-sync --skills [<names>] [--agents [<names>]] [--check] [--targets [<names>]]
|
|
|
+ agent-sync --list
|
|
|
+ agent-sync --help
|
|
|
+
|
|
|
+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.
|
|
|
+
|
|
|
+ --check dry-run, reports drift without writing anything (exit 1 on drift)
|
|
|
+
|
|
|
+Exit codes: ${EXIT_OK} ok or help, ${EXIT_FAILURE} drift (dry-run) or failure, ${EXIT_USAGE} usage error.`;
|
|
|
+
|
|
|
// Resolves the Claude Code config dir, honoring CLAUDE_CONFIG_DIR when set.
|
|
|
function claudeBase() {
|
|
|
return process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude');
|
|
|
@@ -51,6 +75,99 @@ const AGENT_REGISTRY = [
|
|
|
{ name: 'aider-desk', base: homeDir('.aider-desk'), skillsSub: 'skills' },
|
|
|
];
|
|
|
|
|
|
+// Prints a usage error and exits with the usage exit code.
|
|
|
+function fail(message) {
|
|
|
+ console.error(`${message}\nRun 'agent-sync --help' for usage.`);
|
|
|
+ process.exit(EXIT_USAGE);
|
|
|
+}
|
|
|
+
|
|
|
+// Returns the names of the bundled skills (directories under skills/).
|
|
|
+function bundledSkillNames() {
|
|
|
+ return readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })
|
|
|
+ .filter((entry) => entry.isDirectory())
|
|
|
+ .map((entry) => entry.name);
|
|
|
+}
|
|
|
+
|
|
|
+// Returns the file names of the bundled agent prompts (*.md under agents/).
|
|
|
+function bundledAgentFiles() {
|
|
|
+ return readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'));
|
|
|
+}
|
|
|
+
|
|
|
+// Dimension flags hold three states: null (flag absent), true (whole
|
|
|
+// dimension) or an array of names. A repeated flag accumulates names, and a
|
|
|
+// valueless occurrence widens the dimension to everything.
|
|
|
+function mergeDimension(current, value) {
|
|
|
+ if (current === true || value === null) return true;
|
|
|
+ return [...(current ?? []), ...value];
|
|
|
+}
|
|
|
+
|
|
|
+const DIMENSION_FLAGS = { '--skills': 'skills', '--agents': 'agents', '--targets': 'targets' };
|
|
|
+
|
|
|
+// Parses the command line into a run plan (syntax only: names are validated
|
|
|
+// later against the bundled content). Selection flags are --all, --skills,
|
|
|
+// --agents and --targets; every other flag is a modifier.
|
|
|
+function parseArgs(argv) {
|
|
|
+ const plan = { help: false, list: false, dryRun: false, all: false, skills: null, agents: null, targets: null };
|
|
|
+ const optionalList = (index, flag) => {
|
|
|
+ const value = argv[index + 1];
|
|
|
+ if (!value || value.startsWith('--')) return null;
|
|
|
+ const names = value.split(',').map((name) => name.trim()).filter(Boolean);
|
|
|
+ if (names.length === 0) fail(`error: ${flag} received an empty list of names`);
|
|
|
+ return names;
|
|
|
+ };
|
|
|
+ for (let i = 0; i < argv.length; i += 1) {
|
|
|
+ const arg = argv[i];
|
|
|
+ if (arg === '--help' || arg === '-h') plan.help = true;
|
|
|
+ else if (arg === '--list') plan.list = true;
|
|
|
+ else if (arg === '--check') plan.dryRun = true;
|
|
|
+ else if (arg === '--all') plan.all = true;
|
|
|
+ else if (DIMENSION_FLAGS[arg]) {
|
|
|
+ const key = DIMENSION_FLAGS[arg];
|
|
|
+ const value = optionalList(i, arg);
|
|
|
+ plan[key] = mergeDimension(plan[key], value);
|
|
|
+ if (value !== null) i += 1;
|
|
|
+ }
|
|
|
+ else fail(`error: unknown argument '${arg}'`);
|
|
|
+ }
|
|
|
+ return plan;
|
|
|
+}
|
|
|
+
|
|
|
+// Validates the requested names against the bundled content and the agent
|
|
|
+// registry, then resolves the effective selection. A dimension set to true
|
|
|
+// expands to everything; prompt names are given without extension (gv-dev)
|
|
|
+// and mapped to their bundled files (gv-dev.md).
|
|
|
+function resolveSelection(plan) {
|
|
|
+ const skillNames = bundledSkillNames();
|
|
|
+ const agentFiles = bundledAgentFiles();
|
|
|
+ const registryNames = AGENT_REGISTRY.map((agent) => agent.name);
|
|
|
+ const pickSkill = (name) => {
|
|
|
+ if (!skillNames.includes(name)) fail(`error: unknown skill '${name}' (available: ${skillNames.join(', ')})`);
|
|
|
+ return name;
|
|
|
+ };
|
|
|
+ const pickAgent = (name) => {
|
|
|
+ const file = agentFiles.find((candidate) => basename(candidate, '.md') === name);
|
|
|
+ if (!file) fail(`error: unknown agent '${name}' (available: ${agentFiles.map((f) => basename(f, '.md')).join(', ')})`);
|
|
|
+ return file;
|
|
|
+ };
|
|
|
+ const pickTarget = (name) => {
|
|
|
+ if (!registryNames.includes(name)) fail(`error: unknown target '${name}' (available: ${registryNames.join(', ')})`);
|
|
|
+ return name;
|
|
|
+ };
|
|
|
+ return {
|
|
|
+ skills: plan.all || plan.skills === true ? skillNames : (plan.skills ?? []).map(pickSkill),
|
|
|
+ agents: plan.all || plan.agents === true ? agentFiles : (plan.agents ?? []).map(pickAgent),
|
|
|
+ targets: plan.targets === true || !plan.targets ? null : plan.targets.map(pickTarget),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// Prints the bundled catalog plus the AI agents detected on this machine.
|
|
|
+function printCatalog() {
|
|
|
+ const detected = AGENT_REGISTRY.filter((agent) => existsSync(agent.base())).map((agent) => agent.name);
|
|
|
+ console.log(`skills: ${bundledSkillNames().join(', ')}`);
|
|
|
+ console.log(`agents: ${bundledAgentFiles().map((file) => basename(file, '.md')).join(', ')}`);
|
|
|
+ console.log(`targets: ${AGENT_REGISTRY.map((agent) => `${agent.name}${detected.includes(agent.name) ? '*' : ''}`).join(', ')} (* = detected)`);
|
|
|
+}
|
|
|
+
|
|
|
// 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;
|
|
|
@@ -91,37 +208,52 @@ function pointsTo(linkPath, targetPath) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// Copies bundled skills and agents into the canonical directory, replacing
|
|
|
-// entries whose content differs. Returns the list of performed actions.
|
|
|
-function syncCanonical() {
|
|
|
+// Ensures the canonical agents directory exists as a real directory, so the
|
|
|
+// individual .md links have a place to live. An earlier version linked the
|
|
|
+// whole folder with a junction: such a link to our own source is unwound
|
|
|
+// here (removing the junction never touches the source files).
|
|
|
+function ensureCanonicalAgentsDir() {
|
|
|
+ const current = lstatOrNull(CANONICAL_AGENTS_DIR);
|
|
|
+ if (!current) {
|
|
|
+ if (DRY_RUN) return { label: 'agents', status: 'link' };
|
|
|
+ mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true });
|
|
|
+ return { label: 'agents', status: 'added' };
|
|
|
+ }
|
|
|
+ if (current.isSymbolicLink()) {
|
|
|
+ if (!pointsTo(CANONICAL_AGENTS_DIR, SOURCE_AGENTS_DIR)) return { label: 'agents', status: 'skipped' };
|
|
|
+ if (DRY_RUN) return { label: 'agents', status: 'relink' };
|
|
|
+ rmSync(CANONICAL_AGENTS_DIR, { recursive: true, force: true });
|
|
|
+ mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true });
|
|
|
+ return { label: 'agents', status: 'relinked' };
|
|
|
+ }
|
|
|
+ return current.isDirectory() ? { label: 'agents', status: 'up-to-date' } : { label: 'agents', status: 'skipped' };
|
|
|
+}
|
|
|
+
|
|
|
+// Copies the selected skills into the canonical directory and links the agent
|
|
|
+// prompt files individually (no copies, ever). Returns the list of actions.
|
|
|
+function syncCanonical(skills, agentFiles) {
|
|
|
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);
|
|
|
+ for (const name of skills) {
|
|
|
+ const source = join(SOURCE_SKILLS_DIR, name);
|
|
|
+ const dest = join(CANONICAL_SKILLS_DIR, 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 });
|
|
|
+ actions.push({ label: `skills/${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 });
|
|
|
+ for (const file of agentFiles) {
|
|
|
+ actions.push({ label: `agents/${file}`, status: ensureLink(join(CANONICAL_AGENTS_DIR, file), join(SOURCE_AGENTS_DIR, file), { convertForeign: true }) });
|
|
|
}
|
|
|
return actions;
|
|
|
}
|
|
|
|
|
|
-// Detects installed agents by probing their config directory.
|
|
|
-function detectAgents() {
|
|
|
+// Detects installed agents by probing their config directory, optionally
|
|
|
+// restricted to the requested target names.
|
|
|
+function detectAgents(targetNames) {
|
|
|
return AGENT_REGISTRY
|
|
|
- .filter((agent) => existsSync(agent.base()))
|
|
|
+ .filter((agent) => (!targetNames || targetNames.includes(agent.name)) && existsSync(agent.base()))
|
|
|
.map((agent) => ({ ...agent, baseDir: agent.base() }));
|
|
|
}
|
|
|
|
|
|
@@ -129,19 +261,28 @@ function detectAgents() {
|
|
|
// 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) {
|
|
|
+// hardlink as the windows file fallback. Copies are never created: when no
|
|
|
+// link mechanism works the sync aborts with an explicit error. With
|
|
|
+// convertForeign an existing regular file (leftover from old copy-based
|
|
|
+// installs) is replaced by a proper link instead of being kept as a copy.
|
|
|
+function ensureLink(linkPath, targetPath, { convertForeign = false } = {}) {
|
|
|
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';
|
|
|
+ if (convertForeign && current.isFile()) {
|
|
|
+ if (DRY_RUN) return 'relink';
|
|
|
+ rmSync(linkPath, { force: true });
|
|
|
+ } else {
|
|
|
+ // Regular files left by this tool's own windows fallbacks (hardlink, or
|
|
|
+ // copies from versions that still had a copy fallback) 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';
|
|
|
}
|
|
|
- return 'skipped';
|
|
|
}
|
|
|
if (DRY_RUN) return 'link';
|
|
|
mkdirSync(dirname(linkPath), { recursive: true });
|
|
|
@@ -158,99 +299,74 @@ function ensureLink(linkPath, targetPath) {
|
|
|
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.
|
|
|
+ // Windows symlinks need Developer Mode: degrade to a hardlink (same
|
|
|
+ // volume). A copy is never a fallback: failing loudly keeps the "links
|
|
|
+ // only" guarantee instead of silently diverging content.
|
|
|
try {
|
|
|
linkSync(targetPath, linkPath);
|
|
|
return 'hardlinked';
|
|
|
- } catch {
|
|
|
- copyFileSync(targetPath, linkPath);
|
|
|
- return 'copied';
|
|
|
+ } catch (error) {
|
|
|
+ throw new Error(`cannot link '${linkPath}' without a copy (symlink and hardlink both failed: ${error.message})`);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 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) {
|
|
|
+// Links the selected skills (source package names only, so third-party
|
|
|
+// content in the canonical directory is never propagated) into one detected
|
|
|
+// agent's directories. Agent prompts are deliberately not linked here: they
|
|
|
+// are managed only inside the canonical ~/.agents/agents directory.
|
|
|
+function linkForAgent(agent, skills) {
|
|
|
const actions = [];
|
|
|
- for (const name of readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })
|
|
|
- .filter((entry) => entry.isDirectory())
|
|
|
- .map((entry) => entry.name)) {
|
|
|
+ for (const name of skills) {
|
|
|
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 (PLAN.help) {
|
|
|
+ console.log(HELP);
|
|
|
+ process.exit(EXIT_OK);
|
|
|
}
|
|
|
if (!existsSync(SOURCE_SKILLS_DIR) || !existsSync(SOURCE_AGENTS_DIR)) {
|
|
|
console.error(`error: bundled content not found under ${PACKAGE_ROOT}`);
|
|
|
- process.exit(1);
|
|
|
+ process.exit(EXIT_FAILURE);
|
|
|
+ }
|
|
|
+ const hasContent = PLAN.all || PLAN.skills || PLAN.agents;
|
|
|
+ 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.list) {
|
|
|
+ printCatalog();
|
|
|
+ process.exit(EXIT_OK);
|
|
|
+ }
|
|
|
+ if (!hasContent) {
|
|
|
+ console.log(HELP);
|
|
|
+ process.exit(EXIT_OK);
|
|
|
+ }
|
|
|
+ const { skills, agents: agentFiles, targets } = resolveSelection(PLAN);
|
|
|
+
|
|
|
+ const detected = detectAgents(targets);
|
|
|
+ if (targets && detected.length === 0) {
|
|
|
+ fail(`error: none of the requested targets is installed (${targets.join(', ')})`);
|
|
|
}
|
|
|
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));
|
|
|
+ const report = [ensureCanonicalAgentsDir(), ...syncCanonical(skills, agentFiles)];
|
|
|
+ for (const agent of detected) {
|
|
|
+ report.push(...linkForAgent(agent, skills));
|
|
|
}
|
|
|
|
|
|
for (const { label, status } of report) {
|
|
|
console.log(`${status.padEnd(12)} ${label}`);
|
|
|
}
|
|
|
- console.log(`\nagents detected: ${agents.map((agent) => agent.name).join(', ') || 'none'}`);
|
|
|
+ console.log(`\nagents detected: ${detected.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.');
|
|
|
@@ -258,13 +374,16 @@ function main() {
|
|
|
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);
|
|
|
+ process.exit(EXIT_FAILURE);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const PLAN = parseArgs(process.argv.slice(2));
|
|
|
+const DRY_RUN = PLAN.dryRun;
|
|
|
+
|
|
|
try {
|
|
|
main();
|
|
|
} catch (error) {
|
|
|
console.error(`error: ${error.message}`);
|
|
|
- process.exit(1);
|
|
|
+ process.exit(EXIT_FAILURE);
|
|
|
}
|