From a252a4092f2be36848678ccd5ddf276e27867922 Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Tue, 25 Aug 2026 18:03:51 +0800 Subject: [PATCH 1/2] fix: make setup-hooks targeting and verification explicit Honor desktop-provided runtime and root selections, and expose read-only machine health reports without executing installed workspace code. --- README.md | 12 ++ README.zh-CN.md | 11 ++ cli-options.js | 45 ++++++ index.js | 75 +++++++-- src/adapters/claudeCode.js | 102 +++++++++++- src/adapters/codex.js | 277 +++++++++++++++++++++++++++----- src/adapters/hookAdapter.js | 76 ++++++--- src/adapters/opencode.js | 43 ++--- test/adapters.opencode.test.js | 35 ++++ test/adapters.test.js | 285 +++++++++++++++++++++++++++++++++ 10 files changed, 854 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index f5f8054b..a3956bcc 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,18 @@ Evolver integrates with major agent runtimes through `setup-hooks`. Run it once | [opencode](https://opencode.ai) | `evolver setup-hooks --platform=opencode` | Plugin at `~/.opencode/plugins/evolver.js` + scripts in `~/.opencode/hooks/`. Restart opencode. | | [OpenClaw](https://openclaw.com) | No setup needed | OpenClaw natively interprets the `sessions_spawn(...)` stdout directives Evolver emits. Just run `evolver` from inside an OpenClaw session. | +Desktop integrations and automation should pass an explicit config root instead +of relying on the caller's current working directory: + +```bash +evolver setup-hooks --platform=claude-code --root=/absolute/workspace +evolver setup-hooks --platform=claude-code --root=/absolute/workspace --verify --json +``` + +`--runtime=` remains a compatibility alias for `--platform`. When +`--verify --json` is used, stdout contains exactly one JSON health report so a +host application can distinguish a configured integration from a working one. + #### Codex caveats The Codex CLI exposes `SessionStart` / `Stop` / `PostToolUse` hooks (which is diff --git a/README.zh-CN.md b/README.zh-CN.md index d57696e4..66ea7583 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -99,6 +99,17 @@ evolver setup-hooks --platform=claude-code 通过 `~/.claude/` 向 Claude Code 的 hook 系统注册 Evolver。安装完成后重启 Claude Code CLI。 +桌面应用或自动化调用时应显式传入配置根目录,不要依赖调用进程的当前目录: + +```bash +evolver setup-hooks --platform=claude-code --root=/绝对路径/工作区 +evolver setup-hooks --platform=claude-code --root=/绝对路径/工作区 --verify --json +``` + +`--runtime=` 作为 `--platform` 的兼容别名保留。使用 +`--verify --json` 时,stdout 只输出一个机器可读的 JSON 健康报告,宿主可以据此区分 +“配置已写入”和“接入确实可用”。 + #### OpenClaw OpenClaw 会识别 Evolver 向 stdout 输出的 `sessions_spawn(...)` 协议,**无需安装 hooks**。将 Evolver 克隆到 OpenClaw workspace 中,在会话内运行即可: diff --git a/cli-options.js b/cli-options.js index 385c76ea..32c35cd1 100644 --- a/cli-options.js +++ b/cli-options.js @@ -9,6 +9,50 @@ const PROXY_PATH_FLAGS = new Map([ ['--env-file', 'envFile'], ]); +function optionValue(argv, name) { + for (let index = 0; index < argv.length; index += 1) { + const arg = String(argv[index]); + if (arg === name) { + const value = argv[index + 1]; + if (value === undefined || String(value).startsWith('-')) { + throw new Error(name + ' requires a value'); + } + return String(value); + } + if (arg.startsWith(name + '=')) { + const value = arg.slice(name.length + 1); + if (!value) throw new Error(name + ' requires a value'); + return value; + } + } + return undefined; +} + +function parseSetupHooksCliOptions(argv, env = process.env) { + const platform = optionValue(argv, '--platform'); + const runtime = optionValue(argv, '--runtime'); + if (platform && runtime && platform !== runtime) { + throw new Error( + `conflicting --platform=${platform} and --runtime=${runtime}; pass one runtime identity` + ); + } + const rawRoot = optionValue(argv, '--root'); + const root = rawRoot === undefined ? undefined : rawRoot.trim(); + if (rawRoot !== undefined && !root) { + throw new Error('--root requires a non-empty path'); + } + return { + platform: platform || runtime, + root: root + ? path.resolve(expandHomePath(root, env)) + : undefined, + force: argv.includes('--force'), + uninstall: argv.includes('--uninstall'), + verify: argv.includes('--verify'), + json: argv.includes('--json'), + }; +} + function expandHomePath(value, env = process.env) { if (value === '~') return env.HOME || require('os').homedir(); if (value.startsWith('~/') || value.startsWith('~\\')) { @@ -68,6 +112,7 @@ function prepareProxyCliEnvironment(argv, env = process.env, dotenv = require('d module.exports = { applyProxyCliPathOptions, expandHomePath, + parseSetupHooksCliOptions, parseProxyCliPathOptions, prepareProxyCliEnvironment, }; diff --git a/index.js b/index.js index c6ddb355..1044a9fe 100755 --- a/index.js +++ b/index.js @@ -208,6 +208,7 @@ if (process.argv[2] === 'proxy-token') { const { applyProxyCliPathOptions, + parseSetupHooksCliOptions, prepareProxyCliEnvironment, } = require('./cli-options'); @@ -3214,44 +3215,87 @@ async function main() { const hookAdapter = require('./src/adapters/hookAdapter'); const { setupHooks, resolveConfigRoot, detectPlatform, loadAdapter } = hookAdapter; - const platformFlag = args.find(a => typeof a === 'string' && a.startsWith('--platform=')); - const platform = platformFlag ? platformFlag.slice('--platform='.length) : undefined; - const force = args.includes('--force'); - const uninstall = args.includes('--uninstall'); - const verifyOnly = args.includes('--verify'); + let setupOptions; + try { + setupOptions = parseSetupHooksCliOptions(args, process.env); + } catch (error) { + const message = error && error.message || String(error); + if (args.includes('--json')) { + process.stdout.write(JSON.stringify({ + ok: false, + error: { code: 'invalid_arguments', message }, + }) + '\n'); + } else { + console.error('[setup-hooks] ' + message); + } + process.exit(2); + } + const { + platform, + root, + force, + uninstall, + verify: verifyOnly, + json: jsonOut, + } = setupOptions; + const failVerify = (code, message, exitCode) => { + if (jsonOut) { + process.stdout.write(JSON.stringify({ + ok: false, + platform: platform || null, + config_root: root || null, + error: { code, message }, + }) + '\n'); + } else { + console.error('[setup-hooks] --verify: ' + message); + } + process.exit(exitCode); + }; if (verifyOnly) { // Read-only verification: do not touch any files, just report whether // the previously-installed hooks/plugin look healthy. Lets users answer // "is the plugin actually loaded?" without grepping opencode logs. try { - const platformId = platform || detectPlatform(process.cwd()); + const platformId = platform || detectPlatform(root || process.cwd()); if (!platformId) { - console.error('[setup-hooks] --verify: could not detect platform. Pass --platform=opencode|cursor|claude-code|codex|kiro'); - process.exit(2); + failVerify( + 'platform_not_detected', + 'could not detect platform. Pass --platform=opencode|cursor|claude-code|codex|kiro', + 2 + ); } const adapter = loadAdapter(platformId); if (!adapter || typeof adapter.verify !== 'function') { - console.error('[setup-hooks] --verify: platform ' + platformId + ' does not support verification yet.'); - process.exit(2); + failVerify( + 'verification_unsupported', + 'platform ' + platformId + ' does not support verification yet.', + 2 + ); } - const configRoot = resolveConfigRoot(platformId, process.cwd()); + const configRoot = root || resolveConfigRoot(platformId, process.cwd()); const report = adapter.verify({ configRoot }); - if (typeof adapter.printVerifyReport === 'function') { + if (jsonOut) { + process.stdout.write(JSON.stringify(report) + '\n'); + } else if (typeof adapter.printVerifyReport === 'function') { adapter.printVerifyReport(report); } else { console.log(JSON.stringify(report, null, 2)); } process.exit(report.ok ? 0 : 1); } catch (verifyErr) { - console.error('[setup-hooks] --verify error:', verifyErr && verifyErr.message || verifyErr); - process.exit(1); + failVerify( + 'verification_failed', + verifyErr && verifyErr.message || String(verifyErr), + 1 + ); } } try { const result = await setupHooks({ platform, + configRoot: root, cwd: process.cwd(), force, uninstall, @@ -3683,9 +3727,12 @@ async function main() { - --response-file= (LLM response file for skill distillation) - setup-hooks flags: - --platform=cursor|claude-code|codex|kiro|opencode (auto-detect if omitted) + - --runtime= (deprecated alias for --platform) + - --root= (explicit workspace/config root) - --force (overwrite existing config) - --uninstall (remove evolver hooks) - --verify (read-only: print install health for the chosen platform) + - --json (with --verify: emit one machine-readable JSON object) - asset-log flags: - --run= (filter by run ID) - --action= (filter: hub_search_hit, hub_search_miss, asset_reuse, asset_reference, asset_publish, asset_publish_skip) diff --git a/src/adapters/claudeCode.js b/src/adapters/claudeCode.js index 157b7bcf..9721d10c 100644 --- a/src/adapters/claudeCode.js +++ b/src/adapters/claudeCode.js @@ -1,6 +1,6 @@ const fs = require('fs'); const path = require('path'); -const { mergeJsonFile, copyHookScripts, appendSectionToFile, removeHookScripts, removeMarkedSection, assertSafeConfigDir, isEvolverHookCommand, buildSafeNodeHookCommand } = require('./hookAdapter'); +const { mergeJsonFile, copyHookScripts, verifyHookScriptCopies, appendSectionToFile, removeHookScripts, removeMarkedSection, assertSafeConfigDir, isEvolverHookCommand, buildSafeNodeHookCommand } = require('./hookAdapter'); const HOOK_SCRIPTS_DIR_NAME = 'hooks'; const EVOLVER_MARKER = ''; @@ -118,6 +118,104 @@ function install({ configRoot, evolverRoot, force }) { }; } +function verify({ configRoot }) { + const claudeDir = path.join(configRoot, '.claude'); + const settingsPath = path.join(claudeDir, 'settings.json'); + const hooksDir = path.join(claudeDir, HOOK_SCRIPTS_DIR_NAME); + const claudeMdPath = path.join(configRoot, 'CLAUDE.md'); + const checks = []; + let settings = null; + let settingsError = null; + try { + settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + } catch (error) { + settingsError = error && error.message || String(error); + } + checks.push({ + id: 'settings_json_readable', + ok: settings !== null, + detail: settings ? settingsPath : `unreadable: ${settingsError}`, + }); + checks.push({ + id: 'managed_marker', + ok: settings?._evolver_managed === true, + detail: settings?._evolver_managed === true + ? '_evolver_managed is true' + : 'settings.json is not marked as evolver-managed', + }); + checks.push({ + id: 'hooks_enabled', + ok: settings?.disableAllHooks !== true, + detail: settings?.disableAllHooks === true + ? 'settings.json disables all hooks' + : 'hooks are not globally disabled', + }); + + const expectedHooks = buildClaudeHooks('', configRoot).hooks; + const missingCommands = []; + for (const [event, expectedMatchers] of Object.entries(expectedHooks)) { + const actualMatchers = Array.isArray(settings?.hooks?.[event]) + ? settings.hooks[event] + : []; + for (const expectedMatcher of expectedMatchers) { + const present = actualMatchers.some(actualMatcher => { + if ((actualMatcher?.matcher ?? null) !== (expectedMatcher.matcher ?? null)) { + return false; + } + if (!Array.isArray(actualMatcher?.hooks)) return false; + return expectedMatcher.hooks.every(expectedHook => + actualMatcher.hooks.some(actualHook => + actualHook?.type === expectedHook.type && + actualHook?.command === expectedHook.command && + actualHook?.timeout === expectedHook.timeout + ) + ); + }); + if (!present) { + const command = expectedMatcher.hooks[0]?.command || event; + missingCommands.push(`${event}:${path.basename(command.split(' ').pop() || command)}`); + } + } + } + checks.push({ + id: 'hooks_registered', + ok: missingCommands.length === 0, + detail: missingCommands.length === 0 + ? 'all Claude Code hooks are registered' + : 'missing commands: ' + missingCommands.join(', '), + }); + + checks.push(verifyHookScriptCopies(hooksDir)); + + let hasMemorySection = false; + try { + hasMemorySection = fs.readFileSync(claudeMdPath, 'utf8').includes(EVOLVER_MARKER); + } catch { /* reported below */ } + checks.push({ + id: 'claude_md_section', + ok: hasMemorySection, + detail: hasMemorySection + ? 'CLAUDE.md contains the managed evolution section' + : 'CLAUDE.md is missing the managed evolution section', + }); + + return { + ok: checks.every(check => check.ok), + platform: 'claude-code', + config_root: configRoot, + settings_path: settingsPath, + hooks_dir: hooksDir, + checks, + }; +} + +function printVerifyReport(report) { + console.log('[claude-code] Verify report'); + for (const check of report.checks) { + console.log(`[claude-code] ${check.ok ? '[OK] ' : '[FAIL]'} ${check.id} -- ${check.detail}`); + } +} + function uninstall({ configRoot }) { const claudeDir = path.join(configRoot, '.claude'); const settingsPath = path.join(claudeDir, 'settings.json'); @@ -191,4 +289,4 @@ function uninstall({ configRoot }) { return { ok: true, removed: changed }; } -module.exports = { install, uninstall, buildClaudeHooks }; +module.exports = { install, uninstall, verify, printVerifyReport, buildClaudeHooks }; diff --git a/src/adapters/codex.js b/src/adapters/codex.js index 2bd2cc08..039ac94f 100644 --- a/src/adapters/codex.js +++ b/src/adapters/codex.js @@ -1,6 +1,6 @@ const fs = require('fs'); const path = require('path'); -const { mergeJsonFile, copyHookScripts, appendSectionToFile, removeHookScripts, removeMarkedSection, assertSafeConfigDir, isEvolverHookCommand } = require('./hookAdapter'); +const { mergeJsonFile, copyHookScripts, verifyHookScriptCopies, appendSectionToFile, removeHookScripts, removeMarkedSection, assertSafeConfigDir, isEvolverHookCommand } = require('./hookAdapter'); const HOOK_SCRIPTS_DIR_NAME = 'hooks'; const EVOLVER_MARKER = ''; @@ -34,54 +34,154 @@ function buildCodexHooksJson(evolverRoot) { }; } -function ensureConfigToml(codexDir) { - const tomlPath = path.join(codexDir, 'config.toml'); - let content = ''; - try { content = fs.readFileSync(tomlPath, 'utf8'); } catch { /* new file */ } +function isEscapedAt(line, index) { + let slashes = 0; + for (let cursor = index - 1; cursor >= 0 && line[cursor] === '\\'; cursor -= 1) { + slashes += 1; + } + return slashes % 2 === 1; +} - if (/codex_hooks\s*=\s*true/i.test(content)) { - return false; +function structuralTomlLines(lines) { + let multiline = null; + return lines.map((rawLine) => { + let structural = ''; + let singleQuoted = false; + let doubleQuoted = false; + for (let index = 0; index < rawLine.length; index += 1) { + if (multiline) { + if ( + rawLine.startsWith(multiline, index) && + (multiline === "'''" || !isEscapedAt(rawLine, index)) + ) { + multiline = null; + index += 2; + } + continue; + } + if (!singleQuoted && !doubleQuoted && rawLine[index] === '#') break; + if (!singleQuoted && !doubleQuoted && rawLine.startsWith('"""', index)) { + multiline = '"""'; + index += 2; + continue; + } + if (!singleQuoted && !doubleQuoted && rawLine.startsWith("'''", index)) { + multiline = "'''"; + index += 2; + continue; + } + if (!doubleQuoted && rawLine[index] === "'") singleQuoted = !singleQuoted; + if (!singleQuoted && rawLine[index] === '"' && !isEscapedAt(rawLine, index)) { + doubleQuoted = !doubleQuoted; + } + structural += rawLine[index]; + } + return structural.trim(); + }); +} + +function tomlSectionName(line) { + const header = line.match(/^\[([^\]]+)\]$/); + return header ? header[1].trim() : null; +} + +function featuresSection(lines) { + let start = -1; + for (let index = 0; index < lines.length; index += 1) { + const section = tomlSectionName(lines[index]); + if (section === 'features') { + start = index; + continue; + } + if (start >= 0 && section !== null) { + return { start, end: index }; + } + } + return start >= 0 ? { start, end: lines.length } : null; +} + +function codexHooksLine(rawLine) { + return /^codex_hooks\s*=\s*(true|false)$/.exec( + String(rawLine).trim() + ); +} + +function codexHooksEnabled(content) { + const lines = String(content).split(/\r?\n/); + const structural = structuralTomlLines(lines); + const section = featuresSection(structural); + if (!section) return false; + return structural + .slice(section.start + 1, section.end) + .some(line => codexHooksLine(line)?.[1] === 'true'); +} + +function updateCodexHooksFeature(content, enabled) { + const source = String(content); + const newline = source.includes('\r\n') ? '\r\n' : '\n'; + const lines = source.split(/\r?\n/); + const structural = structuralTomlLines(lines); + let section = featuresSection(structural); + if (!section && enabled) { + let prefix = source; + if (prefix && !prefix.endsWith('\n') && !prefix.endsWith('\r')) prefix += newline; + if (prefix && !prefix.endsWith(newline + newline)) prefix += newline; + return { + changed: true, + content: `${prefix}[features]${newline}codex_hooks = true${newline}`, + }; } + if (!section) return { changed: false, content: source }; - if (/\[features\]/.test(content)) { - content = content.replace( - /\[features\]/, - '[features]\ncodex_hooks = true' - ); + const settingIndexes = []; + for (let index = section.start + 1; index < section.end; index += 1) { + if (codexHooksLine(structural[index])) settingIndexes.push(index); + } + if (enabled) { + if ( + settingIndexes.length === 1 && + codexHooksLine(structural[settingIndexes[0]])?.[1] === 'true' + ) { + return { changed: false, content: source }; + } + if (settingIndexes.length === 0) { + lines.splice(section.start + 1, 0, 'codex_hooks = true'); + } else { + lines[settingIndexes[0]] = 'codex_hooks = true'; + for (const index of settingIndexes.slice(1).reverse()) lines.splice(index, 1); + } } else { - const separator = content.length > 0 && !content.endsWith('\n') ? '\n\n' : content.length > 0 ? '\n' : ''; - content += separator + '[features]\ncodex_hooks = true\n'; + if (settingIndexes.length === 0) { + return { changed: false, content: source }; + } + for (const index of settingIndexes.reverse()) lines.splice(index, 1); + section = featuresSection(structuralTomlLines(lines)); + const remaining = lines + .slice(section.start + 1, section.end) + .some(line => line.trim() !== ''); + if (!remaining) lines.splice(section.start, section.end - section.start); } + return { changed: true, content: lines.join(newline) }; +} + +function ensureConfigToml(codexDir) { + const tomlPath = path.join(codexDir, 'config.toml'); + let content = ''; + try { content = fs.readFileSync(tomlPath, 'utf8'); } catch { /* new file */ } - fs.writeFileSync(tomlPath, content, 'utf8'); + const updated = updateCodexHooksFeature(content, true); + if (!updated.changed) return false; + fs.writeFileSync(tomlPath, updated.content, 'utf8'); return true; } -// Reverse of `ensureConfigToml`: drop the `codex_hooks = true` line and, if -// the surrounding `[features]` block becomes empty as a result, drop that -// header too. Other unrelated entries under `[features]` are preserved. -// Returns true when the file changed. function cleanConfigToml(codexDir) { const tomlPath = path.join(codexDir, 'config.toml'); let content; try { content = fs.readFileSync(tomlPath, 'utf8'); } catch { return false; } - if (!/codex_hooks\s*=\s*true/i.test(content)) return false; - - // Drop the `codex_hooks = true` line. The greedy `\s*` after `true` - // consumes the trailing newline plus any blank lines so the - // empty-`[features]` check below cannot be fooled by a stray blank - // line into treating the section as empty while user entries still - // follow. - let next = content.replace(/^\s*codex_hooks\s*=\s*true\s*\n?/im, ''); - // Drop a now-empty `[features]` block. Two strict patterns avoid - // `$` with the /m flag — multiline `$` matches before any `\n`, so - // a single `(?=\s*$)` lookahead can succeed mid-file and strand - // user entries below the removed header (PR #94 round-3). - next = next.replace(/(^|\n)\[features\]\s*\n(?=\s*\[)/, '$1'); - next = next.replace(/(^|\n)\[features\]\s*$/, '$1'); - next = next.replace(/\n{3,}/g, '\n\n').trimEnd(); - if (next.length > 0) next += '\n'; - fs.writeFileSync(tomlPath, next, 'utf8'); + const updated = updateCodexHooksFeature(content, false); + if (!updated.changed) return false; + fs.writeFileSync(tomlPath, updated.content, 'utf8'); return true; } @@ -143,6 +243,100 @@ function install({ configRoot, evolverRoot, force }) { }; } +function verify({ configRoot }) { + const codexDir = path.join(configRoot, '.codex'); + const hooksJsonPath = path.join(codexDir, 'hooks.json'); + const hooksDir = path.join(codexDir, HOOK_SCRIPTS_DIR_NAME); + const configTomlPath = path.join(codexDir, 'config.toml'); + const agentsMdPath = path.join(configRoot, 'AGENTS.md'); + const checks = []; + let hooks = null; + let hooksError = null; + try { + hooks = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + } catch (error) { + hooksError = error && error.message || String(error); + } + checks.push({ + id: 'hooks_json_readable', + ok: hooks !== null, + detail: hooks ? hooksJsonPath : `unreadable: ${hooksError}`, + }); + checks.push({ + id: 'managed_marker', + ok: hooks?._evolver_managed === true, + detail: hooks?._evolver_managed === true + ? '_evolver_managed is true' + : 'hooks.json is not marked as evolver-managed', + }); + + const expectedHooks = buildCodexHooksJson('').hooks; + const missingCommands = []; + for (const [event, expectedEntries] of Object.entries(expectedHooks)) { + const actualEntries = Array.isArray(hooks?.hooks?.[event]) + ? hooks.hooks[event] + : []; + for (const expected of expectedEntries) { + const present = actualEntries.some(entry => + entry?.type === expected.type && + entry?.command === expected.command && + entry?.timeout === expected.timeout + ); + if (!present) { + missingCommands.push(`${event}:${path.basename(expected.command)}`); + } + } + } + checks.push({ + id: 'hooks_registered', + ok: missingCommands.length === 0, + detail: missingCommands.length === 0 + ? 'all Codex hooks are registered' + : 'missing commands: ' + missingCommands.join(', '), + }); + + checks.push(verifyHookScriptCopies(hooksDir)); + + let configToml = ''; + try { configToml = fs.readFileSync(configTomlPath, 'utf8'); } catch { /* reported below */ } + const hooksEnabled = codexHooksEnabled(configToml); + checks.push({ + id: 'codex_hooks_enabled', + ok: hooksEnabled, + detail: hooksEnabled + ? 'config.toml enables codex_hooks' + : 'config.toml does not enable codex_hooks', + }); + + let hasMemorySection = false; + try { + hasMemorySection = fs.readFileSync(agentsMdPath, 'utf8').includes(EVOLVER_MARKER); + } catch { /* reported below */ } + checks.push({ + id: 'agents_md_section', + ok: hasMemorySection, + detail: hasMemorySection + ? 'AGENTS.md contains the managed evolution section' + : 'AGENTS.md is missing the managed evolution section', + }); + + return { + ok: checks.every(check => check.ok), + platform: 'codex', + config_root: configRoot, + hooks_path: hooksJsonPath, + hooks_dir: hooksDir, + checks, + }; +} + +function printVerifyReport(report) { + console.log('[codex] Verify report'); + for (const check of report.checks) { + console.log(`[codex] ${check.ok ? '[OK] ' : '[FAIL]'} ${check.id} -- ${check.detail}`); + } +} + function uninstall({ configRoot }) { const codexDir = path.join(configRoot, '.codex'); const hooksJsonPath = path.join(codexDir, 'hooks.json'); @@ -213,4 +407,13 @@ function uninstall({ configRoot }) { return { ok: true, removed: changed }; } -module.exports = { install, uninstall, buildCodexHooksJson, ensureConfigToml, cleanConfigToml }; +module.exports = { + install, + uninstall, + verify, + printVerifyReport, + buildCodexHooksJson, + codexHooksEnabled, + ensureConfigToml, + cleanConfigToml, +}; diff --git a/src/adapters/hookAdapter.js b/src/adapters/hookAdapter.js index 3e627da5..131991cf 100644 --- a/src/adapters/hookAdapter.js +++ b/src/adapters/hookAdapter.js @@ -10,6 +10,16 @@ const PLATFORMS = { opencode: { name: 'opencode', configDir: '.opencode', detector: '.opencode' }, }; +const HOOK_SCRIPT_NAMES = Object.freeze([ + '_runtimePaths.js', + '_memoryFiltering.js', + '_lockPaths.js', + 'evolver-session-start.js', + 'evolver-signal-detect.js', + 'evolver-session-end.js', + 'evolver-task-recall.js', +]); + // Detect the host agent from runtime environment signals, which are far more // reliable than scanning for `.claude` / `.cursor` directories when both exist // on disk (e.g. under $HOME). Precedence when signals conflict (#590): @@ -261,18 +271,9 @@ function copyHookScripts(destDir, evolverRoot) { // To keep future helpers from re-living this, the regression test in // test/adapters.test.js scans every `require('./_*')` in the source // adapter scripts and asserts the target file is in this list. - const scripts = [ - '_runtimePaths.js', - '_memoryFiltering.js', - '_lockPaths.js', - 'evolver-session-start.js', - 'evolver-signal-detect.js', - 'evolver-session-end.js', - 'evolver-task-recall.js', - ]; fs.mkdirSync(destDir, { recursive: true }); const copied = []; - for (const name of scripts) { + for (const name of HOOK_SCRIPT_NAMES) { const src = path.join(scriptsDir, name); const dest = path.join(destDir, name); if (!fs.existsSync(src)) { @@ -295,6 +296,34 @@ function copyHookScripts(destDir, evolverRoot) { return copied; } +function verifyHookScriptCopies(hooksDir) { + const scriptsDir = path.join(__dirname, 'scripts'); + const invalid = []; + for (const name of HOOK_SCRIPT_NAMES) { + const source = path.join(scriptsDir, name); + const installed = path.join(hooksDir, name); + try { + const installedStat = fs.lstatSync(installed); + if (!installedStat.isFile()) { + invalid.push(`${name} is not a regular file`); + continue; + } + if (!fs.readFileSync(source).equals(fs.readFileSync(installed))) { + invalid.push(`${name} differs from the installed Evolver version`); + } + } catch { + invalid.push(`${name} is missing`); + } + } + return { + id: 'hook_scripts_present', + ok: invalid.length === 0, + detail: invalid.length === 0 + ? `all ${HOOK_SCRIPT_NAMES.length} runtime files match this Evolver version` + : invalid.join(', '), + }; +} + function appendSectionToFile(filePath, marker, content) { let existing = ''; try { existing = fs.readFileSync(filePath, 'utf8'); } catch { /* new file */ } @@ -349,17 +378,8 @@ function removeHookScripts(hooksDir) { // files behind that the user then has to clean up by hand (#547 fix // would have introduced exactly this gap if only the install side // had been updated). - const scripts = [ - '_runtimePaths.js', - '_memoryFiltering.js', - '_lockPaths.js', - 'evolver-session-start.js', - 'evolver-signal-detect.js', - 'evolver-session-end.js', - 'evolver-task-recall.js', - ]; let removed = 0; - for (const name of scripts) { + for (const name of HOOK_SCRIPT_NAMES) { const p = path.join(hooksDir, name); try { if (fs.existsSync(p)) { fs.unlinkSync(p); removed++; } @@ -413,10 +433,10 @@ function removeMarkedSection(filePath, marker) { } } -async function setupHooks({ platform, cwd, force, uninstall, evolverRoot } = {}) { +async function setupHooks({ platform, cwd, configRoot, force, uninstall, evolverRoot } = {}) { const effectiveCwd = cwd || process.cwd(); const effectiveEvolverRoot = evolverRoot || path.resolve(__dirname, '..'); - const platformId = platform || detectPlatform(effectiveCwd); + const platformId = platform || detectPlatform(configRoot || effectiveCwd); if (!platformId) { console.error('[setup-hooks] Could not detect platform. Use --platform=cursor|claude-code|codex|kiro|opencode'); @@ -429,7 +449,9 @@ async function setupHooks({ platform, cwd, force, uninstall, evolverRoot } = {}) return { ok: false, error: 'unknown_platform' }; } - const configRoot = resolveConfigRoot(platformId, effectiveCwd); + const resolvedConfigRoot = configRoot + ? path.resolve(configRoot) + : resolveConfigRoot(platformId, effectiveCwd); const adapter = loadAdapter(platformId); if (!adapter) { console.error(`[setup-hooks] No adapter found for ${platformId}`); @@ -437,13 +459,13 @@ async function setupHooks({ platform, cwd, force, uninstall, evolverRoot } = {}) } console.log(`[setup-hooks] Platform: ${meta.name}`); - console.log(`[setup-hooks] Config root: ${configRoot}`); + console.log(`[setup-hooks] Config root: ${resolvedConfigRoot}`); if (uninstall) { - return adapter.uninstall({ configRoot, evolverRoot: effectiveEvolverRoot }); + return adapter.uninstall({ configRoot: resolvedConfigRoot, evolverRoot: effectiveEvolverRoot }); } - return adapter.install({ configRoot, evolverRoot: effectiveEvolverRoot, force }); + return adapter.install({ configRoot: resolvedConfigRoot, evolverRoot: effectiveEvolverRoot, force }); } module.exports = { @@ -458,6 +480,7 @@ module.exports = { collectCommands, isEvolverHookCommand, copyHookScripts, + verifyHookScriptCopies, appendSectionToFile, assertSafeConfigDir, assertNotSymlink, @@ -465,5 +488,6 @@ module.exports = { removeHookScripts, removeMarkedSection, setupHooks, + HOOK_SCRIPT_NAMES, PLATFORMS, }; diff --git a/src/adapters/opencode.js b/src/adapters/opencode.js index 831dca32..361f89b6 100644 --- a/src/adapters/opencode.js +++ b/src/adapters/opencode.js @@ -1,6 +1,6 @@ const fs = require('fs'); const path = require('path'); -const { copyHookScripts, removeHookScripts, removeMarkedSection, assertSafeConfigDir } = require('./hookAdapter'); +const { copyHookScripts, verifyHookScriptCopies, removeHookScripts, removeMarkedSection, assertSafeConfigDir } = require('./hookAdapter'); const HOOK_SCRIPTS_DIR_NAME = 'hooks'; const PLUGINS_DIR_NAME = 'plugins'; @@ -186,12 +186,6 @@ function verify({ configRoot }) { const pluginsDir = path.join(opencodeDir, PLUGINS_DIR_NAME); const pluginPath = path.join(pluginsDir, PLUGIN_FILE_NAME); const agentsMdPath = path.join(configRoot, 'AGENTS.md'); - const expectedScripts = [ - 'evolver-session-start.js', - 'evolver-signal-detect.js', - 'evolver-session-end.js', - ]; - const checks = []; const pluginExists = fs.existsSync(pluginPath); @@ -214,15 +208,17 @@ function verify({ configRoot }) { let pluginLoadError = null; if (pluginExists) { try { - // Require the plugin in an isolated module cache slot to confirm it - // parses and exports the expected shape. opencode does an ESM dynamic - // import; CommonJS require here is a strict subset of that, so a - // failure here is a guaranteed failure under opencode too. - delete require.cache[require.resolve(pluginPath)]; - const mod = require(pluginPath); - const fn = mod && (mod.Evolver || mod.default); - pluginLoadable = typeof fn === 'function'; - if (!pluginLoadable) pluginLoadError = 'no Evolver/default function export'; + const stat = fs.lstatSync(pluginPath); + if (!stat.isFile()) { + pluginLoadError = 'plugin path is not a regular file'; + } else { + const installed = fs.readFileSync(pluginPath, 'utf8'); + const expected = buildPluginSource(hooksDir); + pluginLoadable = installed === expected; + if (!pluginLoadable) { + pluginLoadError = 'plugin differs from the source generated by this Evolver version'; + } + } } catch (err) { pluginLoadError = (err && err.message) || String(err); } @@ -231,20 +227,11 @@ function verify({ configRoot }) { id: 'plugin_loadable', ok: pluginLoadable, detail: pluginLoadable - ? 'require() succeeded and exports Evolver()' - : 'require() failed: ' + (pluginLoadError || 'unknown'), + ? 'plugin matches the generated Evolver source' + : 'static verification failed: ' + (pluginLoadError || 'unknown'), }); - const missingScripts = expectedScripts.filter( - (name) => !fs.existsSync(path.join(hooksDir, name)) - ); - checks.push({ - id: 'hook_scripts_present', - ok: missingScripts.length === 0, - detail: missingScripts.length === 0 - ? 'all 3 hook scripts present in ' + hooksDir - : 'missing: ' + missingScripts.join(', '), - }); + checks.push(verifyHookScriptCopies(hooksDir)); let agentsMdHasSection = false; try { diff --git a/test/adapters.opencode.test.js b/test/adapters.opencode.test.js index 19aa4d4b..fef5de8c 100644 --- a/test/adapters.opencode.test.js +++ b/test/adapters.opencode.test.js @@ -289,6 +289,25 @@ describe('opencode adapter: verify (issue #531)', () => { } finally { cleanup(tmp); } }); + it('verifies an edited plugin without executing workspace code', () => { + const tmp = makeTmpDir(); + try { + fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true }); + const evolverRoot = path.resolve(__dirname, '..'); + opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true }); + const proofPath = path.join(tmp, 'plugin-executed'); + const pluginPath = path.join(tmp, '.opencode', 'plugins', 'evolver.js'); + fs.writeFileSync( + pluginPath, + `// _evolver_managed: true\nrequire('fs').writeFileSync(${JSON.stringify(proofPath)}, 'bad')\n` + ); + + const report = opencodeAdapter.verify({ configRoot: tmp }); + assert.equal(report.ok, false); + assert.equal(fs.existsSync(proofPath), false); + } finally { cleanup(tmp); } + }); + it('reports ok=false when one hook script is missing', () => { const tmp = makeTmpDir(); try { @@ -306,6 +325,22 @@ describe('opencode adapter: verify (issue #531)', () => { } finally { cleanup(tmp); } }); + it('reports ok=false when a copied helper script is missing', () => { + const tmp = makeTmpDir(); + try { + fs.mkdirSync(path.join(tmp, '.opencode'), { recursive: true }); + const evolverRoot = path.resolve(__dirname, '..'); + opencodeAdapter.install({ configRoot: tmp, evolverRoot, force: true }); + fs.unlinkSync(path.join(tmp, '.opencode', 'hooks', '_runtimePaths.js')); + + const report = opencodeAdapter.verify({ configRoot: tmp }); + assert.equal(report.ok, false); + const scripts = report.checks.find((check) => check.id === 'hook_scripts_present'); + assert.equal(scripts.ok, false); + assert.match(scripts.detail, /_runtimePaths\.js/); + } finally { cleanup(tmp); } + }); + it('reports ok=false when AGENTS.md section is missing', () => { const tmp = makeTmpDir(); try { diff --git a/test/adapters.test.js b/test/adapters.test.js index ebde21c3..55aa1f9e 100644 --- a/test/adapters.test.js +++ b/test/adapters.test.js @@ -80,6 +80,91 @@ function withHome(home, fn) { // -- hookAdapter -- describe('hookAdapter', () => { + describe('setup-hooks CLI contract', () => { + it('honors legacy --runtime plus explicit --root and emits JSON verification', () => { + const tmp = makeTmpDir(); + try { + const decoyCwd = path.join(tmp, 'decoy'); + const targetRoot = path.join(tmp, 'target workspace'); + fs.mkdirSync(path.join(decoyCwd, '.cursor'), { recursive: true }); + fs.mkdirSync(targetRoot, { recursive: true }); + const entry = path.resolve(__dirname, '..', 'index.js'); + + const install = spawnSync(process.execPath, [ + entry, + 'setup-hooks', + '--runtime=claude-code', + `--root=${targetRoot}`, + '--force', + ], { + cwd: decoyCwd, + encoding: 'utf8', + timeout: 15000, + }); + assert.equal(install.status, 0, install.stderr); + assert.ok(fs.existsSync(path.join(targetRoot, '.claude', 'settings.json'))); + assert.ok(!fs.existsSync(path.join(decoyCwd, '.cursor', 'hooks.json'))); + + const verify = spawnSync(process.execPath, [ + entry, + 'setup-hooks', + '--runtime=claude-code', + `--root=${targetRoot}`, + '--verify', + '--json', + ], { + cwd: decoyCwd, + encoding: 'utf8', + timeout: 15000, + }); + assert.equal(verify.status, 0, verify.stderr); + const report = JSON.parse(verify.stdout); + assert.equal(report.ok, true); + assert.equal(report.platform, 'claude-code'); + assert.equal(report.config_root, targetRoot); + + const settingsPath = path.join(targetRoot, '.claude', 'settings.json'); + const malformed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + malformed.hooks.SessionStart[0].hooks = {}; + fs.writeFileSync(settingsPath, JSON.stringify(malformed)); + const failedVerify = spawnSync(process.execPath, [ + entry, + 'setup-hooks', + '--runtime=claude-code', + `--root=${targetRoot}`, + '--verify', + '--json', + ], { + cwd: decoyCwd, + encoding: 'utf8', + timeout: 15000, + }); + assert.equal(failedVerify.status, 1); + assert.equal(JSON.parse(failedVerify.stdout).ok, false); + } finally { cleanup(tmp); } + }); + + it('rejects conflicting --platform and --runtime values', () => { + const tmp = makeTmpDir(); + try { + const entry = path.resolve(__dirname, '..', 'index.js'); + const result = spawnSync(process.execPath, [ + entry, + 'setup-hooks', + '--platform=codex', + '--runtime=claude-code', + `--root=${tmp}`, + ], { + cwd: tmp, + encoding: 'utf8', + timeout: 15000, + }); + assert.equal(result.status, 2); + assert.match(result.stderr, /conflicting.*platform.*runtime/i); + } finally { cleanup(tmp); } + }); + }); + describe('detectPlatform', () => { it('detects cursor from .cursor directory', () => { const tmp = makeTmpDir(); @@ -912,6 +997,63 @@ describe('claudeCode adapter', () => { } finally { cleanup(tmp); } }); + it('verifies the installed hook files and reports missing runtime assets', () => { + const tmp = makeTmpDir(); + try { + const evolverRoot = path.resolve(__dirname, '..'); + claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true }); + const healthy = claudeAdapter.verify({ configRoot: tmp }); + assert.equal(healthy.ok, true); + assert.equal(healthy.platform, 'claude-code'); + + fs.unlinkSync(path.join(tmp, '.claude', 'hooks', 'evolver-session-end.js')); + const broken = claudeAdapter.verify({ configRoot: tmp }); + assert.equal(broken.ok, false); + assert.ok(broken.checks.some(check => + check.id === 'hook_scripts_present' && + check.ok === false && + check.detail.includes('evolver-session-end.js') + )); + } finally { cleanup(tmp); } + }); + + it('does not accept commands that merely mention managed script names', () => { + const tmp = makeTmpDir(); + try { + const evolverRoot = path.resolve(__dirname, '..'); + claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true }); + const settingsPath = path.join(tmp, '.claude', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + settings.hooks.SessionStart[0].hooks[0].command = 'echo evolver-session-start.js'; + fs.writeFileSync(settingsPath, JSON.stringify(settings)); + const report = claudeAdapter.verify({ configRoot: tmp }); + assert.equal(report.ok, false); + assert.equal( + report.checks.find(check => check.id === 'hooks_registered').ok, + false + ); + } finally { cleanup(tmp); } + }); + + it('reports globally disabled or structurally altered hooks as unhealthy', () => { + const tmp = makeTmpDir(); + try { + const evolverRoot = path.resolve(__dirname, '..'); + claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true }); + const settingsPath = path.join(tmp, '.claude', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + settings.disableAllHooks = true; + settings.hooks.PostToolUse[0].matcher = 'Bash'; + settings.hooks.Stop[0].hooks[0].type = 'prompt'; + fs.writeFileSync(settingsPath, JSON.stringify(settings)); + + const report = claudeAdapter.verify({ configRoot: tmp }); + assert.equal(report.ok, false); + assert.equal(report.checks.find(check => check.id === 'hooks_enabled').ok, false); + assert.equal(report.checks.find(check => check.id === 'hooks_registered').ok, false); + } finally { cleanup(tmp); } + }); + it('install preserves user-installed Stop hook (#539)', () => { const tmp = makeTmpDir(); try { @@ -1169,6 +1311,43 @@ describe('codex adapter', () => { } finally { cleanup(tmp); } }); + it('verifies the installed hooks, feature flag, and memory section', () => { + const tmp = makeTmpDir(); + try { + const evolverRoot = path.resolve(__dirname, '..'); + codexAdapter.install({ configRoot: tmp, evolverRoot, force: true }); + const healthy = codexAdapter.verify({ configRoot: tmp }); + assert.equal(healthy.ok, true); + assert.equal(healthy.platform, 'codex'); + + fs.writeFileSync( + path.join(tmp, '.codex', 'config.toml'), + '[features]\n# codex_hooks = true\n' + ); + const broken = codexAdapter.verify({ configRoot: tmp }); + assert.equal(broken.ok, false); + assert.ok(broken.checks.some(check => + check.id === 'codex_hooks_enabled' && check.ok === false + )); + } finally { cleanup(tmp); } + }); + + it('rejects a Codex hook whose command is present under the wrong type', () => { + const tmp = makeTmpDir(); + try { + const evolverRoot = path.resolve(__dirname, '..'); + codexAdapter.install({ configRoot: tmp, evolverRoot, force: true }); + const hooksPath = path.join(tmp, '.codex', 'hooks.json'); + const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + hooks.hooks.Stop[0].type = 'prompt'; + fs.writeFileSync(hooksPath, JSON.stringify(hooks)); + + const report = codexAdapter.verify({ configRoot: tmp }); + assert.equal(report.ok, false); + assert.equal(report.checks.find(check => check.id === 'hooks_registered').ok, false); + } finally { cleanup(tmp); } + }); + it('ensureConfigToml adds feature flag', () => { const tmp = makeTmpDir(); try { @@ -1184,6 +1363,112 @@ describe('codex adapter', () => { } finally { cleanup(tmp); } }); + it('does not treat a commented codex_hooks line as enabled', () => { + const tmp = makeTmpDir(); + try { + const codexDir = path.join(tmp, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + fs.writeFileSync( + path.join(codexDir, 'config.toml'), + '[features]\n# codex_hooks = true\n' + ); + assert.equal(codexAdapter.ensureConfigToml(codexDir), true); + const content = fs.readFileSync(path.join(codexDir, 'config.toml'), 'utf8'); + assert.equal(codexAdapter.codexHooksEnabled(content), true); + assert.match(content, /^codex_hooks = true$/m); + } finally { cleanup(tmp); } + }); + + it('replaces a false feature value without creating a duplicate TOML key', () => { + const tmp = makeTmpDir(); + try { + const codexDir = path.join(tmp, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + fs.writeFileSync( + path.join(codexDir, 'config.toml'), + '[features]\ncodex_hooks = false\nother = true\n' + ); + assert.equal(codexAdapter.ensureConfigToml(codexDir), true); + const content = fs.readFileSync(path.join(codexDir, 'config.toml'), 'utf8'); + assert.equal((content.match(/^codex_hooks\s*=/gm) || []).length, 1); + assert.match(content, /^codex_hooks = true$/m); + assert.match(content, /^other = true$/m); + } finally { cleanup(tmp); } + }); + + it('uninstall preserves same-named keys outside the features table', () => { + const tmp = makeTmpDir(); + try { + const codexDir = path.join(tmp, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + fs.writeFileSync( + path.join(codexDir, 'config.toml'), + '[features]\ncodex_hooks = true\n\n[custom]\ncodex_hooks = true\n' + ); + assert.equal(codexAdapter.cleanConfigToml(codexDir), true); + const content = fs.readFileSync(path.join(codexDir, 'config.toml'), 'utf8'); + assert.equal(codexAdapter.codexHooksEnabled(content), false); + assert.match(content, /\[custom\]\ncodex_hooks = true/); + } finally { cleanup(tmp); } + }); + + it('ignores feature-shaped text inside TOML multiline strings', () => { + const tmp = makeTmpDir(); + try { + const codexDir = path.join(tmp, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + const original = [ + '[custom]', + 'notes = """first line', + '', + '[features]', + 'codex_hooks = true', + 'last line"""', + '', + ].join('\n'); + const configPath = path.join(codexDir, 'config.toml'); + fs.writeFileSync(configPath, original); + + assert.equal(codexAdapter.codexHooksEnabled(original), false); + assert.equal(codexAdapter.ensureConfigToml(codexDir), true); + const installed = fs.readFileSync(configPath, 'utf8'); + assert.equal(codexAdapter.codexHooksEnabled(installed), true); + assert.ok(installed.includes(original), 'install must preserve multiline string bytes'); + + assert.equal(codexAdapter.cleanConfigToml(codexDir), true); + const cleaned = fs.readFileSync(configPath, 'utf8'); + assert.equal(codexAdapter.codexHooksEnabled(cleaned), false); + assert.ok(cleaned.includes(original), 'uninstall must preserve multiline string bytes'); + } finally { cleanup(tmp); } + }); + + it('keeps TOML table and key matching case-sensitive', () => { + const tmp = makeTmpDir(); + try { + const codexDir = path.join(tmp, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + const configPath = path.join(codexDir, 'config.toml'); + fs.writeFileSync( + configPath, + '[Features]\ncodex_hooks = true\n\n[features]\nCodex_Hooks = true\n' + ); + + assert.equal( + codexAdapter.codexHooksEnabled(fs.readFileSync(configPath, 'utf8')), + false + ); + assert.equal(codexAdapter.ensureConfigToml(codexDir), true); + const installed = fs.readFileSync(configPath, 'utf8'); + assert.match(installed, /\[Features\]\ncodex_hooks = true/); + assert.match(installed, /\[features\]\ncodex_hooks = true\nCodex_Hooks = true/); + + assert.equal(codexAdapter.cleanConfigToml(codexDir), true); + const cleaned = fs.readFileSync(configPath, 'utf8'); + assert.match(cleaned, /\[Features\]\ncodex_hooks = true/); + assert.match(cleaned, /\[features\]\nCodex_Hooks = true/); + } finally { cleanup(tmp); } + }); + it('uninstalls hooks and AGENTS.md section', () => { const tmp = makeTmpDir(); try { From d6f9e33bd0deaf25f00f15f9482d85a311521273 Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Tue, 25 Aug 2026 20:33:25 +0800 Subject: [PATCH 2/2] ci: retrigger governance check after PR body fix pull_request_target does not rerun on body edits, so the harness packet needs a new synchronize event.