#!/usr/bin/env node 'use strict'; /** * Phase 1 · T1.2 + T1.6 补丁 — SessionStart MCP Probe Hook * * 目的: * 1. 创建 hooks/session-start-mcp-probe.js (轻量每日探测) * 2. 注册到 settings.json 的 UserPromptSubmit (Bookworm 无 SessionStart 键) * * 设计: * - 日级守卫: 今日 snapshot 存在则 1ms 返回 * - 轻量探测: 只校验命令存在性 / URL 格式,不 spawn MCP * - Fail-open: 任何异常 exit 0,不阻断用户输入 * - Budget: 首次 <500ms,后续 <5ms * - Feature flag: ~/.claude/.bookworm-features.json.mcp_probe=false 可关闭 * * 幂等: * - sentinel: PHASE1_T1_2_MCP_PROBE_HOOK_2026_04_24 * - settings.json: 通过 command 字符串唯一性检测 * * 原子性: * - hook 文件: tmp + rename * - settings.json: .bak.phase1-t1.2 + tmp + rename * * 回滚: * - 删除 hook 文件 + 从 settings.json 的 UserPromptSubmit 数组移除对应条目 * - 或在 .bookworm-features.json 设 mcp_probe=false */ const fs = require('fs'); const path = require('path'); const CLAUDE_ROOT = path.join(__dirname, '..', '..'); const HOOKS_DIR = path.join(CLAUDE_ROOT, 'hooks'); const HOOK_TARGET = path.join(HOOKS_DIR, 'session-start-mcp-probe.js'); const SETTINGS_FILE = path.join(CLAUDE_ROOT, 'settings.json'); const SETTINGS_BAK = SETTINGS_FILE + '.bak.phase1-t1.2'; const SENTINEL = 'PHASE1_T1_2_MCP_PROBE_HOOK_2026_04_24'; const HOOK_CMD = 'node C:/Users/leesu/.claude/hooks/session-start-mcp-probe.js'; const HOOK_CONTENT = `#!/usr/bin/env node 'use strict'; /** * SessionStart MCP Probe (Phase 1 · T1.2) * sentinel: ${SENTINEL} * * 事件: UserPromptSubmit (Bookworm 无 SessionStart 键,用 UserPromptSubmit + 日期守卫替代) * 目的: 每日首次会话轻量探测 MCP 配置健康度,结果写 logs/mcp-health-.json * * 预算: * - 今日 snapshot 已存在: <5ms (fs.existsSync 快速返回) * - 首次运行: <500ms (22 个 MCP 的命令存在性检查) * * 容错: * - Feature flag 关闭: 立即 exit 0 * - 任何异常: exit 0 (fail-open,永不阻断用户输入) * * 不做的事: * - 不 spawn MCP 子进程 (太慢,交给 /mcp-probe 技能) * - 不做实际 HTTP 请求 * - 不修改 .claude.json */ const fs = require('fs'); const path = require('path'); const os = require('os'); const HOME = process.env.USERPROFILE || process.env.HOME || os.homedir(); const CLAUDE_ROOT = process.env.CLAUDE_HOME || (fs.existsSync(path.join(HOME, '.claude')) ? path.join(HOME, '.claude') : HOME); const LOGS_DIR = path.join(CLAUDE_ROOT, 'logs'); const FEATURE_FLAGS_FILE = path.join(CLAUDE_ROOT, '.bookworm-features.json'); const TODAY = new Date().toISOString().slice(0, 10); const HEALTH_FILE = path.join(LOGS_DIR, 'mcp-health-' + TODAY + '.json'); const CONFIG_FILE = path.join(HOME, '.claude.json'); function safeExit() { process.exit(0); } // 已知在 PATH 上的命令(避免 which 调用开销) const WELL_KNOWN_CMDS = /^(npx|node|python|python3|bash|sh|pnpm|yarn|uvx|go|deno|bun)$/; function commandPlausible(cmd) { if (!cmd || typeof cmd !== 'string') return false; if (path.isAbsolute(cmd)) return fs.existsSync(cmd); // Windows 可能配置带 .cmd/.exe/.bat 后缀 (e.g. npx.cmd),剥离后比对已知命令 const stripped = cmd.replace(/\\.(exe|cmd|bat)$/i, ''); if (WELL_KNOWN_CMDS.test(stripped)) return true; // 回退: 按 PATH 扫描 (受限于 PATH 数量,10-30 次 existsSync 可接受) const PATH_DIRS = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean); const candidates = [cmd, cmd + '.exe', cmd + '.cmd', cmd + '.bat']; for (const dir of PATH_DIRS) { for (const c of candidates) { try { if (fs.existsSync(path.join(dir, c))) return true; } catch {} } } return false; } function probeMcp(name, cfg) { try { if (cfg && (cfg.type === 'http' || cfg.type === 'sse')) { const hasUrl = !!cfg.url && /^https?:\\/\\//.test(cfg.url); return { kind: cfg.type, url: cfg.url || null, urlValid: hasUrl, reachable: hasUrl }; } // 默认 stdio const command = (cfg && cfg.command) || ''; const exists = commandPlausible(command); return { kind: 'stdio', command, commandExists: exists, reachable: exists }; } catch (e) { return { error: String(e.message || e), reachable: false }; } } function main() { // Feature flag 检查 try { if (fs.existsSync(FEATURE_FLAGS_FILE)) { const flags = JSON.parse(fs.readFileSync(FEATURE_FLAGS_FILE, 'utf8')); if (flags && flags.mcp_probe === false) return safeExit(); } } catch {} // 今日 snapshot 已存在 → 跳过 (日级守卫) if (fs.existsSync(HEALTH_FILE)) return safeExit(); // 读取 MCP 配置 let mcpServers = {}; try { const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); mcpServers = cfg.mcpServers || {}; } catch { return safeExit(); } const results = {}; let reachable = 0; let unreachable = 0; const unreachableList = []; for (const name of Object.keys(mcpServers)) { const r = probeMcp(name, mcpServers[name]); results[name] = r; if (r.reachable) reachable++; else { unreachable++; unreachableList.push(name); } } const snapshot = { schema_version: 1, date: TODAY, probedAt: new Date().toISOString(), probeKind: 'lightweight-static', totalMcps: Object.keys(mcpServers).length, reachable, unreachable, unreachableList, results }; // 确保 logs 目录 try { fs.mkdirSync(LOGS_DIR, { recursive: true }); } catch {} // 原子写 try { const tmp = HEALTH_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(snapshot, null, 2), 'utf8'); fs.renameSync(tmp, HEALTH_FILE); } catch {} safeExit(); } if (require.main === module) main(); module.exports = { probeMcp, commandPlausible }; `; function patchHookFile() { if (!fs.existsSync(HOOKS_DIR)) { console.error('[patch-phase1-T1.2] hooks 目录不存在:', HOOKS_DIR); process.exit(1); } if (fs.existsSync(HOOK_TARGET)) { const current = fs.readFileSync(HOOK_TARGET, 'utf8'); if (current.includes(SENTINEL) && current === HOOK_CONTENT) { console.log('[patch-phase1-T1.2] hook 已落地且内容一致,跳过 hook 写入'); return 'skipped'; } const bak = HOOK_TARGET + '.bak.phase1-t1.2'; fs.copyFileSync(HOOK_TARGET, bak); console.log('[patch-phase1-T1.2] 已备份旧 hook:', bak); } const tmp = HOOK_TARGET + '.tmp'; fs.writeFileSync(tmp, HOOK_CONTENT, 'utf8'); fs.renameSync(tmp, HOOK_TARGET); console.log('[patch-phase1-T1.2] 已写入 hook:', HOOK_TARGET); // 语法自检 (只检查能否被 Node 解析,不执行 main) try { const { execFileSync } = require('child_process'); execFileSync(process.execPath, ['--check', HOOK_TARGET], { stdio: 'pipe' }); console.log('[patch-phase1-T1.2] hook 语法自检 PASS (node --check)'); } catch (e) { console.error('[patch-phase1-T1.2] hook 语法自检失败:', (e.stderr || e.message || '').toString().slice(0, 500)); process.exit(3); } return 'written'; } function patchSettings() { if (!fs.existsSync(SETTINGS_FILE)) { console.error('[patch-phase1-T1.2] settings.json 不存在:', SETTINGS_FILE); process.exit(4); } const before = fs.readFileSync(SETTINGS_FILE, 'utf8'); const settings = JSON.parse(before); if (!settings.hooks) settings.hooks = {}; if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = []; // 幂等: 检查是否已注册 const alreadyRegistered = settings.hooks.UserPromptSubmit.some(group => Array.isArray(group.hooks) && group.hooks.some(h => h.command === HOOK_CMD) ); if (alreadyRegistered) { console.log('[patch-phase1-T1.2] settings.json 已注册,跳过'); return 'skipped'; } // 备份原 settings.json fs.copyFileSync(SETTINGS_FILE, SETTINGS_BAK); console.log('[patch-phase1-T1.2] 已备份 settings.json:', SETTINGS_BAK); // 追加新 hook group (独立 group,不影响既有 hook 顺序) settings.hooks.UserPromptSubmit.push({ hooks: [ { type: 'command', command: HOOK_CMD, timeout: 3000 } ] }); // 原子写 const tmp = SETTINGS_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(settings, null, 2), 'utf8'); // 验证写出的 JSON 可解析 try { JSON.parse(fs.readFileSync(tmp, 'utf8')); } catch (e) { fs.unlinkSync(tmp); console.error('[patch-phase1-T1.2] 写出的 settings.json 无法解析,中止'); process.exit(5); } fs.renameSync(tmp, SETTINGS_FILE); console.log('[patch-phase1-T1.2] settings.json 已更新'); return 'updated'; } function main() { const hookResult = patchHookFile(); const settingsResult = patchSettings(); console.log(''); console.log('[patch-phase1-T1.2] sentinel:', SENTINEL); console.log('[patch-phase1-T1.2] hook:', hookResult); console.log('[patch-phase1-T1.2] settings:', settingsResult); console.log('[patch-phase1-T1.2] 完成。'); console.log(''); console.log('验证:'); console.log(' 1. 下次 UserPromptSubmit 触发,生成 ~/.claude/logs/mcp-health-.json'); console.log(' 2. 查看: cat ~/.claude/logs/mcp-health-$(date -u +%Y-%m-%d).json'); console.log(''); console.log('紧急回滚:'); console.log(' 方式 1: cp ' + SETTINGS_BAK + ' ' + SETTINGS_FILE); console.log(' 方式 2: 在 ' + path.join(CLAUDE_ROOT, '.bookworm-features.json') + ' 设 {"mcp_probe": false}'); process.exit(0); } main();