Files
2026-09-13 00:25:10 +08:00

1006 lines
47 KiB
JavaScript

// `evolver setup-hooks` — wire (or remove) evolver into an agent runtime. Drives the evolver-mcp installer:
// for Claude Code it registers the evolver MCP server AND merges a SessionStart hook so memory is injected at
// session start (the hybrid). `--scope=user` registers into ~/.claude.json so evolver loads in EVERY project
// (the real device-wide scope); `--scope=project` (default) writes the project's own .mcp.json. Deny-nothing:
// it only writes the runtime's own config, is idempotent, refuses symlinked adapter paths, and `--uninstall`
// cleanly removes only evolver's entries.
import { planInjection, installInjection, uninstallInjection, runtimeSupport, renderManualWiring, renderServiceGuidance, SETUP_RUNTIMES, SERVICE_TARGETS, kiroConfigRoot, resolveOpenCodeConfig, McpConfigConflictError, McpConfigOwnershipError, McpConfigVerificationError, McpServerValidationError } from '@evomap/evolver-mcp';
import { assetstore, events } from '@evomap/evolver-core';
import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { basename, delimiter, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { currentTopCursorGenes } from './cursorRewrite.js';
import { resolveProxyBinPath, resolveStableNodePath } from './lifecycle.js';
import { reviewLedgerForStore } from './reviewFilter.js';
import { maybeEmitNonGitWorkspaceNotice } from './nonGitWorkspaceNotice.js';
import { createSetupLifecyclePlan, decodeSetupLifecyclePlan, executeSetupLifecyclePlan, SETUP_LIFECYCLE_SCHEMA, setupLifecycleCapabilities, SetupLifecycleError, verifySetupLifecycle, } from './setupHooksLifecycle.js';
const requireFromHere = createRequire(import.meta.url);
function configuredHomeDir() {
const configured = process.platform === 'win32'
? process.env['USERPROFILE']
: process.env['HOME'];
return configured && configured.trim() ? configured : homedir();
}
function parseFlags(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a && a.startsWith('--')) {
const key = a.slice(2);
const eq = key.indexOf('=');
if (eq >= 0) {
out[key.slice(0, eq)] = key.slice(eq + 1);
}
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) {
out[key] = argv[++i];
}
else {
out[key] = true;
}
}
}
return out;
}
const USAGE = `usage: evolver setup-hooks [--runtime=${SETUP_RUNTIMES.join('|')}] [--scope=user|project|global] [--root=<dir>] [--lifecycle=capabilities|plan|verify|execute] [--action=install|uninstall] [--confirmed-plan=<base64url>] [--target-id=<id>] [--completed-targets=<id,id>] [--env-file=<path>] [--profile-descriptor=<json>] [--service=${SERVICE_TARGETS.join('|')}] [--verify] [--uninstall] [--dry-run] [--force] [--json] [--hook-command="evolver inject session-start"] [--server-node=<absolute-node>] [--server-command=<cmd>] [--server-args=a,b] [--service-command=<cmd>] [--service-args=a,b]\n --verify is read-only for opencode and kiro\n lifecycle operations are versioned JSON and require --json\n`;
export function commandNamesForPath(command, platform, pathExt) {
if (platform !== 'win32' || /\.[^\\/]+$/.test(command))
return [command];
const extensions = (pathExt?.trim() || '.COM;.EXE;.BAT;.CMD')
.split(';')
.map((extension) => extension.trim())
.filter((extension) => /^\.?[A-Za-z\d]+$/.test(extension))
.map((extension) => extension.startsWith('.') ? extension : `.${extension}`);
return [command, ...extensions.map((extension) => `${command}${extension}`)];
}
class StableNodePathError extends Error {
constructor() {
super('[setup-hooks] cannot resolve a stable absolute Node executable outside package-manager stores; install Node at a standard system path or pass --server-node=<absolute-node>');
this.name = 'StableNodePathError';
}
}
class McpNodeOverrideError extends Error {
constructor(message) {
super(`[setup-hooks] ${message}`);
this.name = 'McpNodeOverrideError';
}
}
/** Paths under package-manager stores are volatile: the linked Node disappears on the next install/upgrade,
* so a persisted command pointing there breaks the generated MCP config. Exported for tests so the assertion
* regex cannot drift from the resolver's. */
export const VOLATILE_PACKAGE_MANAGER_NODE_PATH = /(?:^|[\\/])(?:pnpm[\\/]store|store[\\/]v\d+[\\/]links|node_modules[\\/]\.pnpm|node_modules[\\/]\.bin|\.npm[\\/]_npx)(?:[\\/]|$)/i;
function executableFile(path) {
try {
if (!statSync(path).isFile())
return false;
accessSync(path, constants.X_OK);
return true;
}
catch {
return false;
}
}
function stableNodeExecutablePath(path, preserveStableEntry = false) {
if (!isAbsolute(path) || !/^node(?:js)?(?:\.exe)?$/i.test(basename(path)) || !executableFile(path))
return undefined;
if (preserveStableEntry && VOLATILE_PACKAGE_MANAGER_NODE_PATH.test(path))
return undefined;
try {
const canonical = realpathSync(path);
if (VOLATILE_PACKAGE_MANAGER_NODE_PATH.test(canonical)
|| !/^node(?:js)?(?:\.exe)?$/i.test(basename(canonical))
|| !executableFile(canonical))
return undefined;
return preserveStableEntry ? resolve(path) : canonical;
}
catch {
return undefined;
}
}
function commandPaths(commands, platform, env) {
const names = commands.flatMap((command) => commandNamesForPath(command, platform, env['PATHEXT']));
const candidates = [];
for (const rawDirectory of (env['PATH'] ?? '').split(delimiter).filter(Boolean)) {
const directory = rawDirectory.trim().replace(/^"|"$/g, '');
for (const name of names) {
const candidate = resolve(directory, name);
if (executableFile(candidate))
candidates.push(candidate);
}
}
return candidates;
}
function commandPath(command, platform, env) {
return commandPaths([command], platform, env)[0];
}
export function resolveStableMcpNodePath(options = {}) {
const execPath = options.execPath ?? process.execPath;
const platform = options.platform ?? process.platform;
const stableExecPath = stableNodeExecutablePath(execPath);
if (stableExecPath)
return stableExecPath;
// Do not persist a PATH candidate: PATH and ProgramFiles-style environment variables are
// caller-controlled and the selected file can be replaced after validation. Fixed system
// locations are the only automatic fallback; non-standard layouts use --server-command.
const systemCandidates = options.systemCandidates ?? (platform === 'win32'
? []
: [
'/opt/homebrew/bin/node',
'/usr/local/bin/node',
'/usr/local/bin/nodejs',
'/usr/bin/node',
'/usr/bin/nodejs',
]);
for (const candidate of systemCandidates) {
if (!candidate)
continue;
const stableCandidate = stableNodeExecutablePath(candidate, true);
if (stableCandidate)
return stableCandidate;
}
throw new StableNodePathError();
}
function commandOnPath(command) {
return commandPath(command, process.platform, process.env) !== undefined;
}
function defaultRuntimeAvailable(runtime, configRoot, scope) {
if (runtime === 'opencode') {
const resolution = resolveOpenCodeConfig({
configRoot,
scope,
homeDir: configuredHomeDir(),
xdgConfigHome: process.env['XDG_CONFIG_HOME'],
opencodeConfig: process.env['OPENCODE_CONFIG'],
opencodeConfigDir: process.env['OPENCODE_CONFIG_DIR'],
});
return commandOnPath('opencode') || (resolution.evidencePaths ?? [resolution.configPath]).some(existsSync);
}
const pathOptions = {
configRoot,
homeDir: configuredHomeDir(),
kiroHome: process.env['KIRO_HOME'],
};
const evidenceRoots = scope === 'project'
? [
kiroConfigRoot({ ...pathOptions, scope: 'project' }),
kiroConfigRoot({ ...pathOptions, scope: 'user' }),
]
: [kiroConfigRoot({ ...pathOptions, scope: 'user' })];
return commandOnPath('kiro') || commandOnPath('kiro-cli') || evidenceRoots.some(existsSync);
}
function displaySetupPath(path, configRoot, scope) {
if (scope === 'user') {
const home = configuredHomeDir();
const homePath = safeRelativePath(home, path);
if (homePath === '')
return '~';
if (homePath !== undefined)
return `~/${homePath.replaceAll('\\', '/')}`;
}
const projectPath = safeProjectRelativePath(configRoot, path);
if (projectPath === '')
return '.';
return projectPath !== undefined ? projectPath.replaceAll('\\', '/') : '<config-path>';
}
function safeRelativePath(root, path) {
const candidate = relative(root, path);
if (isAbsolute(candidate) || candidate === '..' || candidate.startsWith(`..${sep}`))
return undefined;
return candidate;
}
function safeProjectRelativePath(root, path) {
const descendant = safeRelativePath(root, path);
if (descendant !== undefined)
return descendant;
return safeRelativePath(dirname(path), root) === undefined ? undefined : relative(root, path);
}
function displayResultPaths(paths, configRoot, scope) {
return paths?.map((path) => displaySetupPath(path, configRoot, scope));
}
export function safeSetupOperationError(error) {
if (error instanceof McpConfigConflictError)
return error.message;
if (error instanceof McpConfigOwnershipError)
return error.message;
if (error instanceof McpConfigVerificationError) {
return error.restored
? '[setup-hooks] runtime configuration verification failed; the previous configuration was restored.'
: '[setup-hooks] runtime configuration verification failed; rollback could not be confirmed. Inspect the runtime config before retrying.';
}
if (error instanceof McpServerValidationError)
return error.message;
if (error instanceof StableNodePathError)
return error.message;
if (error instanceof McpNodeOverrideError)
return error.message;
const name = error instanceof Error ? error.name : '';
switch (name) {
case 'McpConfigConflictError': return '[setup-hooks] existing Evolver configuration conflicts; use --force to replace only the Evolver entry.';
case 'McpConfigOwnershipError': return '[setup-hooks] Evolver ownership metadata is ambiguous or no longer matches the runtime configuration; inspect it before retrying.';
case 'McpConfigShapeError': return '[setup-hooks] runtime configuration has an invalid JSON shape.';
case 'McpConfigVerificationError': return error.restored === true
? '[setup-hooks] runtime configuration verification failed; the previous configuration was restored.'
: '[setup-hooks] runtime configuration verification failed; rollback could not be confirmed. Inspect the runtime config before retrying.';
case 'StableNodePathError': return '[setup-hooks] cannot resolve a stable absolute Node executable outside package-manager stores; install Node at a standard system path or pass --server-node=<absolute-node>';
case 'McpNodeOverrideError': return '[setup-hooks] invalid --server-node configuration';
case 'McpConfigChangedError': return '[setup-hooks] runtime configuration changed during the operation; retry after the runtime finishes writing it.';
case 'EmptySharedConfigError': return '[setup-hooks] runtime configuration is empty; repair or remove it before retrying.';
case 'UnparseableConfigError': return '[setup-hooks] runtime configuration is not valid JSON; repair it before retrying.';
case 'SymlinkRefusedError': return '[setup-hooks] refused an unsafe symbolic-link configuration path.';
default: return '[setup-hooks] runtime configuration operation failed.';
}
}
export function safeSetupResultError(error) {
if (typeof error !== 'string')
return safeSetupOperationError(error);
const reason = error.replace(/\s+/g, ' ').trim();
const passive = reason.match(/^passive runtime ([a-z0-9][a-z0-9-]{0,63}): no tool injection$/);
const unimplementedInstall = reason.match(/^installer not yet implemented for ([a-z0-9][a-z0-9-]{0,63}) \(supported: [a-z0-9, -]+\)$/);
const unimplementedUninstall = reason.match(/^uninstall not implemented for ([a-z0-9][a-z0-9-]{0,63})$/);
const runtime = passive?.[1] ?? unimplementedInstall?.[1] ?? unimplementedUninstall?.[1];
if (!runtime || !SETUP_RUNTIMES.includes(runtime)) {
return safeSetupOperationError(error);
}
if (passive)
return `[setup-hooks] passive runtime ${runtime}: no tool injection`;
if (unimplementedInstall)
return `[setup-hooks] installer not yet implemented for ${runtime}`;
return `[setup-hooks] uninstall not implemented for ${runtime}`;
}
const PROFILE_DESCRIPTOR_FLAG = 'profile-descriptor';
const SECRET_ASSIGNMENT_RE = /\b(?:A2A_NODE_SECRET|EVOMAP_ENTERPRISE_TOKEN|EVOMAP_PRIVATE_HUB_TOKEN|EVOMAP_NODE_SECRET|EVOLVER_IPC_TOKEN|EVOLVER_LLM_TOKEN|PHUB_ENTERPRISE_TOKEN|PRIVATE_HUB_ENTERPRISE_TOKEN)\b\s*[:=]\s*["']?[^"'\s<]+/;
const INLINE_BEARER_RE = /\bBearer\s+(?!<|\$|%|REDACTED\b|TOKEN\b|token\b|env:)[A-Za-z0-9._~+/=-]{12,}/i;
const INLINE_SECRET_FLAG_RE = /(?:^|\s)--?(?:api[-_]?key|password|passwd|secret|token)(?:=|\s+)["']?(?!<|\$|%|REDACTED\b|TOKEN\b|token\b|env:)[^"'\s]+/i;
const RAW_SECRET_VALUE_RE = /^(?=.{16,}$)(?!.*[\\/])(?=.*(?:evomap|key|phub|private|secret|token))(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9._~+/=-]+$/i;
/** Map and validate --scope before any config write. Omitted keeps the legacy project default; values are
* intentionally case-sensitive so typos do not silently install into project scope. */
function parseScope(raw) {
if (raw === undefined)
return { ok: true, scope: 'project' };
if (raw === 'project')
return { ok: true, scope: 'project' };
if (raw === 'user' || raw === 'global')
return { ok: true, scope: 'user' };
if (raw === true) {
return { ok: false, error: 'missing value for --scope (expected: project|user|global)' };
}
return { ok: false, error: `invalid --scope '${raw}' (expected: project|user|global; values are case-sensitive)` };
}
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function hasInlineSecretSyntax(value) {
return SECRET_ASSIGNMENT_RE.test(value) || INLINE_BEARER_RE.test(value) || INLINE_SECRET_FLAG_RE.test(value);
}
function hasInlineSecretHint(hint) {
const trimmed = hint.trim();
return hasInlineSecretSyntax(trimmed) || RAW_SECRET_VALUE_RE.test(trimmed);
}
function looksLikeEnvFilePointer(value) {
return /[\\/]/.test(value)
|| /^[A-Za-z]:[\\/]/.test(value)
|| /^[.~$%]/.test(value)
|| /\.(?:env|dotenv)(?:$|[._-])/i.test(value);
}
function validatePointerValue(label, value) {
const trimmed = value.trim();
return hasInlineSecretSyntax(trimmed) || (!looksLikeEnvFilePointer(trimmed) && RAW_SECRET_VALUE_RE.test(trimmed))
? `--${PROFILE_DESCRIPTOR_FLAG}.${label} contains an inline secret-looking value; use an EVOLVER_ENV_FILE pointer path instead`
: undefined;
}
function profileDescriptorFromFlag(f) {
const rawPath = f[PROFILE_DESCRIPTOR_FLAG];
if (rawPath === undefined)
return { ok: true, descriptor: { manualHints: {} } };
if (rawPath === true || rawPath.trim().length === 0) {
return { ok: false, error: `missing value for --${PROFILE_DESCRIPTOR_FLAG}` };
}
let parsed;
try {
parsed = JSON.parse(readFileSync(rawPath, 'utf8'));
}
catch (error) {
return { ok: false, error: `cannot read --${PROFILE_DESCRIPTOR_FLAG}: ${error instanceof Error ? error.message : String(error)}` };
}
if (!isRecord(parsed)) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG} must be a JSON object` };
}
const envFileRaw = parsed['envFile'] ?? parsed['env_file'];
let envFile;
if (envFileRaw !== undefined) {
if (typeof envFileRaw !== 'string' || envFileRaw.trim().length === 0) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.envFile must be a non-empty string` };
}
envFile = envFileRaw.trim();
const error = validatePointerValue('envFile', envFile);
if (error)
return { ok: false, error };
}
const mcpServer = parseDescriptorServer(parsed['mcpServer'] ?? parsed['mcp_server'], 'mcpServer');
if (!mcpServer.ok)
return mcpServer;
const serviceExec = parseDescriptorServer(parsed['serviceExec'] ?? parsed['service_exec'], 'serviceExec');
if (!serviceExec.ok)
return serviceExec;
const hintsByRuntime = parsed['manualHints'] ?? parsed['manual_hints'];
const manualHints = {};
if (hintsByRuntime === undefined) {
return {
ok: true,
descriptor: {
...(envFile !== undefined ? { envFile } : {}),
...(mcpServer.server ? { mcpServer: mcpServer.server } : {}),
...(serviceExec.server ? { serviceExec: serviceExec.server } : {}),
manualHints,
},
};
}
if (!isRecord(hintsByRuntime)) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.manualHints must be an object keyed by runtime` };
}
for (const [runtime, runtimeHints] of Object.entries(hintsByRuntime)) {
if (!Array.isArray(runtimeHints) || runtimeHints.some((hint) => typeof hint !== 'string')) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.manualHints.${runtime} must be an array of strings` };
}
const hints = runtimeHints.map((hint) => hint.trim()).filter(Boolean);
const secretHint = hints.find(hasInlineSecretHint);
if (secretHint) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.manualHints.${runtime} contains an inline secret-looking value; use an EVOLVER_ENV_FILE pointer instead` };
}
manualHints[runtime] = hints;
}
return {
ok: true,
descriptor: {
...(envFile !== undefined ? { envFile } : {}),
...(mcpServer.server ? { mcpServer: mcpServer.server } : {}),
...(serviceExec.server ? { serviceExec: serviceExec.server } : {}),
manualHints,
},
};
}
function parseDescriptorServer(value, label) {
if (value === undefined)
return { ok: true };
if (!isRecord(value)) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.${label} must be an object` };
}
if (typeof value['command'] !== 'string' || value['command'].trim().length === 0) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.${label}.command must be a non-empty string` };
}
const argsRaw = value['args'];
if (argsRaw !== undefined && (!Array.isArray(argsRaw) || argsRaw.some((arg) => typeof arg !== 'string'))) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.${label}.args must be an array of strings` };
}
const command = value['command'].trim();
const args = argsRaw;
const commandLine = [command, ...(args ?? [])].join(' ');
if (hasInlineSecretHint(commandLine)) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.${label}.command/args contain an inline secret-looking value; use an EVOLVER_ENV_FILE pointer instead` };
}
const envRaw = value['env'];
let env;
if (envRaw !== undefined) {
if (!isRecord(envRaw))
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.${label}.env must be an object` };
const keys = Object.keys(envRaw);
const unsupported = keys.filter((key) => key !== 'EVOLVER_ENV_FILE');
if (unsupported.length > 0) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.${label}.env may only contain EVOLVER_ENV_FILE` };
}
const envFile = envRaw['EVOLVER_ENV_FILE'];
if (envFile !== undefined) {
if (typeof envFile !== 'string' || envFile.trim().length === 0) {
return { ok: false, error: `--${PROFILE_DESCRIPTOR_FLAG}.${label}.env.EVOLVER_ENV_FILE must be a non-empty string` };
}
const pointer = envFile.trim();
const error = validatePointerValue(`${label}.env.EVOLVER_ENV_FILE`, pointer);
if (error)
return { ok: false, error };
env = { EVOLVER_ENV_FILE: pointer };
}
}
return {
ok: true,
server: {
command,
...(args !== undefined ? { args } : {}),
...(env ? { env } : {}),
},
};
}
function argsFromFlag(value) {
return typeof value === 'string' ? value.split(',').filter(Boolean) : undefined;
}
function defaultMcpServerArgs() {
let stdioPath;
try {
stdioPath = requireFromHere.resolve('@evomap/evolver-mcp/stdio');
}
catch {
stdioPath = fileURLToPath(new URL('../../evolver-mcp/dist/stdio.js', import.meta.url));
}
return [stdioPath];
}
function defaultMcpServer(resolveNodePath = resolveStableMcpNodePath) {
return { command: resolveNodePath(), args: defaultMcpServerArgs() };
}
function defaultProxyDaemon() {
return { command: resolveStableNodePath(), args: [resolveProxyBinPath() ?? '/ABSOLUTE/PATH/TO/evolver-proxy.js'] };
}
function withEnvFile(server, envFile) {
if (typeof envFile !== 'string' || !envFile.trim())
return server;
return {
...server,
env: {
...(server.env ?? {}),
EVOLVER_ENV_FILE: envFile.trim(),
},
};
}
/** The evolver MCP stdio server command (default or --server-command/--server-args), with the --env-file pointer
* merged in. Shared by the installed path (registered into config) and the manual path (rendered into wiring text). */
function descriptorEnvFile(f, descriptor, descriptorServer) {
if (typeof f['env-file'] === 'string' && f['env-file'].trim().length > 0)
return f['env-file'];
return descriptorServer?.env?.['EVOLVER_ENV_FILE'] ?? descriptor.envFile;
}
function buildServer(f, descriptor = { manualHints: {} }, resolveNodePath) {
const serverArgs = argsFromFlag(f['server-args']);
if (f['server-node'] === true)
throw new McpNodeOverrideError('missing value for --server-node');
if (typeof f['server-node'] === 'string' && typeof f['server-command'] === 'string') {
throw new McpNodeOverrideError('--server-node cannot be combined with --server-command');
}
let nodePath;
let serverBase;
if (typeof f['server-node'] === 'string') {
nodePath = resolveStableMcpNodePath({
execPath: f['server-node'],
platform: process.platform,
systemCandidates: [],
});
serverBase = { command: nodePath, args: serverArgs ?? defaultMcpServerArgs() };
}
else if (typeof f['server-command'] === 'string') {
serverBase = {
command: f['server-command'],
...(serverArgs !== undefined ? { args: serverArgs } : {}),
...(serverArgs === undefined && descriptor.mcpServer?.args ? { args: descriptor.mcpServer.args } : {}),
};
}
else if (descriptor.mcpServer) {
serverBase = {
...descriptor.mcpServer,
...(serverArgs !== undefined ? { args: serverArgs } : {}),
};
}
else {
serverBase = {
...defaultMcpServer(resolveNodePath),
...(serverArgs !== undefined ? { args: serverArgs } : {}),
};
nodePath = serverBase.command;
}
return {
server: withEnvFile(serverBase, descriptorEnvFile(f, descriptor, descriptor.mcpServer)),
...(nodePath !== undefined ? { nodePath } : {}),
};
}
function buildServiceExec(f, descriptor = { manualHints: {} }) {
const serviceArgs = argsFromFlag(f['service-args']);
const serviceBase = typeof f['service-command'] === 'string'
? {
command: f['service-command'],
...(serviceArgs !== undefined ? { args: serviceArgs } : {}),
...(serviceArgs === undefined && descriptor.serviceExec?.args ? { args: descriptor.serviceExec.args } : {}),
}
: descriptor.serviceExec
? {
...descriptor.serviceExec,
...(serviceArgs !== undefined ? { args: serviceArgs } : {}),
}
: {
...defaultProxyDaemon(),
...(serviceArgs !== undefined ? { args: serviceArgs } : {}),
};
return withEnvFile(serviceBase, descriptorEnvFile(f, descriptor, descriptor.serviceExec));
}
function appendAdapterNotes(text, hints) {
return hints && hints.length > 0
? `${text}\n\nAdapter notes:\n${hints.map((hint) => ` - ${hint}`).join('\n')}`
: text;
}
const SETUP_VALUE_FLAGS = new Set([
'runtime', 'platform', 'scope', 'root', 'lifecycle', 'action', 'confirmed-plan', 'target-id', 'completed-targets', 'env-file', 'profile-descriptor', 'service',
'hook-command', 'server-node', 'server-command', 'server-args', 'service-command', 'service-args',
]);
function setupHelpRequested(argv) {
if (argv.includes('--help'))
return true;
return argv.some((arg, index) => {
if (arg !== '-h')
return false;
const previous = argv[index - 1];
if (!previous?.startsWith('--') || previous.includes('='))
return true;
return !SETUP_VALUE_FLAGS.has(previous.slice(2));
});
}
function lifecycleAction(raw) {
return raw === 'install' || raw === 'uninstall' ? raw : undefined;
}
function runSetupLifecycle(f, runtime, scope, configRoot, emit, deps) {
const operation = f['lifecycle'];
if (operation === undefined)
return undefined;
const fail = (code, error, exitCode = 2) => {
if (f['json'] === true)
emit({ schema: SETUP_LIFECYCLE_SCHEMA, operation, ok: false, code, error });
else
process.stderr.write(`[setup-hooks] ${error}\n`);
return exitCode;
};
if (f['json'] !== true)
return fail('PLAN_INVALID', 'lifecycle operations require --json');
if (typeof operation !== 'string' || !['capabilities', 'plan', 'verify', 'execute'].includes(operation)) {
return fail('PLAN_INVALID', 'invalid --lifecycle operation');
}
if (f['verify'] !== undefined || f['uninstall'] !== undefined || f['dry-run'] !== undefined || f['force'] !== undefined) {
return fail('PLAN_INVALID', 'lifecycle operations cannot be combined with legacy mutation flags');
}
if (operation !== 'execute' && (f['target-id'] !== undefined || f['completed-targets'] !== undefined)) {
return fail('PLAN_INVALID', 'target execution flags are valid only for --lifecycle=execute');
}
if (operation === 'execute' && f['completed-targets'] !== undefined && f['target-id'] === undefined) {
return fail('PLAN_INVALID', '--completed-targets requires --target-id');
}
const capabilities = setupLifecycleCapabilities(runtime, scope);
if (operation === 'capabilities') {
emit({ schema: SETUP_LIFECYCLE_SCHEMA, operation, ok: true, capabilities });
return 0;
}
if (!capabilities.supported)
return fail('CAPABILITY_UNAVAILABLE', `lifecycle protocol is unavailable for ${runtime}/${scope}`);
const action = lifecycleAction(f['action']);
if ((operation === 'plan' || operation === 'execute') && action === undefined) {
return fail('PLAN_INVALID', '--action=install|uninstall is required');
}
const descriptor = profileDescriptorFromFlag(f);
if (!descriptor.ok)
return fail('PLAN_INVALID', descriptor.error, 1);
let server = { command: 'evolver-mcp' };
let productBridgeNodePath;
if (operation === 'verify' || action === 'install') {
try {
const built = buildServer(f, descriptor.descriptor, deps.resolveMcpNodePath);
server = built.server;
productBridgeNodePath = built.nodePath ?? (deps.resolveMcpNodePath ?? resolveStableMcpNodePath)();
}
catch (error) {
return fail('CAPABILITY_UNAVAILABLE', safeSetupOperationError(error), 1);
}
}
const injectionPlan = planInjection(runtime, server);
const context = {
runtime,
scope,
configRoot,
injectionPlan,
installOptions: {
configRoot,
server,
scope,
...(productBridgeNodePath !== undefined ? { productBridgeNodePath } : {}),
...(typeof f['hook-command'] === 'string' ? { hookCommand: f['hook-command'] } : {}),
homeDir: configuredHomeDir(),
codexHome: process.env['CODEX_HOME'],
kiroHome: process.env['KIRO_HOME'],
xdgConfigHome: process.env['XDG_CONFIG_HOME'],
opencodeConfig: process.env['OPENCODE_CONFIG'],
opencodeConfigDir: process.env['OPENCODE_CONFIG_DIR'],
},
};
try {
if (operation === 'plan') {
const plan = createSetupLifecyclePlan(context, action);
emit({ schema: SETUP_LIFECYCLE_SCHEMA, operation, ok: true, plan });
return 0;
}
if (operation === 'verify') {
const verified = verifySetupLifecycle(context);
emit({ schema: SETUP_LIFECYCLE_SCHEMA, operation, ok: true, verified });
return 0;
}
const encoded = f['confirmed-plan'];
if (typeof encoded !== 'string' || encoded.length === 0) {
return fail('PLAN_INVALID', '--confirmed-plan=<base64url> is required');
}
const confirmed = decodeSetupLifecyclePlan(encoded);
if (confirmed.action !== action)
return fail('PLAN_INVALID', 'confirmed lifecycle plan action does not match --action');
const targetId = f['target-id'];
const completedRaw = f['completed-targets'];
if (targetId !== undefined && (typeof targetId !== 'string' || targetId.length === 0)) {
return fail('PLAN_INVALID', '--target-id requires a value');
}
if (completedRaw !== undefined && typeof completedRaw !== 'string') {
return fail('PLAN_INVALID', '--completed-targets requires a comma-separated value');
}
const result = executeSetupLifecyclePlan(context, confirmed, {
...(typeof targetId === 'string' ? { targetId } : {}),
...(typeof completedRaw === 'string' && completedRaw.length > 0
? { completedTargetIds: completedRaw.split(',').filter(Boolean) }
: {}),
});
emit({ schema: SETUP_LIFECYCLE_SCHEMA, operation, ok: true, result });
return result.complete ? 0 : 1;
}
catch (error) {
if (error instanceof SetupLifecycleError)
return fail(error.code, error.message, 1);
return fail('VERIFY_FAILED', safeSetupOperationError(error), 1);
}
}
export async function runSetupHooks(argv, store, review, deps = {}) {
if (setupHelpRequested(argv)) {
process.stdout.write(USAGE);
return 0;
}
const f = parseFlags(argv);
const runtimeRaw = typeof f['runtime'] === 'string'
? f['runtime']
: (typeof f['platform'] === 'string' ? f['platform'] : 'claude-code');
const json = f['json'] === true;
const emit = (obj) => { if (json)
process.stdout.write(`${JSON.stringify(obj)}\n`); };
const emitWarnings = (warnings) => {
if (json || !warnings)
return;
for (const warning of warnings)
process.stderr.write(`[setup-hooks] warning: ${warning}\n`);
};
const scopeResult = parseScope(f['scope']);
if (!scopeResult.ok) {
if (json)
emit({ runtime: runtimeRaw, outcome: 'error', files: [], error: scopeResult.error });
else
process.stderr.write(`${scopeResult.error}\n${USAGE}`);
return 1;
}
// Tri-state setup matrix (#217): classify BEFORE touching any config so a bootstrapper gets a deterministic
// installed/manual/unsupported answer. Only the `installed` class proceeds to mutate runtime config; `manual`
// prints the honest "do it by hand" reason without mutating, and `unsupported` refuses with a clear reason.
const support = runtimeSupport(runtimeRaw);
if (support.outcome === 'unsupported') {
if (json)
emit({ runtime: runtimeRaw, outcome: 'unsupported', files: [], reason: support.reason });
else
process.stderr.write(`${support.reason}\n${USAGE}`);
return 1;
}
// Installed runtimes have a real config writer + uninstaller (including Antigravity MCP config).
const runtime = runtimeRaw;
const configRoot = typeof f['root'] === 'string' ? f['root'] : process.cwd();
// claude-code: user scope targets ~/.claude.json regardless of --root; project scope uses configRoot.
const scope = scopeResult.scope;
const lifecycleCode = runSetupLifecycle(f, runtime, scope, configRoot, emit, deps);
if (lifecycleCode !== undefined)
return lifecycleCode;
// V1 exposed setup-hooks --verify as a read-only operation. Silently ignoring it is unsafe: the command then
// falls through to installInjection and can rewrite runtime configuration while the operator asked only to
// inspect it. V2's JSON MCP installers already have a transaction-free dry-run preflight, so use that as the
// verifier for OpenCode and Kiro. Legacy installers do not have a proven read-only preflight and must refuse.
if (f['verify'] !== undefined) {
const verificationArgsValid = f['verify'] === true
&& f['uninstall'] === undefined
&& f['dry-run'] === undefined
&& f['force'] === undefined;
if (!verificationArgsValid) {
const error = '--verify is read-only and cannot take a value or be combined with --uninstall, --dry-run, or --force';
if (json)
emit({ runtime, action: 'verify', ok: false, verified: false, files: [], error });
else
process.stderr.write(`${error}\n${USAGE}`);
return 2;
}
if (runtime !== 'opencode' && runtime !== 'kiro') {
const error = `--verify is not available for ${runtime}; no config was written`;
if (json)
emit({ runtime, action: 'verify', ok: false, verified: false, files: [], error });
else
process.stderr.write(`${error}\n`);
return 2;
}
const verifyDescriptor = profileDescriptorFromFlag(f);
if (!verifyDescriptor.ok) {
if (json)
emit({ runtime, action: 'verify', ok: false, verified: false, files: [], error: verifyDescriptor.error });
else
process.stderr.write(`${verifyDescriptor.error}\n`);
return 1;
}
let verification;
try {
const { server } = buildServer(f, verifyDescriptor.descriptor, deps.resolveMcpNodePath);
verification = installInjection(planInjection(runtime, server), {
configRoot,
server,
scope,
dryRun: true,
homeDir: configuredHomeDir(),
kiroHome: process.env['KIRO_HOME'],
xdgConfigHome: process.env['XDG_CONFIG_HOME'],
opencodeConfig: process.env['OPENCODE_CONFIG'],
opencodeConfigDir: process.env['OPENCODE_CONFIG_DIR'],
});
}
catch (error) {
const message = safeSetupOperationError(error);
if (json)
emit({ runtime, action: 'verify', ok: false, verified: false, files: [], error: message });
else
process.stderr.write(`${message}\n`);
return 1;
}
if (!verification.ok) {
const message = safeSetupResultError(verification.error);
if (json)
emit({ runtime, action: 'verify', ok: false, verified: false, files: [], error: message });
else
process.stderr.write(`${message}\n`);
return 1;
}
const files = displayResultPaths(verification.files, configRoot, scope) ?? [];
const verified = verification.alreadyInstalled === true && verification.verified === true;
if (json)
emit({ runtime, action: 'verify', ok: verified, verified, files });
else
process.stdout.write(verified
? `verified evolver setup for ${runtime}: ${files.join(', ')}\n`
: `evolver setup for ${runtime} is missing or does not match the requested configuration\n`);
return verified ? 0 : 1;
}
if (f['dry-run'] === true && runtime !== 'opencode' && runtime !== 'kiro') {
const error = `--dry-run is currently supported for opencode and kiro setup only; no config was written.`;
if (json)
emit({ runtime, outcome: 'error', files: [], error });
else
process.stderr.write(`${error}\n`);
return 1;
}
if (support.outcome === 'installed' && f['uninstall']) {
let r;
try {
r = uninstallInjection(runtime, {
configRoot,
scope,
homeDir: configuredHomeDir(),
codexHome: process.env['CODEX_HOME'],
kiroHome: process.env['KIRO_HOME'],
xdgConfigHome: process.env['XDG_CONFIG_HOME'],
opencodeConfig: process.env['OPENCODE_CONFIG'],
opencodeConfigDir: process.env['OPENCODE_CONFIG_DIR'],
dryRun: f['dry-run'] === true,
});
}
catch (error) {
const message = safeSetupOperationError(error);
if (json)
emit({ runtime, action: 'uninstall', files: [], error: message });
else
process.stderr.write(`${message}\n`);
return 1;
}
const files = displayResultPaths(r.files, configRoot, scope) ?? [];
const backups = displayResultPaths(r.backups, configRoot, scope);
if (!r.ok) {
const message = safeSetupResultError(r.error);
if (json)
emit({ runtime, action: 'uninstall', files: [], error: message });
else
process.stderr.write(`${message}\n`);
return 1;
}
if (r.dryRun) {
if (json)
emit({ runtime, action: 'uninstall', files, backups, dryRun: true });
else
process.stdout.write(files.length ? `dry-run: would remove evolver from: ${files.join(', ')}\n` : 'dry-run: nothing to remove\n');
return 0;
}
if (json)
emit({ runtime, action: 'uninstall', files, backups });
else
process.stdout.write(files.length ? `removed evolver from: ${files.join(', ')}\n` : 'nothing to remove\n');
return 0;
}
const profileDescriptor = profileDescriptorFromFlag(f);
if (!profileDescriptor.ok) {
if (json)
emit({ runtime: runtimeRaw, outcome: 'error', files: [], error: profileDescriptor.error });
else
process.stderr.write(`${profileDescriptor.error}\n`);
return 1;
}
const descriptor = profileDescriptor.descriptor;
if (support.outcome === 'manual') {
// server + --service=<target>: emit a ready service template (#217 slice 4). Print-only — evolver does NOT
// manage service lifecycle; it just prints a template wiring the EVOLVER_ENV_FILE pointer (never a secret).
if (runtimeRaw === 'server' && typeof f['service'] === 'string') {
const target = f['service'];
if (!SERVICE_TARGETS.includes(target)) {
const msg = `unknown --service target '${target}' (supported: ${SERVICE_TARGETS.join('|')})`;
if (json)
emit({ runtime: runtimeRaw, outcome: 'manual', files: [], error: msg });
else
process.stderr.write(`${msg}\n`);
return 1;
}
let template;
try {
template = appendAdapterNotes(renderServiceGuidance(target, { exec: buildServiceExec(f, descriptor) }), descriptor.manualHints[runtimeRaw]);
}
catch (error) {
const message = safeSetupOperationError(error);
if (json)
emit({ runtime: runtimeRaw, outcome: 'error', files: [], error: message });
else
process.stderr.write(`${message}\n`);
return 1;
}
if (json)
emit({ runtime: runtimeRaw, outcome: 'manual', files: [], service: target, template });
else
process.stdout.write(`${template}\n`);
return 0;
}
let server;
try {
server = buildServer(f, descriptor, deps.resolveMcpNodePath).server;
}
catch (error) {
const message = safeSetupOperationError(error);
if (json)
emit({ runtime: runtimeRaw, outcome: 'error', files: [], error: message });
else
process.stderr.write(`${message}\n`);
return 1;
}
// Otherwise: PRECISE manual wiring (#217 slice 2) so manual is actionable, not a dead end.
const instructions = renderManualWiring(runtimeRaw, { server, hints: descriptor.manualHints[runtimeRaw] ?? [] });
if (json)
emit({ runtime: runtimeRaw, outcome: 'manual', files: [], reason: support.reason, instructions });
else
process.stdout.write(`${instructions}\n`);
return 0;
}
let server;
let serverNodePath;
try {
const built = buildServer(f, descriptor, deps.resolveMcpNodePath);
server = built.server;
serverNodePath = built.nodePath;
}
catch (error) {
const message = safeSetupOperationError(error);
if (json)
emit({ runtime, outcome: 'error', files: [], error: message });
else
process.stderr.write(`${message}\n`);
return 1;
}
if ((runtime === 'opencode' || runtime === 'kiro') && f['dry-run'] !== true) {
let runtimeDetected;
try {
runtimeDetected = (deps.runtimeAvailable ?? defaultRuntimeAvailable)(runtime, configRoot, scope);
}
catch (error) {
const message = safeSetupOperationError(error);
if (json)
emit({ runtime, outcome: 'error', files: [], error: message });
else
process.stderr.write(`${message}\n`);
return 1;
}
if (!runtimeDetected) {
const error = `${runtime} runtime was not detected; install or launch ${runtime === 'opencode' ? 'OpenCode' : 'Kiro'} first, then rerun. No config was written.`;
if (json)
emit({ runtime, outcome: 'error', files: [], error });
else
process.stderr.write(`${error}\n`);
return 1;
}
}
if (runtime === 'cursor')
(deps.emitNonGitWorkspaceNotice ?? maybeEmitNonGitWorkspaceNotice)({ cwd: configRoot });
// cursor seeds .cursor/rules/evolver.mdc with the CURRENT top REVIEW-APPROVED genes so the file is useful
// immediately (before the first daemon rewrite) without seeding an unapproved draft (A2a). The renderer thus
// has a real production caller even without the daemon running.
const cursorStore = store ?? new assetstore.LocalJsonlProvider(events.assetsDir());
const cursorGenes = runtime === 'cursor'
? await currentTopCursorGenes(cursorStore, review ?? reviewLedgerForStore(cursorStore))
: undefined;
let r;
try {
// claude-code and codex also persist a managed evox-product bridge entry that launches its own stdio shim.
// It must not inherit a package-manager-local process.execPath (#1068): give it the same stable Node the
// evolver entry uses — the pinned --server-node when set, otherwise the default stable resolution. Runtimes
// without a bridge entry never resolve a Node here.
const productBridgeNodePath = (runtime === 'claude-code' || runtime === 'codex')
? serverNodePath ?? (deps.resolveMcpNodePath ?? resolveStableMcpNodePath)()
: undefined;
r = installInjection(planInjection(runtime, server), {
configRoot, server, scope,
...(productBridgeNodePath !== undefined ? { productBridgeNodePath } : {}),
...(typeof f['hook-command'] === 'string' ? { hookCommand: f['hook-command'] } : {}),
...(cursorGenes ? { genes: cursorGenes } : {}),
force: f['force'] === true,
dryRun: f['dry-run'] === true,
homeDir: configuredHomeDir(),
codexHome: process.env['CODEX_HOME'],
kiroHome: process.env['KIRO_HOME'],
xdgConfigHome: process.env['XDG_CONFIG_HOME'],
opencodeConfig: process.env['OPENCODE_CONFIG'],
opencodeConfigDir: process.env['OPENCODE_CONFIG_DIR'],
});
}
catch (e) {
// A SUPPORTED runtime whose install THROWS (symlinked adapter path, I/O error) is an install ERROR, not an
// `unsupported` runtime. Reporting `unsupported` here would tell a bootstrapper the runtime isn't supported and
// make it stop trying, when the real cause is operational. `error` is a CLI result status (the attempt failed),
// distinct from the support-matrix `unsupported` (an unrecognized runtime id) (Bugbot #264).
const msg = safeSetupOperationError(e);
if (json)
emit({ runtime, outcome: 'error', files: [], error: msg });
else
process.stderr.write(`${msg}\n`);
return 1;
}
if (!r.ok) {
// Same reasoning for an ok:false install result: a failed install of a supported runtime is `error`, not `unsupported`.
const message = safeSetupResultError(r.error);
if (json)
emit({ runtime, outcome: 'error', files: [], error: message });
else
process.stderr.write(`${message}\n`);
return 1;
}
const files = displayResultPaths(r.files, configRoot, scope) ?? [];
const backups = displayResultPaths(r.backups, configRoot, scope);
if (r.dryRun) {
if (json) {
emit({
runtime,
outcome: 'installed',
files,
backups,
dryRun: true,
...(r.alreadyInstalled ? { alreadyInstalled: true } : {}),
});
}
else if (r.alreadyInstalled) {
process.stdout.write('dry-run: evolver already installed (no changes)\n');
}
else {
process.stdout.write(`dry-run: would install evolver (${r.mode}) → ${files.join(', ')}\n`);
}
return 0;
}
if (r.alreadyInstalled) {
if (json)
emit({ runtime, outcome: 'installed', files, alreadyInstalled: true, ...(r.warnings ? { warnings: r.warnings } : {}) });
else
process.stdout.write('evolver already installed (use --force to reinstall)\n');
emitWarnings(r.warnings);
return 0;
}
if (json)
emit({ runtime, outcome: 'installed', files, backups, mode: r.mode, verified: r.verified, ...(r.warnings ? { warnings: r.warnings } : {}) });
else
process.stdout.write(`installed evolver (${r.mode}) → ${files.join(', ')}\n`);
emitWarnings(r.warnings);
return 0;
}