bookworm-smart-assistant/scripts/patches/patch-phase1-t1.2-mcp-probe-hook.js

296 lines
9.4 KiB
JavaScript
Raw Normal View History

#!/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-<date>.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-<today>.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();