@skillfm/local 2.0.5 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12,13 +12,29 @@
12
12
  // 不做: JSONL mailbox、stdio MCP bridge、自动升级、多租户、email OTP 路径
13
13
  import { createServer } from 'node:http';
14
14
  import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
15
- import { join } from 'node:path';
15
+ import { join, dirname } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
+ import { fileURLToPath } from 'node:url';
17
18
  import { probeSoulFile, buildConsentPayload, writeSkillfmBlock, removeSkillfmBlock, verifySkillfmBlock, markDeclined, hasDeclined, } from './soul.js';
18
19
  import { guardState } from './guard/state.js';
19
20
  import { agentHint } from './agent-hints.js';
21
+ import { buildElicitationError, buildElicitationId, buildIsErrorResult, buildRejectEnvelope, } from './mcp-output/builder.js';
22
+ import { MCP_OUTPUT_FEATURES, MCP_OUTPUT_VERSION, } from './mcp-output/types.js';
23
+ import { renderNotFoundPage, renderReviewPage, reviewStore, } from './mcp-output/deny-review.js';
20
24
  const PKG_NAME = '@skillfm/local';
21
- const PKG_VERSION = '0.2.4';
25
+ // 动态从 package.json 读 version, 避免硬编码字符串与 package.json 不同步
26
+ // (历史教训 v3.2.x: 硬编码 '0.2.6' 与 package.json "2.0.6" 长期对不上)
27
+ // dist/index.js 相对 package.json 在 ../package.json
28
+ const PKG_VERSION = (() => {
29
+ try {
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf-8'));
32
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
33
+ }
34
+ catch {
35
+ return 'unknown';
36
+ }
37
+ })();
22
38
  // OAuth endpoints live at API root (not under /api/v1), so we keep two base URLs.
23
39
  const DEFAULT_API_ROOT = process.env.SKILLFM_API_ROOT || 'https://api.skillfm.ai';
24
40
  const DEFAULT_API_BASE_URL = process.env.SKILLFM_API_URL || `${DEFAULT_API_ROOT}/api/v1`;
@@ -259,6 +275,17 @@ function json(res, status, body) {
259
275
  });
260
276
  res.end(payload);
261
277
  }
278
+ function html(res, status, body) {
279
+ res.writeHead(status, {
280
+ 'Content-Type': 'text/html; charset=utf-8',
281
+ 'Content-Length': Buffer.byteLength(body),
282
+ });
283
+ res.end(body);
284
+ }
285
+ function buildSidecarBaseUrl() {
286
+ const port = parseInt(process.env.SKILLFM_LOCAL_PORT || '19821', 10);
287
+ return `http://127.0.0.1:${port}`;
288
+ }
262
289
  const routes = {
263
290
  'GET /health': async (_req, res) => {
264
291
  json(res, 200, { ok: true, service: PKG_NAME, version: PKG_VERSION });
@@ -912,12 +939,33 @@ const routes = {
912
939
  },
913
940
  // --------------------------------------------------------------------
914
941
  // GET /internal/guard/check?session_id=<id>&tool=<name>&harness=<name>
915
- // PreToolUse hook 触发;返回 200 放行 / 412 阻断
942
+ // &format=native|mcp-error|mcp-result|mcp-envelope
943
+ // &task_id=<id>&request_id=<id>&redline=<S1..S9>&tier=<A|B|C>
944
+ //
945
+ // PreToolUse hook 触发;返回 200 放行 / 412 阻断(native)
946
+ // 或 200 + MCP-style envelope(mcp-* formats)
947
+ //
948
+ // M9.5 升级:format 参数支持 4 种输出
949
+ // - native (默认,向后兼容):旧 412 + { reason, hint }
950
+ // - mcp-error : URLElicitationRequiredError(-32042) only
951
+ // - mcp-result : { result, isError:true } only
952
+ // - mcp-envelope : 双发({ error, result })
916
953
  // --------------------------------------------------------------------
917
954
  'GET /internal/guard/check': async (req, res) => {
918
955
  const url = new URL(req.url || '/', 'http://x');
919
956
  const session_id = url.searchParams.get('session_id') || '';
920
957
  const tool = url.searchParams.get('tool') || undefined;
958
+ const format = (url.searchParams.get('format') || 'native');
959
+ const task_id = url.searchParams.get('task_id') || `task-${Date.now().toString(36)}`;
960
+ const request_id_raw = url.searchParams.get('request_id');
961
+ const request_id = request_id_raw
962
+ ? /^\d+$/.test(request_id_raw)
963
+ ? parseInt(request_id_raw, 10)
964
+ : request_id_raw
965
+ : null;
966
+ const redline = url.searchParams.get('redline') || undefined;
967
+ const tierRaw = url.searchParams.get('tier');
968
+ const tier = tierRaw === 'A' || tierRaw === 'B' || tierRaw === 'C' ? tierRaw : undefined;
921
969
  if (!session_id) {
922
970
  return json(res, 400, { ok: false, error: 'session_id required' });
923
971
  }
@@ -925,14 +973,76 @@ const routes = {
925
973
  if (decision.allow) {
926
974
  return json(res, 200, { ok: true, allow: true, reason: decision.reason });
927
975
  }
928
- return json(res, 412, {
929
- ok: false,
930
- allow: false,
931
- reason: decision.reason,
976
+ // 拒绝路径
977
+ if (format === 'native') {
978
+ return json(res, 412, {
979
+ ok: false,
980
+ allow: false,
981
+ reason: decision.reason,
982
+ hint: decision.hint,
983
+ });
984
+ }
985
+ const rejectInput = {
986
+ task_id,
987
+ reason: decision.reason || 'guard 校验未通过',
932
988
  hint: decision.hint,
989
+ tool,
990
+ tier,
991
+ redline,
992
+ sidecarBaseUrl: buildSidecarBaseUrl(),
993
+ requestId: request_id,
994
+ };
995
+ // 提前生成 elicitation_id,登记到 reviewStore,让 envelope 与 store index 配对
996
+ const elicitation_id = buildElicitationId(task_id);
997
+ reviewStore.open(rejectInput, elicitation_id);
998
+ if (format === 'mcp-error') {
999
+ return json(res, 200, buildElicitationError(rejectInput, elicitation_id));
1000
+ }
1001
+ if (format === 'mcp-result') {
1002
+ return json(res, 200, buildIsErrorResult(rejectInput));
1003
+ }
1004
+ // mcp-envelope (双发)
1005
+ return json(res, 200, buildRejectEnvelope(rejectInput, elicitation_id));
1006
+ },
1007
+ // --------------------------------------------------------------------
1008
+ // GET /internal/mcp/probe
1009
+ // doctor 用:暴露 sidecar MCP 输出层版本和能力
1010
+ // --------------------------------------------------------------------
1011
+ 'GET /internal/mcp/probe': async (_req, res) => {
1012
+ return json(res, 200, {
1013
+ ok: true,
1014
+ output_version: MCP_OUTPUT_VERSION,
1015
+ features: MCP_OUTPUT_FEATURES,
1016
+ formats: ['native', 'mcp-error', 'mcp-result', 'mcp-envelope'],
933
1017
  });
934
1018
  },
935
1019
  // --------------------------------------------------------------------
1020
+ // GET /internal/mcp/reviews
1021
+ // debug:列出当前 pending / decided 的审阅记录
1022
+ // --------------------------------------------------------------------
1023
+ 'GET /internal/mcp/reviews': async (_req, res) => {
1024
+ return json(res, 200, { ok: true, reviews: reviewStore.list() });
1025
+ },
1026
+ // --------------------------------------------------------------------
1027
+ // GET /internal/mcp/elicitation/poll?elicitationId=<id>
1028
+ // M9.5 L2-4: SkillFM MCP bridge 用来 polling 等审阅完成。
1029
+ // 返回 { status: 'pending' | 'complete' | 'not-found', payload?, decision? }
1030
+ // --------------------------------------------------------------------
1031
+ 'GET /internal/mcp/elicitation/poll': async (req, res) => {
1032
+ const url = new URL(req.url || '/', 'http://x');
1033
+ const eid = url.searchParams.get('elicitationId') || '';
1034
+ if (!eid)
1035
+ return json(res, 400, { ok: false, error: 'elicitationId required' });
1036
+ const rec = reviewStore.getByElicitationId(eid);
1037
+ if (!rec)
1038
+ return json(res, 200, { status: 'not-found' });
1039
+ if (rec.decision === 'pending')
1040
+ return json(res, 200, { status: 'pending' });
1041
+ const { buildElicitationCompletePayload } = await import('./mcp-output/deny-review.js');
1042
+ const payload = buildElicitationCompletePayload(rec);
1043
+ return json(res, 200, { status: 'complete', decision: rec.decision, payload });
1044
+ },
1045
+ // --------------------------------------------------------------------
936
1046
  // POST /internal/guard/post-tool-use
937
1047
  // body: { session_id, tool?, outcome? }
938
1048
  // PostToolUse hook;仅 audit,warn-only,永远 200。
@@ -967,6 +1077,99 @@ async function handleRequest(req, res) {
967
1077
  }
968
1078
  const method = req.method || 'GET';
969
1079
  const url = (req.url || '/').split('?')[0];
1080
+ // ---- SSE: /internal/mcp/elicitation/stream?elicitationId=<id> ----
1081
+ if (url === '/internal/mcp/elicitation/stream' && method === 'GET') {
1082
+ const u = new URL(req.url || '/', 'http://x');
1083
+ const eid = u.searchParams.get('elicitationId') || '';
1084
+ if (!eid) {
1085
+ res.writeHead(400);
1086
+ res.end('elicitationId required');
1087
+ return;
1088
+ }
1089
+ res.writeHead(200, {
1090
+ 'Content-Type': 'text/event-stream',
1091
+ 'Cache-Control': 'no-cache, no-transform',
1092
+ Connection: 'keep-alive',
1093
+ });
1094
+ // 已 decided:立即 push + 关闭
1095
+ const existing = reviewStore.getByElicitationId(eid);
1096
+ if (existing && existing.decision !== 'pending') {
1097
+ const { buildElicitationCompletePayload } = await import('./mcp-output/deny-review.js');
1098
+ const payload = buildElicitationCompletePayload(existing);
1099
+ res.write(`event: elicitation-complete\n`);
1100
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
1101
+ res.end();
1102
+ return;
1103
+ }
1104
+ // 订阅 — 每 15s 发 keepalive 注释,事件来了发完即关
1105
+ const onComplete = (payload) => {
1106
+ try {
1107
+ res.write(`event: elicitation-complete\n`);
1108
+ res.write(`data: ${JSON.stringify(payload.payload)}\n\n`);
1109
+ }
1110
+ catch {
1111
+ // ignore
1112
+ }
1113
+ finally {
1114
+ try {
1115
+ res.end();
1116
+ }
1117
+ catch {
1118
+ // ignore
1119
+ }
1120
+ clearInterval(keepAlive);
1121
+ reviewStore.off(`elicitation:${eid}`, onComplete);
1122
+ }
1123
+ };
1124
+ const keepAlive = setInterval(() => {
1125
+ try {
1126
+ res.write(`: keepalive\n\n`);
1127
+ }
1128
+ catch {
1129
+ clearInterval(keepAlive);
1130
+ reviewStore.off(`elicitation:${eid}`, onComplete);
1131
+ }
1132
+ }, 15_000);
1133
+ reviewStore.on(`elicitation:${eid}`, onComplete);
1134
+ req.on('close', () => {
1135
+ clearInterval(keepAlive);
1136
+ reviewStore.off(`elicitation:${eid}`, onComplete);
1137
+ });
1138
+ return;
1139
+ }
1140
+ // ---- prefix route: /deny-review/:task_id [/approve|/cancel|/modify] ----
1141
+ if (url.startsWith('/deny-review/')) {
1142
+ const tail = url.slice('/deny-review/'.length);
1143
+ const parts = tail.split('/').filter(Boolean);
1144
+ const task_id = decodeURIComponent(parts[0] || '');
1145
+ const action = parts[1] || ''; // '' | 'approve' | 'cancel' | 'modify'
1146
+ if (!task_id) {
1147
+ res.writeHead(400);
1148
+ res.end('task_id required');
1149
+ return;
1150
+ }
1151
+ if (method === 'GET' && !action) {
1152
+ const rec = reviewStore.get(task_id);
1153
+ if (!rec)
1154
+ return html(res, 404, renderNotFoundPage(task_id));
1155
+ return html(res, 200, renderReviewPage(rec));
1156
+ }
1157
+ if (method === 'POST' && (action === 'approve' || action === 'cancel' || action === 'modify')) {
1158
+ const decision = action === 'approve'
1159
+ ? 'approved'
1160
+ : action === 'cancel'
1161
+ ? 'cancelled'
1162
+ : 'modified';
1163
+ const rec = reviewStore.decide(task_id, decision);
1164
+ if (!rec) {
1165
+ return json(res, 404, { ok: false, error: 'task_not_found', task_id });
1166
+ }
1167
+ return json(res, 200, { ok: true, decision: rec.decision, task_id });
1168
+ }
1169
+ res.writeHead(405);
1170
+ res.end('method not allowed');
1171
+ return;
1172
+ }
970
1173
  const key = `${method} ${url}`;
971
1174
  const handler = routes[key];
972
1175
  if (!handler) {
@@ -1168,11 +1371,22 @@ function cmdHelp() {
1168
1371
  console.log(`${PKG_NAME} v${PKG_VERSION}
1169
1372
 
1170
1373
  Usage:
1171
- skillfm-local start Start local sidecar on 127.0.0.1:${DEFAULT_PORT}
1172
- (falls back to a random port if ${DEFAULT_PORT} is taken)
1173
- skillfm-local stop Stop the running sidecar
1174
- skillfm-local status Print current sidecar state
1175
- skillfm-local help Show this help
1374
+ skillfm-local start Start local sidecar on 127.0.0.1:${DEFAULT_PORT}
1375
+ (falls back to a random port if ${DEFAULT_PORT} is taken)
1376
+ skillfm-local stop Stop the running sidecar
1377
+ skillfm-local status Print current sidecar state
1378
+ skillfm-local init [--v3] [--host=<id>]
1379
+ Project-level init: write SKILL.md priming + harness
1380
+ PreToolUse hook (Layer 1 + Layer 3). --v3 = three-layer
1381
+ architecture (recommended); omit for v2 flat (legacy).
1382
+ skillfm-local bootstrap [--host=<id>] [--json]
1383
+ ⭐ One-line full setup: verify sidecar + activation,
1384
+ then run init --v3 in current cwd. Recommended for
1385
+ first-time agents after activation.
1386
+ skillfm-local doctor [--v2] [--disable-hooks] [--json]
1387
+ Three-layer self-check (Layer 1 SKILL.md / Layer 2 MCP
1388
+ reject / Layer 3 hook), or v0.2 layered defense check.
1389
+ skillfm-local help Show this help
1176
1390
 
1177
1391
  Service discovery:
1178
1392
  ${LOCAL_SETTINGS_FILE} JSON with {url, pid, started_at}
@@ -1205,6 +1419,9 @@ const main = async () => {
1205
1419
  case 'init':
1206
1420
  await cmdInit();
1207
1421
  break;
1422
+ case 'bootstrap':
1423
+ await cmdBootstrap();
1424
+ break;
1208
1425
  case 'doctor':
1209
1426
  await cmdDoctor();
1210
1427
  break;
@@ -1225,6 +1442,8 @@ const main = async () => {
1225
1442
  async function cmdInit() {
1226
1443
  const { detectHarness } = await import('./harness/detector.js');
1227
1444
  const { initHarness } = await import('./harness/writers.js');
1445
+ const { detectPrimaryHost, installAll } = await import('./harness/kernels/registry.js');
1446
+ const { writeProjectSkillMd, writeUserScopeSkillMd } = await import('./skill-md/writer.js');
1228
1447
  const args = process.argv.slice(3);
1229
1448
  const flags = {};
1230
1449
  for (let i = 0; i < args.length; i += 1) {
@@ -1247,25 +1466,267 @@ async function cmdInit() {
1247
1466
  const detected = detectHarness({});
1248
1467
  const harness = (typeof flags.harness === 'string' && flags.harness) || detected.harness;
1249
1468
  const lang = (typeof flags.lang === 'string' && flags.lang) || 'auto';
1469
+ const apply = flags['dry-run'] !== true;
1470
+ const v3 = flags['v3'] === true || flags['kernels'] === true;
1471
+ // ---- v3 路径(推荐):3 层抽象 — Layer 1 SKILL.md + Layer 3 kernel registry
1472
+ if (v3) {
1473
+ const host = typeof flags.host === 'string'
1474
+ ? { host: flags.host, source: 'forced', reason: 'user --host flag' }
1475
+ : detectPrimaryHost();
1476
+ // Layer 1: SKILL.md
1477
+ const skillMd = writeProjectSkillMd({ cwd: process.cwd(), lang });
1478
+ let userSkillMd = null;
1479
+ if (host.host === 'claude-code' && flags['copy-to-user'] === true) {
1480
+ userSkillMd = writeUserScopeSkillMd(lang);
1481
+ }
1482
+ // Layer 3: kernel adapters
1483
+ const layer3 = await installAll({
1484
+ host,
1485
+ cwd: process.cwd(),
1486
+ apply,
1487
+ forceKernel: typeof flags.kernel === 'string' ? flags.kernel : undefined,
1488
+ alsoInstallMcpHosts: typeof flags['also-mcp-hosts'] === 'string'
1489
+ ? flags['also-mcp-hosts'].split(',').map((s) => s.trim()).filter(Boolean)
1490
+ : undefined,
1491
+ });
1492
+ if (flags['json'] === true) {
1493
+ console.log(JSON.stringify({
1494
+ ok: true,
1495
+ architecture: 'v3-three-layer',
1496
+ detect: detected,
1497
+ host,
1498
+ layer1_skill_md: { project: skillMd, user: userSkillMd },
1499
+ layer3_kernels: layer3,
1500
+ }, null, 2));
1501
+ return;
1502
+ }
1503
+ // 人类可读输出
1504
+ const lines = [];
1505
+ lines.push(`✓ 检测 host: ${host.host}${host.source !== 'detected' ? ` (${host.source})` : ''}`);
1506
+ if (host.reason)
1507
+ lines.push(` reason: ${host.reason}`);
1508
+ lines.push('');
1509
+ // Layer 1
1510
+ const skillBadge = skillMd.action === 'created'
1511
+ ? '已创建'
1512
+ : skillMd.action === 'replaced'
1513
+ ? '已升级'
1514
+ : skillMd.action === 'appended'
1515
+ ? '已追加'
1516
+ : '已就绪';
1517
+ lines.push(`✓ Layer 1: SKILL.md (${skillBadge}) — ${skillMd.path}`);
1518
+ if (userSkillMd) {
1519
+ lines.push(`✓ Layer 1: 用户级 SKILL.md (${userSkillMd.action}) — ${userSkillMd.path}`);
1520
+ }
1521
+ lines.push('');
1522
+ // Layer 2 — 提示用户 sidecar MCP reject 是否在跑(可选探测)
1523
+ lines.push(`ℹ Layer 2: MCP Protocol-Reject 由 sidecar 输出层提供`);
1524
+ lines.push(` → 检查 \`skillfm doctor\` Layer 2 是否 ✅ (output_version=v2)`);
1525
+ lines.push('');
1526
+ // Layer 3
1527
+ if (layer3.matched.length === 0) {
1528
+ lines.push(`⚠ Layer 3: 当前 host=${host.host} 未匹配任何 kernel`);
1529
+ lines.push(` → 仅 Layer 1+2 生效;如需 Layer 3 强制,加 --kernel=<hook-file|deny-pipeline|mcp-only>`);
1530
+ }
1531
+ else {
1532
+ for (const result of layer3.results) {
1533
+ const fileSummary = result.files
1534
+ .map((f) => `${f.action} ${f.path}`)
1535
+ .join(', ');
1536
+ lines.push(`✓ Layer 3 [${result.kernel}] host=${result.host}: ${fileSummary || '(无文件改动)'}`);
1537
+ for (const w of result.warnings)
1538
+ lines.push(` ⚠ ${w}`);
1539
+ }
1540
+ }
1541
+ lines.push('');
1542
+ // 顶层 warnings
1543
+ if (layer3.warnings.length > 0) {
1544
+ lines.push('⚠ 整体提示:');
1545
+ for (const w of layer3.warnings)
1546
+ lines.push(` - ${w}`);
1547
+ lines.push('');
1548
+ }
1549
+ // mcp-only 主动联动提示
1550
+ if (layer3.matched.includes('mcp-only')) {
1551
+ lines.push('🔗 mcp-only 内核强制力依赖 Layer 2:');
1552
+ lines.push(' - 确保 sidecar 在跑(skillfm start)');
1553
+ lines.push(' - 确保 sidecar 输出层 ≥ v2(URLElicitationRequiredError)');
1554
+ lines.push(' - 跑 `skillfm doctor` 验证三层全部就绪');
1555
+ lines.push('');
1556
+ }
1557
+ lines.push(`下一步:`);
1558
+ lines.push(` - skillfm start # 启 sidecar (如未启)`);
1559
+ lines.push(` - skillfm doctor # 三层架构自检`);
1560
+ lines.push(` - 在 ${host.host} 内开新 session 验证 priming + hook 生效`);
1561
+ console.log(lines.join('\n'));
1562
+ return;
1563
+ }
1564
+ // ---- v2 路径(兼容):单 harness × 单模板
1250
1565
  const report = initHarness({
1251
1566
  harness,
1252
1567
  cwd: process.cwd(),
1253
1568
  lang,
1254
1569
  llmOnly: flags['llm-only'] === true,
1255
1570
  });
1256
- console.log(JSON.stringify({ ok: true, detect: detected, report }, null, 2));
1571
+ console.log(JSON.stringify({ ok: true, architecture: 'v2-flat', detect: detected, report }, null, 2));
1572
+ }
1573
+ // ============================================================================
1574
+ // cmdBootstrap — 一行命令完整安装:start + activate (auto) + init --v3
1575
+ // ============================================================================
1576
+ /**
1577
+ * 解决"激活后只写 2 个文件"的 UX 缺陷。
1578
+ *
1579
+ * 流程:
1580
+ * 1. 检测 sidecar 是否在跑;不在跑则启
1581
+ * 2. 检测是否已激活(~/.skillfm/config.json brain_key);未激活则提示用户走 OAuth
1582
+ * (bootstrap 不能自动完成 OAuth — 用户必须在浏览器手动授权)
1583
+ * 3. 已激活后立即触发 init --v3 在 cwd 写 SKILL.md + harness hook + MCP 注册
1584
+ *
1585
+ * 与 cmdStart 区别:start 只起 sidecar;bootstrap 是 start + 强引导激活 + 项目级 init。
1586
+ *
1587
+ * 使用:
1588
+ * npx -y -p @skillfm/local skillfm-local bootstrap
1589
+ * npx -y -p @skillfm/local skillfm-local bootstrap --host=cursor
1590
+ * npx -y -p @skillfm/local skillfm-local bootstrap --json
1591
+ */
1592
+ async function cmdBootstrap() {
1593
+ const args = process.argv.slice(3);
1594
+ const flags = {};
1595
+ for (let i = 0; i < args.length; i += 1) {
1596
+ const t = args[i];
1597
+ if (t.startsWith('--')) {
1598
+ const eq = t.indexOf('=');
1599
+ if (eq > 0)
1600
+ flags[t.slice(2, eq)] = t.slice(eq + 1);
1601
+ else {
1602
+ const nxt = args[i + 1];
1603
+ if (nxt !== undefined && !nxt.startsWith('--')) {
1604
+ flags[t.slice(2)] = nxt;
1605
+ i += 1;
1606
+ }
1607
+ else
1608
+ flags[t.slice(2)] = true;
1609
+ }
1610
+ }
1611
+ }
1612
+ const asJson = flags['json'] === true;
1613
+ const lines = [];
1614
+ const log = (msg) => {
1615
+ if (!asJson)
1616
+ console.log(msg);
1617
+ lines.push(msg);
1618
+ };
1619
+ // 1. sidecar 状态
1620
+ const existing = readLocalSettings();
1621
+ const sidecarRunning = existing && isPidAlive(existing.pid);
1622
+ if (sidecarRunning) {
1623
+ log(`✓ sidecar 已在 ${existing.url} 运行 (PID ${existing.pid})`);
1624
+ }
1625
+ else {
1626
+ log(`⚠ sidecar 未在跑,请先启动:npx -y -p @skillfm/local skillfm-local start`);
1627
+ log(` bootstrap 不会自动启 sidecar(避免子进程孤儿化);请单独跑 start 后再 bootstrap。`);
1628
+ if (asJson) {
1629
+ console.log(JSON.stringify({ ok: false, reason: 'sidecar_not_running', steps: lines }, null, 2));
1630
+ }
1631
+ process.exit(1);
1632
+ }
1633
+ // 2. 激活状态
1634
+ const cfg = readConfig();
1635
+ const activated = Boolean(cfg.agentToken);
1636
+ if (!activated) {
1637
+ log(`⚠ 尚未激活 — brain_key 未保存。`);
1638
+ log(` 请走 OAuth 流程:`);
1639
+ log(` curl -sS -X POST ${existing.url}/activate/start`);
1640
+ log(` 用户在浏览器完成授权后,每 5s 轮询:`);
1641
+ log(` curl -sS -X POST ${existing.url}/activate/poll`);
1642
+ log(` 激活成功后再次跑 bootstrap 完成项目级 init。`);
1643
+ if (asJson) {
1644
+ console.log(JSON.stringify({ ok: false, reason: 'not_activated', steps: lines }, null, 2));
1645
+ }
1646
+ process.exit(1);
1647
+ }
1648
+ log(`✓ 已激活 (brain_key 长度 ${cfg.agentToken.length} chars)`);
1649
+ // 3. 项目级 init --v3
1650
+ log(``);
1651
+ log(`正在执行 init --v3 完成项目级三层防御注入...`);
1652
+ // 直接复用 cmdInit 内 v3 路径的实现(detectPrimaryHost + writeProjectSkillMd + installAll)
1653
+ const { detectPrimaryHost, installAll } = await import('./harness/kernels/registry.js');
1654
+ const { writeProjectSkillMd } = await import('./skill-md/writer.js');
1655
+ const host = typeof flags.host === 'string'
1656
+ ? { host: flags.host, source: 'forced', reason: 'bootstrap --host flag' }
1657
+ : detectPrimaryHost();
1658
+ const lang = (typeof flags.lang === 'string' && flags.lang) || 'auto';
1659
+ const skillMd = writeProjectSkillMd({ cwd: process.cwd(), lang });
1660
+ log(`✓ Layer 1 SKILL.md (${skillMd.action}) — ${skillMd.path}`);
1661
+ const layer3 = await installAll({
1662
+ host,
1663
+ cwd: process.cwd(),
1664
+ apply: true,
1665
+ forceKernel: typeof flags.kernel === 'string' ? flags.kernel : undefined,
1666
+ });
1667
+ if (layer3.matched.length === 0) {
1668
+ log(`⚠ Layer 3: host=${host.host} 未匹配任何 kernel;仅 Layer 1+2 生效`);
1669
+ }
1670
+ else {
1671
+ for (const result of layer3.results) {
1672
+ const fileSummary = result.files.map((f) => `${f.action} ${f.path}`).join(', ');
1673
+ log(`✓ Layer 3 [${result.kernel}] host=${result.host}: ${fileSummary || '(无文件改动)'}`);
1674
+ }
1675
+ }
1676
+ log(``);
1677
+ log(`✅ Bootstrap 完成。三层防御已就绪:`);
1678
+ log(` Layer 1 SKILL.md priming → ./SKILL.md`);
1679
+ log(` Layer 2 MCP Protocol-Reject → sidecar 输出层 v2`);
1680
+ log(` Layer 3 ${layer3.matched.join('+') || '(unknown host, layer 1+2 only)'}`);
1681
+ log(``);
1682
+ log(`下一步:跑 \`skillfm doctor\` 自检三层全部就绪`);
1683
+ if (asJson) {
1684
+ console.log(JSON.stringify({
1685
+ ok: true,
1686
+ architecture: 'v3-three-layer',
1687
+ sidecar: { url: existing.url, pid: existing.pid },
1688
+ activated: true,
1689
+ host,
1690
+ layer1_skill_md: skillMd,
1691
+ layer3_kernels: layer3,
1692
+ steps: lines,
1693
+ }, null, 2));
1694
+ }
1257
1695
  }
1258
1696
  async function cmdDoctor() {
1259
- const { runDoctor, renderDoctorReport } = await import('./doctor.js');
1260
- const report = await runDoctor(process.cwd());
1261
- if (process.argv.includes('--json')) {
1262
- console.log(JSON.stringify(report, null, 2));
1697
+ const flags = process.argv.slice(3);
1698
+ const useV2 = flags.includes('--v2');
1699
+ const asJson = flags.includes('--json');
1700
+ const disableHooksMode = flags.includes('--disable-hooks');
1701
+ if (useV2) {
1702
+ const { runDoctor, renderDoctorReport } = await import('./doctor.js');
1703
+ const report = await runDoctor(process.cwd());
1704
+ if (asJson)
1705
+ console.log(JSON.stringify(report, null, 2));
1706
+ else
1707
+ console.log(renderDoctorReport(report));
1708
+ const hasFail = report.items.some((i) => i.status === 'fail');
1709
+ process.exit(hasFail ? 1 : 0);
1710
+ }
1711
+ // v3 默认(三层抽象架构)
1712
+ const { runDoctorV3, renderDoctorV3Report, renderDisableHooksReport } = await import('./doctor.js');
1713
+ const report = await runDoctorV3(process.cwd());
1714
+ if (asJson) {
1715
+ console.log(JSON.stringify(disableHooksMode ? { ...report, mode: 'disable-hooks-preview' } : report, null, 2));
1716
+ }
1717
+ else if (disableHooksMode) {
1718
+ console.log(renderDisableHooksReport(report));
1263
1719
  }
1264
1720
  else {
1265
- console.log(renderDoctorReport(report));
1721
+ console.log(renderDoctorV3Report(report));
1722
+ }
1723
+ // 退出码:任意 fail 项 → 1(disable-hooks 模式只是预览,永远 0)
1724
+ if (disableHooksMode) {
1725
+ process.exit(0);
1266
1726
  }
1267
- const hasFail = report.items.some((i) => i.status === 'fail');
1268
- process.exit(hasFail ? 1 : 0);
1727
+ const layer1HasFail = report.layer1.items.some((i) => i.status === 'fail');
1728
+ const layer2Fail = report.layer2.diagnose.level === 'absent';
1729
+ process.exit(layer1HasFail || layer2Fail ? 1 : 0);
1269
1730
  }
1270
1731
  main().catch((e) => {
1271
1732
  console.error(JSON.stringify({ ok: false, error: e?.message || String(e) }));