- 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)
337 lines
12 KiB
JavaScript
337 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
/**
|
||
* Phase 1 · T1.4 补丁 — /mcp-prune 命令
|
||
*
|
||
* 产出:
|
||
* 1. scripts/mcp-prune.js — 剪枝分析 CLI (纯只读 + 生成 plan)
|
||
* 2. skills/mcp-prune/SKILL.md — 触发器 /mcp-prune
|
||
*
|
||
* 安全设计:
|
||
* - 默认只报告 (read-only)
|
||
* - --plan: 生成 mcp-prune-plan-<date>.json
|
||
* - --confirm: 输出用户手动 apply 的指令,不自动修改 .claude.json
|
||
* - 永远不修改用户核心配置
|
||
*
|
||
* 幂等:
|
||
* - sentinel: PHASE1_T1_4_MCP_PRUNE_2026_04_24
|
||
* - 已存在且一致则跳过
|
||
*
|
||
* 回滚:
|
||
* - 删除 scripts/mcp-prune.js + skills/mcp-prune/ 目录
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const CLAUDE_ROOT = path.join(__dirname, '..', '..');
|
||
const SENTINEL = 'PHASE1_T1_4_MCP_PRUNE_2026_04_24';
|
||
|
||
const SCRIPT_TARGET = path.join(CLAUDE_ROOT, 'scripts', 'mcp-prune.js');
|
||
const SKILL_DIR = path.join(CLAUDE_ROOT, 'skills', 'mcp-prune');
|
||
const SKILL_TARGET = path.join(SKILL_DIR, 'SKILL.md');
|
||
|
||
const SCRIPT_CONTENT = `#!/usr/bin/env node
|
||
'use strict';
|
||
/**
|
||
* /mcp-prune — MCP 剪枝分析工具 (Phase 1 · T1.4)
|
||
* sentinel: ${SENTINEL}
|
||
*
|
||
* 职责:
|
||
* - 基于 mcp-usage-tracker 的数据识别低频 MCP
|
||
* - 交叉 mcp-critical-allowlist.json 保护救命 MCP
|
||
* - 生成剪枝 plan 文件 (JSON)
|
||
* - 输出用户手动 apply 的指令
|
||
*
|
||
* 用法:
|
||
* node scripts/mcp-prune.js # 默认: 只报告
|
||
* node scripts/mcp-prune.js --days 30 # 用 30 天窗口
|
||
* node scripts/mcp-prune.js --plan # 写入 mcp-prune-plan-<date>.json
|
||
* node scripts/mcp-prune.js --confirm # 输出用户 apply 的完整步骤
|
||
*
|
||
* 安全:
|
||
* - 永不修改 ~/.claude.json (用户核心配置)
|
||
* - 永不自动删除 MCP
|
||
* - --confirm 只打印操作指南,用户自行执行
|
||
*/
|
||
|
||
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 TRACKER_PATH = path.join(CLAUDE_ROOT, 'scripts', 'mcp-usage-tracker.js');
|
||
const ALLOWLIST_FILE = path.join(CLAUDE_ROOT, 'mcp-critical-allowlist.json');
|
||
const GLOBAL_CONFIG = path.join(HOME, '.claude.json');
|
||
|
||
function parseArgs(argv) {
|
||
const args = { days: 30, plan: false, confirm: false };
|
||
for (let i = 2; i < argv.length; i++) {
|
||
const a = argv[i];
|
||
if (a === '--days' && argv[i + 1]) { args.days = parseInt(argv[++i], 10) || 30; }
|
||
else if (a === '--plan') { args.plan = true; }
|
||
else if (a === '--confirm') { args.confirm = true; }
|
||
else if (a === '-h' || a === '--help') { args.help = true; }
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function safeReadJson(file, fallback) {
|
||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
||
catch { return fallback; }
|
||
}
|
||
|
||
function runAnalysis(days) {
|
||
const tracker = require(TRACKER_PATH);
|
||
const allowlist = safeReadJson(ALLOWLIST_FILE, { critical: [] });
|
||
const criticalSet = new Set((allowlist.critical || []).map(c => c.name));
|
||
const cfg = safeReadJson(GLOBAL_CONFIG, { mcpServers: {} });
|
||
const allServers = Object.keys(cfg.mcpServers || {});
|
||
|
||
const events = tracker.collectMcpEvents(days);
|
||
const stats = tracker.aggregate(events, allServers);
|
||
const candidates = tracker.identifyPruneCandidates(stats, criticalSet);
|
||
|
||
return { stats, candidates, criticalSet, allServers, cfg, days, events };
|
||
}
|
||
|
||
function report(result) {
|
||
console.log('');
|
||
console.log('═══════════════════════════════════════════════════════════');
|
||
console.log(' /mcp-prune — 剪枝分析 · ' + result.days + '天窗口');
|
||
console.log('═══════════════════════════════════════════════════════════');
|
||
console.log('');
|
||
console.log('总 MCP 数: ' + result.allServers.length);
|
||
console.log('★critical (永不剪枝): ' + result.criticalSet.size);
|
||
console.log('活跃 MCP (>0 调用): ' + Object.values(result.stats).filter(s => s.totalCalls > 0).length);
|
||
console.log('剪枝候选 (0 调用): ' + result.candidates.length);
|
||
console.log('');
|
||
|
||
if (result.candidates.length === 0) {
|
||
console.log('✅ 没有剪枝候选。所有 MCP 都在最近 ' + result.days + ' 天被使用或在 critical 清单。');
|
||
return;
|
||
}
|
||
|
||
console.log('剪枝候选清单:');
|
||
console.log('─'.repeat(60));
|
||
for (const c of result.candidates) {
|
||
console.log(' - ' + c.server.padEnd(24) + ' ' + c.reason);
|
||
}
|
||
console.log('');
|
||
|
||
const estTokens = result.candidates.length * 200;
|
||
console.log('预估节省 (tokens/cold start): ≈ ' + estTokens + ' tokens');
|
||
console.log(' (每 MCP schema 均值 200 tokens × ' + result.candidates.length + ')');
|
||
}
|
||
|
||
function writePlan(result, planFile) {
|
||
const plan = {
|
||
schema_version: 1,
|
||
generated: new Date().toISOString(),
|
||
tool: 'mcp-prune',
|
||
windowDays: result.days,
|
||
totalMcps: result.allServers.length,
|
||
critical: Array.from(result.criticalSet),
|
||
candidates: result.candidates.map(c => ({
|
||
server: c.server,
|
||
reason: c.reason,
|
||
recommendedAction: 'REMOVE from ~/.claude.json mcpServers (manual)',
|
||
backupHint: 'cp ~/.claude.json ~/.claude.json.bak-before-prune-' + new Date().toISOString().slice(0, 10)
|
||
})),
|
||
note: '此 plan 仅供参考,mcp-prune 绝不自动修改 .claude.json。用户需人工 apply。'
|
||
};
|
||
|
||
const tmp = planFile + '.tmp';
|
||
fs.writeFileSync(tmp, JSON.stringify(plan, null, 2), 'utf8');
|
||
fs.renameSync(tmp, planFile);
|
||
console.log('');
|
||
console.log('✅ Plan 写入: ' + planFile);
|
||
return plan;
|
||
}
|
||
|
||
function printApplyInstructions(plan) {
|
||
console.log('');
|
||
console.log('═══════════════════════════════════════════════════════════');
|
||
console.log(' 用户 Apply 步骤 (请人工执行)');
|
||
console.log('═══════════════════════════════════════════════════════════');
|
||
console.log('');
|
||
console.log('1. 备份原配置:');
|
||
console.log(' Copy-Item ~/.claude.json ~/.claude.json.bak-$(Get-Date -Format yyyy-MM-dd)');
|
||
console.log('');
|
||
console.log('2. 用编辑器打开 ~/.claude.json,在 mcpServers 下删除以下键:');
|
||
for (const c of plan.candidates) {
|
||
console.log(' • ' + c.server);
|
||
}
|
||
console.log('');
|
||
console.log('3. 重启 Claude Code 使改动生效');
|
||
console.log('');
|
||
console.log('4. 若需恢复某个 MCP, 从备份文件找回对应 JSON 片段即可');
|
||
console.log('');
|
||
console.log('⚠ 本工具不会自动修改 .claude.json — 这是故意设计的安全边界。');
|
||
}
|
||
|
||
function main() {
|
||
const args = parseArgs(process.argv);
|
||
if (args.help) {
|
||
console.log('用法: node scripts/mcp-prune.js [--days N] [--plan] [--confirm]');
|
||
console.log(' 默认: 只报告');
|
||
console.log(' --plan: 生成 mcp-prune-plan-<date>.json');
|
||
console.log(' --confirm: 打印用户手动 apply 的完整步骤');
|
||
process.exit(0);
|
||
}
|
||
|
||
if (!fs.existsSync(TRACKER_PATH)) {
|
||
console.error('错误: mcp-usage-tracker.js 不存在,需先完成 Phase 1 T1.1');
|
||
process.exit(2);
|
||
}
|
||
|
||
const result = runAnalysis(args.days);
|
||
report(result);
|
||
|
||
if (result.candidates.length === 0) {
|
||
process.exit(0);
|
||
}
|
||
|
||
if (args.plan || args.confirm) {
|
||
const planFile = path.join(CLAUDE_ROOT, 'mcp-prune-plan-' + new Date().toISOString().slice(0, 10) + '.json');
|
||
const plan = writePlan(result, planFile);
|
||
|
||
if (args.confirm) {
|
||
printApplyInstructions(plan);
|
||
}
|
||
} else {
|
||
console.log('');
|
||
console.log('提示: 运行 \\'--plan\\' 生成 plan 文件, \\'--confirm\\' 查看完整 apply 步骤');
|
||
}
|
||
|
||
process.exit(0);
|
||
}
|
||
|
||
if (require.main === module) main();
|
||
`;
|
||
|
||
const SKILL_CONTENT = `---
|
||
name: mcp-prune
|
||
version: 1.0.0
|
||
description: |
|
||
MCP 剪枝分析工具 (Phase 1 · T1.4)。基于 mcp-usage-tracker 的使用率数据,
|
||
识别最近 N 天零调用且非 critical 的 MCP 候选,生成剪枝 plan 文件。
|
||
绝不自动修改 ~/.claude.json,用户需人工 apply。
|
||
触发词: "mcp-prune", "剪枝 MCP", "MCP 剪枝", "清理 MCP", "精简 MCP",
|
||
"disable unused MCP", "prune MCP servers"。
|
||
maturity: stable
|
||
allowed-tools:
|
||
- Bash
|
||
- Read
|
||
---
|
||
|
||
# /mcp-prune — MCP 剪枝分析
|
||
|
||
基于 \`scripts/mcp-usage-tracker.js\` 产出的使用率数据,识别并报告低频 MCP
|
||
候选。**绝不自动修改** \`~/.claude.json\` — 用户必须人工 apply。
|
||
|
||
## 安全边界
|
||
|
||
| 能力 | 默认 | --plan | --confirm |
|
||
|------|------|--------|-----------|
|
||
| 只读分析 | ✅ | ✅ | ✅ |
|
||
| 生成 plan 文件 | — | ✅ | ✅ |
|
||
| 修改 .claude.json | ❌ | ❌ | ❌ (永远不自动改) |
|
||
| 打印 apply 指令 | — | — | ✅ |
|
||
|
||
## 执行
|
||
|
||
\`\`\`bash
|
||
# 报告模式 (默认 30 天窗口)
|
||
node ~/.claude/scripts/mcp-prune.js
|
||
|
||
# 7 天窗口 (更激进)
|
||
node ~/.claude/scripts/mcp-prune.js --days 7
|
||
|
||
# 写入 plan 文件
|
||
node ~/.claude/scripts/mcp-prune.js --plan
|
||
|
||
# 打印用户 apply 步骤
|
||
node ~/.claude/scripts/mcp-prune.js --confirm
|
||
\`\`\`
|
||
|
||
## 剪枝逻辑
|
||
|
||
- 候选条件: 窗口内 0 调用 **AND** 不在 \`~/.claude/mcp-critical-allowlist.json\` 中
|
||
- 豁免: critical 清单永远保留
|
||
- 数据源:
|
||
- 使用率: \`~/.claude/debug/activity-*.jsonl\` (event=='mcp')
|
||
- 白名单: \`~/.claude/mcp-critical-allowlist.json\`
|
||
- 配置: \`~/.claude.json\` (只读)
|
||
|
||
## 输出
|
||
|
||
- 报告到 stdout
|
||
- --plan 时写入 \`~/.claude/mcp-prune-plan-<date>.json\`
|
||
- --confirm 追加 apply 指令 (PowerShell + 编辑 .claude.json 指引)
|
||
|
||
## 关联
|
||
|
||
- 依赖: \`scripts/mcp-usage-tracker.js\` (Phase 1 · T1.1)
|
||
- 依赖: \`mcp-critical-allowlist.json\` (Phase 1 · T1.5)
|
||
- 消费方: 用户手动 apply plan
|
||
|
||
## sentinel
|
||
|
||
${SENTINEL}
|
||
`;
|
||
|
||
function writeIfDifferent(target, content, label) {
|
||
if (fs.existsSync(target)) {
|
||
const current = fs.readFileSync(target, 'utf8');
|
||
if (current === content) {
|
||
console.log('[patch-phase1-T1.4] ' + label + ' 已一致,跳过');
|
||
return 'skipped';
|
||
}
|
||
const bak = target + '.bak.phase1-t1.4';
|
||
fs.copyFileSync(target, bak);
|
||
console.log('[patch-phase1-T1.4] 已备份:', bak);
|
||
}
|
||
const tmp = target + '.tmp.js';
|
||
fs.writeFileSync(tmp, content, 'utf8');
|
||
if (target.endsWith('.js')) {
|
||
try {
|
||
const { execFileSync } = require('child_process');
|
||
execFileSync(process.execPath, ['--check', tmp], { stdio: 'pipe' });
|
||
} catch (e) {
|
||
try { fs.unlinkSync(tmp); } catch {}
|
||
console.error('[patch-phase1-T1.4] ' + label + ' 语法检查失败:',
|
||
(e.stderr || e.message || '').toString().slice(0, 500));
|
||
process.exit(3);
|
||
}
|
||
}
|
||
fs.renameSync(tmp, target);
|
||
console.log('[patch-phase1-T1.4] 已写入 ' + label + ':', target);
|
||
return 'written';
|
||
}
|
||
|
||
function main() {
|
||
// 确保 skill 目录存在
|
||
if (!fs.existsSync(SKILL_DIR)) {
|
||
fs.mkdirSync(SKILL_DIR, { recursive: true });
|
||
console.log('[patch-phase1-T1.4] 已创建 skill 目录:', SKILL_DIR);
|
||
}
|
||
|
||
const r1 = writeIfDifferent(SCRIPT_TARGET, SCRIPT_CONTENT, 'script');
|
||
const r2 = writeIfDifferent(SKILL_TARGET, SKILL_CONTENT, 'SKILL.md');
|
||
|
||
console.log('');
|
||
console.log('[patch-phase1-T1.4] sentinel:', SENTINEL);
|
||
console.log('[patch-phase1-T1.4] script:', r1);
|
||
console.log('[patch-phase1-T1.4] skill:', r2);
|
||
console.log('[patch-phase1-T1.4] 完成。');
|
||
console.log('');
|
||
console.log('验证:');
|
||
console.log(' node scripts/mcp-prune.js --days 30');
|
||
process.exit(0);
|
||
}
|
||
|
||
main();
|