bookworm-smart-assistant/scripts/patches/patch-phase1-t1.2-mcp-probe-hook.js
Bookworm Admin b7a8e29d21 release: v6.7.0 - OTA E2E test release
- VERSION file as authoritative version source
- export.mjs reads VERSION with package.json fallback
- bw-ota.ps1 DryRun mode for safe testing
- auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
2026-04-27 17:59:44 +08:00

296 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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