diff --git a/bin/adapters/codex.js b/bin/adapters/codex.js index 5b54305..6d382fc 100644 --- a/bin/adapters/codex.js +++ b/bin/adapters/codex.js @@ -455,30 +455,14 @@ function patchAndReportCodexDefaults({ cfgPath, ok, warn }) { } // ── abyss 联动(hook + MCP)── -// hook 形状与 skills/indexing-code/hooks/codex/hooks.json 同源: -// [hooks.SessionStart] matcher/command/timeout -// [hooks.PreToolUse] matcher/command/timeout +// hook 形状与 skills/indexing-code/hooks/codex/hooks.json 同源,Codex 0.125+ 数组表 schema: +// [[hooks.SessionStart]] matcher + [[hooks.SessionStart.hooks]] type/command/timeout +// [[hooks.PreToolUse]] matcher + [[hooks.PreToolUse.hooks]] type/command/timeout +// 旧扁平 [hooks.X] 已被 Codex 拒载(invalid type: map, expected a sequence) // MCP: [mcp_servers.abyss] command/args —— 节名是我方命名空间,可自由 upsert。 const ABYSS_HOOK_MARKER = 'indexing-code/hooks/common'; -function readKeyValueInSection(content, sectionName, key) { - const lines = content.split(/\r?\n/); - const sectionRe = new RegExp(`^\\s*\\[${escapeRegExp(sectionName)}\\]\\s*$`); - const anySectionRe = /^\s*\[[^\]]+\]\s*$/; - const keyRe = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=\\s*(.*)$`); - let inSection = false; - for (const line of lines) { - if (sectionRe.test(line)) { inSection = true; continue; } - if (inSection && anySectionRe.test(line)) return null; - if (inSection) { - const m = line.match(keyRe); - if (m) return m[1].trim(); - } - } - return null; -} - function upsertKeyInSection(content, sectionName, key, valueLiteral, eol) { const removed = removeKeyAssignmentsInSection(content, sectionName, key); return ensureKeyInSection(removed.merged, sectionName, key, valueLiteral, eol).merged; @@ -489,27 +473,108 @@ function tomlPath(p) { return p.split(path.sep).join('/'); } -function injectCodexHooks(content, hookDir, eol) { +// 任意 TOML 表头:既配 [section] 也配 [[array.of.tables]] +const ANY_TOML_HEADER_RE = /^\s*\[\[?[^\]]+\]\]?\s*$/; +// hook 事件级表头(不含 .hooks 子表),捕获事件名 +const HOOK_EVENT_HEADER_RE = /^\[\[?hooks\.([A-Za-z]+)\]\]?$/; + +// 按表头把 TOML 切成块(保留原始行),[[..]] 与 [..] 同视为分界 +function splitTomlBlocks(content) { + const lines = content.split(/\r?\n/); + const blocks = []; + let cur = { header: null, lines: [] }; + for (const line of lines) { + if (ANY_TOML_HEADER_RE.test(line)) { + blocks.push(cur); + cur = { header: line.trim(), lines: [line] }; + } else { + cur.lines.push(line); + } + } + blocks.push(cur); + return blocks; +} + +// 把一个事件级 block 与其紧随的同事件 .hooks 子表聚成一组 +function gatherHookGroup(blocks, startIdx, eventName) { + const group = [blocks[startIdx]]; + const childRe = new RegExp(`^\\[\\[?hooks\\.${escapeRegExp(eventName)}\\.hooks\\]\\]?$`); + let j = startIdx + 1; + while (j < blocks.length && blocks[j].header && childRe.test(blocks[j].header)) { + group.push(blocks[j]); + j++; + } + return { group, next: j }; +} + +// Codex 0.125+ 期望数组表形态:[[hooks.Event]] + [[hooks.Event.hooks]],旧扁平表会报 +// "invalid type: map, expected a sequence in hooks" 直接拒载 config.toml。 +function renderCodexHookBlock(ev, dir, winBash, eol) { + const target = `${dir}/${ev.script}`; + const L = [ + `[[hooks.${ev.name}]]`, + `matcher = "${ev.matcher}"`, + '', + `[[hooks.${ev.name}.hooks]]`, + 'type = "command"', + `command = "bash \\"${target}\\""`, + ]; + // Windows 下 Git Bash 可能不在 PATH,command_windows 钉到安装期解析出的 bash 绝对路径 + if (winBash) L.push(`command_windows = "\\"${tomlPath(winBash)}\\" \\"${target}\\""`); + L.push(`timeout = ${ev.timeout}`); + L.push(`statusMessage = "${ev.statusMessage}"`); + return L.join(eol); +} + +function injectCodexHooks(content, hookDir, eol, opts = {}) { const dir = tomlPath(hookDir); + const winBash = opts.winBash || null; const events = [ - { section: 'hooks.SessionStart', matcher: '"startup|resume"', script: 'session-init.sh', timeout: '10' }, - { section: 'hooks.PreToolUse', matcher: '"Bash|shell"', script: 'pre-edit-check.sh', timeout: '5' }, + { name: 'SessionStart', matcher: 'startup|resume', script: 'session-init.sh', timeout: 10, statusMessage: 'abyss: checking index' }, + { name: 'PreToolUse', matcher: 'Bash|shell', script: 'pre-edit-check.sh', timeout: 5, statusMessage: 'abyss: checking callers' }, ]; - let merged = content; + + // 1. 先剥掉我方旧 hook 组(带标记),同时记录用户自有事件——保持非破坏 + 旧路径重锚 + const blocks = splitTomlBlocks(content); + const userOwned = new Set(); + const kept = []; + let i = 0; + while (i < blocks.length) { + const b = blocks[i]; + const m = b.header && b.header.match(HOOK_EVENT_HEADER_RE); + if (m) { + const { group, next } = gatherHookGroup(blocks, i, m[1]); + const groupText = group.map((g) => g.lines.join('\n')).join('\n'); + if (groupText.includes(ABYSS_HOOK_MARKER)) { + // 我方旧条目:丢弃,稍后重建(顺带重锚旧路径) + } else { + userOwned.add(m[1]); + for (const g of group) kept.push(...g.lines); + } + i = next; + continue; + } + kept.push(...b.lines); + i++; + } + + // 2. 为非用户占位的事件追加新块 const installed = []; const skipped = []; + const fresh = []; for (const ev of events) { - const existing = readKeyValueInSection(merged, ev.section, 'command'); - // 用户自有 hook(无我方标记)占位时不抢,保持非破坏 - if (existing && !existing.includes(ABYSS_HOOK_MARKER)) { - skipped.push(ev.section); + if (userOwned.has(ev.name)) { + skipped.push(`hooks.${ev.name}`); continue; } - const cmd = `"bash \\"${dir}/${ev.script}\\""`; - merged = upsertKeyInSection(merged, ev.section, 'matcher', ev.matcher, eol); - merged = upsertKeyInSection(merged, ev.section, 'command', cmd, eol); - merged = upsertKeyInSection(merged, ev.section, 'timeout', ev.timeout, eol); - installed.push(ev.section); + fresh.push(renderCodexHookBlock(ev, dir, winBash, eol)); + installed.push(`hooks.${ev.name}`); + } + + let merged = kept.join(eol); + if (fresh.length) { + const base = merged.replace(/\s+$/, ''); + merged = (base ? base + eol + eol : '') + fresh.join(eol + eol) + eol; } return { merged, installed, skipped }; } @@ -521,45 +586,66 @@ function injectCodexMcp(content, abyssBinPath, eol) { return merged; } -// 卸载用:剥除带标记的 hook 节与 [mcp_servers.abyss] 节 +// 卸载用:剥除带标记的 hook 组(含 [[hooks.X]] + [[hooks.X.hooks]])与 [mcp_servers.abyss] 节 function stripCodexAbyssIntegration(content) { const eol = content.includes('\r\n') ? '\r\n' : '\n'; - const lines = content.split(/\r?\n/); - const anySectionRe = /^\s*\[[^\]]+\]\s*$/; - - // 先按节切块,再决定去留——hook 节看 body 是否含标记,mcp_servers.abyss 按名删 - const blocks = []; - let current = { header: null, lines: [] }; - for (const line of lines) { - if (anySectionRe.test(line)) { - blocks.push(current); - current = { header: line.trim(), lines: [line] }; - } else { - current.lines.push(line); - } - } - blocks.push(current); + const blocks = splitTomlBlocks(content); const kept = []; let removed = false; - for (const b of blocks) { - const isAbyssMcp = b.header === '[mcp_servers.abyss]'; - const isMarkedHook = b.header && /^\[hooks\./.test(b.header) - && b.lines.some((l) => l.includes(ABYSS_HOOK_MARKER)); - if (isAbyssMcp || isMarkedHook) { removed = true; continue; } + let i = 0; + while (i < blocks.length) { + const b = blocks[i]; + if (b.header === '[mcp_servers.abyss]') { removed = true; i++; continue; } + const m = b.header && b.header.match(HOOK_EVENT_HEADER_RE); + if (m) { + const { group, next } = gatherHookGroup(blocks, i, m[1]); + const groupText = group.map((g) => g.lines.join('\n')).join('\n'); + if (groupText.includes(ABYSS_HOOK_MARKER)) { + removed = true; + } else { + for (const g of group) kept.push(...g.lines); + } + i = next; + continue; + } kept.push(...b.lines); + i++; } // 顺带清掉孤立的 "# abyss hooks" 注释行 const cleaned = kept.filter((l) => l.trim() !== '# abyss hooks'); return { merged: cleaned.join(eol), removed }; } +// 安装期解析 Windows 下的 bash 绝对路径,供 command_windows 钉用——非 win32 返回 null。 +// 优先 PATH 中的 bash,其次 Git for Windows 常见安装位;找不到则不写 command_windows。 +function resolveWindowsBash(platform = process.platform, env = process.env) { + if (platform !== 'win32') return null; + try { + const { execSync } = require('child_process'); + const out = execSync('where bash', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString().split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + // 跳过 WSL 的 C:\Windows\System32\bash.exe(不能跑 Git Bash 脚本),优先 Git 的 + const git = out.find((p) => /git/i.test(p)) || out.find((p) => !/system32/i.test(p)); + if (git) return git; + } catch (_) { /* bash 不在 PATH,回落到常见安装位 */ } + const candidates = [ + path.join(env.ProgramFiles || 'C:/Program Files', 'Git', 'bin', 'bash.exe'), + path.join(env['ProgramFiles(x86)'] || 'C:/Program Files (x86)', 'Git', 'bin', 'bash.exe'), + path.join(env.LOCALAPPDATA || 'C:/Users/Default/AppData/Local', 'Programs', 'Git', 'bin', 'bash.exe'), + ]; + for (const c of candidates) { + try { if (fs.existsSync(c)) return c; } catch (_) { /* ignore */ } + } + return null; +} + function injectCodexAbyssIntegration({ cfgPath, HOME, withMcp = false, abyssBinPath = null }) { const raw = fs.existsSync(cfgPath) ? fs.readFileSync(cfgPath, 'utf8') : ''; const eol = raw.includes('\r\n') ? '\r\n' : '\n'; const hookDir = path.join(HOME, '.codex', 'skills', 'indexing-code', 'hooks', 'common'); - const hooks = injectCodexHooks(raw, hookDir, eol); + const hooks = injectCodexHooks(raw, hookDir, eol, { winBash: resolveWindowsBash() }); let merged = hooks.merged; let mcpInstalled = false; if (withMcp) { diff --git a/skills/indexing-code/hooks/common/install-hooks.sh b/skills/indexing-code/hooks/common/install-hooks.sh index c50114e..6c4091a 100755 --- a/skills/indexing-code/hooks/common/install-hooks.sh +++ b/skills/indexing-code/hooks/common/install-hooks.sh @@ -66,18 +66,28 @@ case "$TARGET" in if grep -q "indexing-code/hooks/common" "$SETTINGS" 2>/dev/null; then echo "✓ Codex hooks already present in $SETTINGS" else + # Codex 0.125+ expects array-of-tables hooks; the old flat [hooks.X] form + # is rejected with "invalid type: map, expected a sequence in hooks". cat >> "$SETTINGS" << TOML # abyss hooks -[hooks.SessionStart] +[[hooks.SessionStart]] matcher = "startup|resume" + +[[hooks.SessionStart.hooks]] +type = "command" command = "bash \"${SCRIPT_DIR}/session-init.sh\"" timeout = 10 +statusMessage = "abyss: checking index" -[hooks.PreToolUse] +[[hooks.PreToolUse]] matcher = "Bash|shell" + +[[hooks.PreToolUse.hooks]] +type = "command" command = "bash \"${SCRIPT_DIR}/pre-edit-check.sh\"" timeout = 5 +statusMessage = "abyss: checking callers" TOML echo "✓ Codex hooks appended to $SETTINGS" fi diff --git a/test/abyss-integration.test.js b/test/abyss-integration.test.js index 2e1d35a..fc0dd7f 100644 --- a/test/abyss-integration.test.js +++ b/test/abyss-integration.test.js @@ -99,24 +99,36 @@ describe('gemini hook 注入', () => { describe('codex TOML hook 注入', () => { const HOOK_DIR = '/home/user/.codex/skills/indexing-code/hooks/common'; - test('空 config 注入两个 hook 节', () => { + test('空 config 注入数组表形态的两个 hook(Codex 0.125+ schema)', () => { const { merged, installed, skipped } = injectCodexHooks('', HOOK_DIR, '\n'); expect(installed).toEqual(['hooks.SessionStart', 'hooks.PreToolUse']); expect(skipped).toEqual([]); - expect(merged).toContain('[hooks.SessionStart]'); - expect(merged).toContain('[hooks.PreToolUse]'); + // 数组表头:[[hooks.X]] + [[hooks.X.hooks]],绝不能再出现扁平 [hooks.X] + expect(merged).toContain('[[hooks.SessionStart]]'); + expect(merged).toContain('[[hooks.SessionStart.hooks]]'); + expect(merged).toContain('[[hooks.PreToolUse]]'); + expect(merged).toContain('[[hooks.PreToolUse.hooks]]'); + expect(merged).not.toMatch(/^\[hooks\./m); + expect(merged).toContain('type = "command"'); expect(merged).toContain(`${HOOK_DIR}/pre-edit-check.sh`); }); test('重复注入幂等(节与键不重复)', () => { const first = injectCodexHooks('', HOOK_DIR, '\n').merged; const second = injectCodexHooks(first, HOOK_DIR, '\n').merged; - const count = (second.match(/\[hooks\.PreToolUse\]/g) || []).length; + const count = (second.match(/\[\[hooks\.PreToolUse\]\]/g) || []).length; expect(count).toBe(1); const cmdCount = (second.match(/pre-edit-check\.sh/g) || []).length; expect(cmdCount).toBe(1); }); + test('command_windows 仅在传入 winBash 时生成', () => { + const without = injectCodexHooks('', HOOK_DIR, '\n').merged; + expect(without).not.toContain('command_windows'); + const withWin = injectCodexHooks('', HOOK_DIR, '\n', { winBash: 'C:/Program Files/Git/bin/bash.exe' }).merged; + expect(withWin).toContain('command_windows = "\\"C:/Program Files/Git/bin/bash.exe\\"'); + }); + test('旧路径条目被重锚定', () => { const stale = injectCodexHooks('', '/tmp/npx-cache/skills/indexing-code/hooks/common', '\n').merged; const fixed = injectCodexHooks(stale, HOOK_DIR, '\n').merged;