tmuxclihook 0.4.0 → 0.5.0

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/README.md CHANGED
@@ -12,6 +12,10 @@ tmuxclihook
12
12
  # 检查 npm registry 并将全局安装更新到最新版
13
13
  tmuxclihook update
14
14
 
15
+ # hooks 健康明细(非交互):每个 CLI 的完整性、判定真源、注册路径、漂移警告
16
+ # 可选参数 claude|codex|opencode 只看一个;exit 1 = 有缺失或漂移
17
+ tmuxclihook check
18
+
15
19
  # 查看版本 / 帮助(version、--version、-v 与 help、--help、-h 等价)
16
20
  tmuxclihook --version
17
21
  tmuxclihook --help
@@ -39,7 +43,7 @@ hooks 安装、tmux 集成写入的路径由「source」决定:环境变量 `T
39
43
  | 菜单项 | 说明 |
40
44
  |--------|------|
41
45
  | 环境检查 | 侦测 tmux(≥3.1) / jq / bash(任意版本) / node(≥18),显示 `✓ ready`、`✗ missing`、`⚠ outdated`,并在确认后安全修复。检测到 cc-switch(`~/.cc-switch/cc-switch.db`)时追加 sqlite3 项 |
42
- | 侦测 AI CLI | 扫描 Claude Code / Codex / opencode,显示版本、最低版本和 hooks 状态;不自动安装或升级 AI CLI。cc-switch 环境下 Claude Code 的 hooks 状态由 bash 侧 `install-claude-hooks.sh check`(sqlite 真源)判定 |
46
+ | 侦测 AI CLI | 扫描 Claude Code / Codex / opencode,显示版本、最低版本和 hooks 健康明细(判定真源、缺失原因、注册路径、漂移警告,见下节);不自动安装或升级 AI CLI。判定全部走 bash 侧 wrapper `check --json`(cc-switch 模式在 bash 内查 sqlite 真源),TUI 只渲染 |
43
47
  | 安装 hooks | 选工具(claude/codex/opencode/全部)→ 调 `scripts/install-<tool>-hooks.sh`(路径随当前 source 解析) |
44
48
  | 卸载 hooks | 对称卸载本插件 hooks,保留其他工具注册的 hook |
45
49
  | 修复 hooks | 完整性检查 → 缺失则重装 |
@@ -47,6 +51,27 @@ hooks 安装、tmux 集成写入的路径由「source」决定:环境变量 `T
47
51
  | source 管理 | 查看当前生效 source(来源 + 失效警告);设置本地仓库路径用于开发调试;清除持久化配置恢复默认 |
48
52
  | 完整卸载插件 | 停止 Codex monitor、清除聚合状态 `@ai_all_status`、清理历史遗留软链(不删仓库);`.tmux.conf` 删声明与 `tmux kill-server` 因破坏性仅打印手动指引 |
49
53
 
54
+ ### hooks 健康明细与漂移收敛
55
+
56
+ `tmuxclihook check`(子命令)与 TUI「侦测 AI CLI」共用同一份 bash 侧明细:每个 wrapper 的 `check --json` 模式输出单行 JSON(status / 判定真源 mode / 逐事件 registered+alive / opencode plugin 结构指纹 / suppress 标记 / drift)。默认 `check`(无 `--json`)输出逐字节不变(`ok`/`missing` + exit code),TUI 与 tmux 自修复的既有契约不受影响。
57
+
58
+ **漂移(drift)**:hooks 指向其他安装源但脚本仍存活(典型场景:本地调试切 source 后、volta 换路径后旧包仍可用)。此时功能未坏、check 仍判 `ok`,但 `tmuxclihook check` 与 TUI 会给出 ⚠ 警告并列出旧路径。漂移参照系是**执行中的 wrapper 自身路径**——`tmuxclihook`/TUI 经当前 source 调 wrapper,警告即「已装 hooks 不是当前 source 写的」。
59
+
60
+ **发现漂移后的收敛(策略不对称)**:
61
+
62
+ | 配置 | 收敛代价 | 方式 |
63
+ |------|---------|------|
64
+ | Claude Code hooks | 无(install 幂等强收敛) | TUI 选择即收敛 |
65
+ | Codex hooks | 重写 `hooks.json` 使 `trusted_hash` 失效,需重新 Trust all | TUI 显式确认后收敛(`TMUX_AI_FORCE_INSTALL=1` 旁路 no-op 门禁) |
66
+ | opencode plugin | 无信任代价(整文件重写) | TUI 确认后收敛 |
67
+ | `.tmux.conf` 声明 | 无 | 复用「tmux 集成」的原地替换 + 重载 |
68
+
69
+ 非交互场景下 `tmuxclihook check` 只报告并 exit 1,收敛动作需回 TUI(或手动跑对应 `install-<tool>-hooks.sh`;codex 记得带 `TMUX_AI_FORCE_INSTALL=1`)。
70
+
71
+ ### Esc 导航
72
+
73
+ clack 0.7 只把 ctrl+c 映射为取消(Esc 默认无效果),安装器在 TUI 内把 Esc 转译为合成取消:**主菜单 Esc 退出**、子菜单 Esc 返回主菜单、确认/输入框 Esc 取消。spinner 期间 Esc 静默(避免误触硬退出);ctrl+c 行为不变。
74
+
50
75
  ### cc-switch 模式(Claude Code)
51
76
 
52
77
  检测到 `~/.cc-switch/cc-switch.db` 时,Claude Code hooks 的装/卸/修/状态检查全部指向其通用配置(db `settings` 表 `common_config_claude`)——`switch` 命令启动 claude 时会把通用配置合并进实例配置,`~/.claude/settings.json` 与实例文件都会被 cc-switch 覆盖,直接写它们不持久。写入用与 cc-switch 自身一致的 `json_patch` 原子更新,他人 hook 与配置其他字段保留。注意:cc-switch GUI 运行中会把内存缓存写回 db,安装前建议先退出 GUI(脚本会警告);不经 `switch` 直接运行的 `claude` 读不到 db 里的 hooks。
package/bin/cli.js CHANGED
@@ -14,10 +14,12 @@ import { checkEnv, detectClis, interactiveShell } from '../src/detect.js';
14
14
  import { planBrewRemediation, runBrewAction } from '../src/env.js';
15
15
  import { installHooks, uninstallHooks, repairHooks } from '../src/hooks.js';
16
16
  import { checkSymlink, checkTmuxConf } from '../src/symlink.js';
17
+ import { checkToolDetail, renderToolDetail, summarizeDetails } from '../src/check-detail.js';
17
18
  import { ensureTmuxIntegration, reloadTmux } from '../src/tmuxconf.js';
18
19
  import { stopMonitor, clearAggregateStatus, removeSymlinks } from '../src/purge.js';
19
20
  import {
20
21
  TOOLS,
22
+ toolById,
21
23
  repoExists,
22
24
  REPO_ROOT,
23
25
  TMUX_ENTRY,
@@ -26,12 +28,24 @@ import {
26
28
  refreshSource,
27
29
  } from '../src/adapters-meta.js';
28
30
  import { setSource, clearSource, getPersistedSource, originLabel } from '../src/source.js';
31
+ import { enableEscapeCancel, setSpinnerActive } from '../src/esc.js';
29
32
  import { note, padDisplay } from '../src/tui.js';
30
33
  import { updatePackage, CURRENT_VERSION } from '../src/update.js';
31
34
 
32
35
  const OK = '✓';
33
36
  const NO = '✗';
34
37
 
38
+ // p.spinner 的包装:spinner 活跃期间 esc.js 不把 Esc 转译为 ctrl+c
39
+ // (否则 Esc 会在 spinner 期间触发 block() 的 process.exit 硬退出)。
40
+ function spinner(...args) {
41
+ const s = p.spinner(...args);
42
+ const start = s.start.bind(s);
43
+ const stop = s.stop.bind(s);
44
+ s.start = (...a) => { setSpinnerActive(true); return start(...a); };
45
+ s.stop = (...a) => { setSpinnerActive(false); return stop(...a); };
46
+ return s;
47
+ }
48
+
35
49
  function renderEnvResults(results, title = '环境依赖') {
36
50
  const lines = results.map((result) => {
37
51
  const mark = result.status === 'ready' ? OK : result.status === 'outdated' ? '⚠' : NO;
@@ -41,7 +55,7 @@ function renderEnvResults(results, title = '环境依赖') {
41
55
  }
42
56
 
43
57
  async function doctor() {
44
- const s = p.spinner();
58
+ const s = spinner();
45
59
  s.start('检查环境依赖');
46
60
  const results = await checkEnv();
47
61
  s.stop('环境检查完成');
@@ -101,14 +115,14 @@ async function doctor() {
101
115
  const entry = entryByKey.get(key);
102
116
  if (!entry) continue;
103
117
 
104
- const s2 = p.spinner();
118
+ const s2 = spinner();
105
119
  s2.start(entry.command);
106
120
  const result = await runBrewAction(entry.action, entry.pkg);
107
121
  if (result.ok) s2.stop(`${OK} ${entry.name} 修复命令执行完成`);
108
122
  else s2.stop(`${NO} ${entry.name} 修复失败: ${result.output}`);
109
123
  }
110
124
 
111
- const verifySpinner = p.spinner();
125
+ const verifySpinner = spinner();
112
126
  verifySpinner.start('重新检查环境依赖');
113
127
  const finalResults = await checkEnv();
114
128
  verifySpinner.stop('环境复检完成');
@@ -122,7 +136,7 @@ async function doctor() {
122
136
  }
123
137
 
124
138
  async function detectCliMenu() {
125
- const s = p.spinner();
139
+ const s = spinner();
126
140
  s.start('侦测已安装的 AI CLI');
127
141
  const rows = await detectClis();
128
142
  s.stop('侦测完成');
@@ -131,10 +145,88 @@ async function detectCliMenu() {
131
145
  if (!r.installed) return ` ${NO} ${padDisplay(r.label, 14)} 未安装`;
132
146
  const verMark = r.meetsMin ? OK : '⚠';
133
147
  const minNote = r.minVersion && !r.meetsMin ? ` (需 ≥ ${r.minVersion})` : '';
134
- const hookNote = r.hooksInstalled ? '已装 hooks' : '未装 hooks';
135
- return ` ${verMark} ${padDisplay(r.label, 14)} v${r.version}${minNote} ${hookNote}`;
148
+ return ` ${verMark} ${padDisplay(r.label, 14)} v${r.version}${minNote}`;
136
149
  });
137
150
  note(lines.join('\n'), 'AI CLI');
151
+
152
+ // 逐工具 hooks 明细(判定在 bash 侧 check --json,这里只渲染——缺失原因、
153
+ // 判定真源、注册路径、漂移)。未安装的 CLI 跳过(wrapper 判 missing 无意义)。
154
+ const details = [];
155
+ for (const r of rows) {
156
+ if (!r.installed) continue;
157
+ const tool = toolById(r.id);
158
+ if (!tool) continue;
159
+ const d = await checkToolDetail(tool);
160
+ details.push(d);
161
+ note(renderToolDetail(d).join('\n'), `${r.label} hooks 明细`);
162
+ }
163
+
164
+ await offerDriftConvergence(details);
165
+ }
166
+
167
+ // 漂移收敛入口:检测(check --json 的 drift 字段 + .tmux.conf 的 pointsToCurrent)
168
+ // 与收敛分离。收敛策略不对称——claude 无代价直接收敛;codex 重写即 trusted_hash
169
+ // 失效,必须显式确认;opencode 重写无信任代价,确认一次即可。
170
+ async function offerDriftConvergence(details) {
171
+ const driftTools = details.filter((d) => d.status !== 'unknown' && d.drift?.drifted);
172
+ const conf = checkTmuxConf();
173
+ const confStale = conf.exists && conf.referenced && !conf.pointsToCurrent;
174
+
175
+ const options = driftTools.map((d) => ({
176
+ value: `tool:${d.tool}`,
177
+ label: `${d.label} hooks 收敛到当前 source`,
178
+ hint: `旧路径 ${(d.drift.stale || []).join(', ')}`,
179
+ }));
180
+ if (confStale) {
181
+ options.push({ value: 'tmuxconf', label: '.tmux.conf 加载声明更新为当前 source' });
182
+ }
183
+ if (options.length === 0) return;
184
+
185
+ options.push({ value: '__skip__', label: '← 暂不处理' });
186
+ p.log.warn('检测到部分配置指向其他安装源(漂移)。功能仍可用,但新安装/自修复只会收敛当前 source。');
187
+ const sel = await p.select({
188
+ message: '选择要收敛的项(Esc 跳过)',
189
+ options,
190
+ });
191
+ if (p.isCancel(sel) || sel === '__skip__') return;
192
+
193
+ if (sel === 'tmuxconf') {
194
+ await confirmAndApplyTmuxIntegration(conf);
195
+ return;
196
+ }
197
+
198
+ const id = sel.slice('tool:'.length);
199
+ await convergeDrift(id);
200
+ // 收敛后复检闭环
201
+ const tool = toolById(id);
202
+ if (tool) {
203
+ const d = await checkToolDetail(tool);
204
+ note(renderToolDetail(d).join('\n'), `${d.label} 收敛后状态`);
205
+ }
206
+ }
207
+
208
+ async function convergeDrift(id) {
209
+ if (id === 'codex') {
210
+ const go = await p.confirm({
211
+ message: '重写 ~/.codex/hooks.json 会使 trusted_hash 失效,下次启动 codex 需在 TUI 点 "Trust all and continue" 授信一次。继续收敛?',
212
+ });
213
+ if (p.isCancel(go) || !go) { p.log.info('已取消'); return; }
214
+ } else if (id === 'opencode') {
215
+ const go = await p.confirm({
216
+ message: '将重写 plugin 文件指向当前 source(本地插件无需重新授信)。继续收敛?',
217
+ });
218
+ if (p.isCancel(go) || !go) { p.log.info('已取消'); return; }
219
+ }
220
+ // claude 无代价(install 本就幂等强收敛),选择即确认
221
+ const r = await installHooks(id, { force: true });
222
+ if (r.ok) {
223
+ p.log.success(`${OK} ${id} hooks 已收敛到当前 source`);
224
+ if (id === 'codex') p.log.warn('下次启动 codex 时选择 "Trust all and continue" 授信一次。');
225
+ if (r.output) p.log.message(r.output.trim());
226
+ } else {
227
+ p.log.error(`${NO} ${id} 收敛失败`);
228
+ p.log.error(r.output || '未知错误');
229
+ }
138
230
  }
139
231
 
140
232
  async function pickTool(message) {
@@ -155,7 +247,7 @@ async function runHookAction(action, verb) {
155
247
  if (!sel) { p.log.info('返回主菜单'); return; }
156
248
  const ids = sel === '__all__' ? TOOLS.map((t) => t.id) : [sel];
157
249
  for (const id of ids) {
158
- const s = p.spinner();
250
+ const s = spinner();
159
251
  s.start(`${verb} ${id} hooks`);
160
252
  const r = await action(id);
161
253
  if (r.ok) {
@@ -317,6 +409,9 @@ async function purgeMenu() {
317
409
 
318
410
  async function main() {
319
411
  p.intro('tmux-ai-status 安装器');
412
+ // clack 0.7 只认 ctrl+c 取消;启用 Esc → 合成 ctrl+c 的转译后,
413
+ // 主菜单 Esc 退出、子菜单 Esc 返回、confirm/text Esc 取消全部真实生效。
414
+ enableEscapeCancel();
320
415
 
321
416
  note(currentSourceLines().join('\n'), 'source 状态');
322
417
 
@@ -328,10 +423,10 @@ async function main() {
328
423
 
329
424
  while (true) {
330
425
  const choice = await p.select({
331
- message: '选择操作(Esc 在子菜单返回主页面)',
426
+ message: '选择操作(Esc 退出;子菜单 Esc 返回本菜单)',
332
427
  options: [
333
428
  { value: 'doctor', label: '环境检查', hint: 'tmux / jq / bash / node' },
334
- { value: 'detect', label: '侦测 AI CLI', hint: '版本 + hooks 状态' },
429
+ { value: 'detect', label: '侦测 AI CLI', hint: '版本 + hooks 明细 + 漂移' },
335
430
  { value: 'install', label: '安装 hooks' },
336
431
  { value: 'uninstall', label: '卸载 hooks' },
337
432
  { value: 'repair', label: '修复 hooks', hint: '完整性检查 + 缺失重装' },
@@ -341,11 +436,9 @@ async function main() {
341
436
  { value: 'exit', label: '退出' },
342
437
  ],
343
438
  });
344
- // 主菜单是顶层:Esc(isCancel)不退出程序,留在主菜单;退出仅走「退出」项。
345
- if (p.isCancel(choice)) {
346
- p.log.info('已在主菜单(退出请选「退出」项)');
347
- continue;
348
- }
439
+ // 主菜单是顶层:Esc 经 esc.js 转译为合成 ctrl+c → isCancel,与「退出」项等价
440
+ // (outro 在循环后统一输出)。子菜单 isCancel → 返回主菜单的既有语义同时被激活。
441
+ if (p.isCancel(choice)) break;
349
442
  if (choice === 'exit') break;
350
443
 
351
444
  try {
@@ -365,6 +458,37 @@ async function main() {
365
458
  p.outro('完成');
366
459
  }
367
460
 
461
+ // tmuxclihook check [claude|codex|opencode] — hooks 健康明细(非交互)。
462
+ // 判定全在 bash 侧 check --json,这里渲染;末尾附 bash 判不了的两项全局信息
463
+ // (.tmux.conf 加载声明指向、当前 source)。有缺失/漂移/无法判定 → exitCode 1。
464
+ async function runCheckCommand(only) {
465
+ let tools = TOOLS;
466
+ if (only) {
467
+ const t = toolById(only);
468
+ if (!t) {
469
+ console.error(`未知工具: ${only}(可选 claude|codex|opencode)`);
470
+ process.exitCode = 1;
471
+ return;
472
+ }
473
+ tools = [t];
474
+ }
475
+ const details = [];
476
+ for (const t of tools) {
477
+ const d = await checkToolDetail(t);
478
+ details.push(d);
479
+ console.log(`${d.label} hooks:`);
480
+ console.log(renderToolDetail(d).join('\n'));
481
+ }
482
+ const conf = checkTmuxConf();
483
+ console.log('.tmux.conf:');
484
+ console.log(` ${confStatusLine(conf)}`);
485
+ console.log('source:');
486
+ for (const line of currentSourceLines()) console.log(line);
487
+
488
+ const s = summarizeDetails(details);
489
+ if (s.hasMissing || s.hasDrift || s.hasError) process.exitCode = 1;
490
+ }
491
+
368
492
  const arg = process.argv[2];
369
493
  if (arg === 'update') {
370
494
  updatePackage().then((ok) => {
@@ -373,6 +497,11 @@ if (arg === 'update') {
373
497
  console.error(err);
374
498
  process.exitCode = 1;
375
499
  });
500
+ } else if (arg === 'check') {
501
+ runCheckCommand(process.argv[3]).catch((err) => {
502
+ console.error(err);
503
+ process.exitCode = 1;
504
+ });
376
505
  } else if (arg === '--version' || arg === '-v' || arg === 'version') {
377
506
  console.log(`tmuxclihook v${CURRENT_VERSION}`);
378
507
  } else if (arg === '--help' || arg === '-h' || arg === 'help') {
@@ -381,6 +510,7 @@ if (arg === 'update') {
381
510
  '\n' +
382
511
  '用法:\n' +
383
512
  ' tmuxclihook 打开交互式 TUI 安装器\n' +
513
+ ' tmuxclihook check hooks 健康明细(可跟 claude|codex|opencode;exit 1 = 有缺失或漂移)\n' +
384
514
  ' tmuxclihook update 检查并更新到 npm 最新版本\n' +
385
515
  ' tmuxclihook version 显示版本(--version / -v 等价)\n' +
386
516
  ' tmuxclihook help 显示本帮助(--help / -h 等价)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmuxclihook",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "交互式 TUI 安装器:环境侦测、AI CLI 侦测、tmux-ai-status hooks 装/卸/修、TPM 软链校验",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,8 @@
13
13
  # ADAPTER_HOLD_UNMATCHED_PERMISSION "true"|"false" 无请求 ID 时是否保守保持审批态
14
14
  # ADAPTER_INSTALLER install 脚本绝对路径(供自修复/TUI 调用)
15
15
  # adapter_check_integrity() hooks 是否完整注册本插件(返回 0=完整)
16
+ # adapter_check_detail() check --json 明细 JSON 片段(可选实现;仅 install wrapper
17
+ # 的 check --json 上下文调用,失败输出降级片段不报错)
16
18
  # adapter_install_hooks() 安装 hooks(合并式,保留他人 hook)
17
19
  # adapter_uninstall_hooks() 卸载本插件 hooks
18
20
  #
@@ -220,6 +222,63 @@ adapter_check_integrity() {
220
222
  return 0
221
223
  }
222
224
 
225
+ # 明细检查(仅 install wrapper 的 check --json 调用;事件热路径不触达)。
226
+ # stdout 输出单行紧凑 JSON 片段 {"mode":...,"events":{...},"plugin":null,"registeredPaths":[...]},
227
+ # 由 lib-install-hooks.sh 的 _wrapper_check_json 拼装 tool/status/suppress/drift。
228
+ # 数据源与存活口径(逐条 command test -x)与 adapter_check_integrity 完全一致;
229
+ # status 判定仍由 integrity 本体独立给出,两者永不发散。
230
+ # 任何失败(jq 失败/配置缺失)输出空 events 的降级片段,不 exit 非 0。
231
+ adapter_check_detail() {
232
+ local mode="file" src="" pairs rows line ev p a
233
+ if _ccswitch_active; then
234
+ mode="ccswitch"
235
+ if ! _ccswitch_dump_common src; then
236
+ printf '{"mode":"ccswitch","events":{},"plugin":null,"registeredPaths":[]}\n'
237
+ return 0
238
+ fi
239
+ elif [ -f "$ADAPTER_HOOKS_FILE" ]; then
240
+ src="$ADAPTER_HOOKS_FILE"
241
+ fi
242
+ if [ -z "$src" ]; then
243
+ printf '{"mode":"%s","events":{},"plugin":null,"registeredPaths":[]}\n' "$mode"
244
+ return 0
245
+ fi
246
+ # TSV:event\tscript(不去重、保留零事件——计数与去重交给下游 jq 聚合)
247
+ pairs=$(jq -r '.hooks as $h |
248
+ ["SessionStart","SessionEnd","UserPromptSubmit","PreToolUse","PostToolUse",
249
+ "PostToolUseFailure","PermissionRequest","Notification","Stop","StopFailure"] as $events |
250
+ $events[] as $e |
251
+ $h[$e][]?.hooks[]?.command // empty |
252
+ select(contains("tmux-ai-status")) | split(" ")[0] | "\($e)\t\(.)"' "$src" 2>/dev/null)
253
+ if [ "$src" != "$ADAPTER_HOOKS_FILE" ]; then
254
+ rm -f "$src"
255
+ fi
256
+ # TSV:event\tscript\talive(0|1)。bash 3.2 无关联数组,逐行展开后由 jq 一次聚合。
257
+ rows=""
258
+ while IFS= read -r line; do
259
+ [ -n "$line" ] || continue
260
+ ev="${line%%$'\t'*}"
261
+ p="${line#*$'\t'}"
262
+ if [ -x "$p" ]; then a=1; else a=0; fi
263
+ rows="${rows}${ev}"$'\t'"${p}"$'\t'"${a}"$'\n'
264
+ done <<EOF
265
+ $pairs
266
+ EOF
267
+ jq -R -s --arg mode "$mode" --arg events "$_CLAUDE_EVENTS" '
268
+ (reduce ($events | split(" ")[]) as $e
269
+ ({}; .[$e] = {registered: 0, alive: 0, scriptPaths: []})) as $base
270
+ | reduce (split("\n") | .[] | select(length > 0) | split("\t")) as $r (
271
+ $base;
272
+ .[$r[0]] = {
273
+ registered: (.[$r[0]].registered + 1),
274
+ alive: (.[$r[0]].alive + ($r[2] | tonumber)),
275
+ scriptPaths: ((.[$r[0]].scriptPaths + [$r[1]]) | unique)
276
+ })
277
+ | {mode: $mode, events: ., plugin: null,
278
+ registeredPaths: ([.[] | .scriptPaths[]] | unique)}
279
+ ' <<<"$rows"
280
+ }
281
+
223
282
  # 安装:合并式更新,保留他人(masko 等)注册的 hook。cc-switch 模式只写库
224
283
  # (~/.claude/settings.json 与实例文件都会被 cc-switch 覆盖,写了也不持久)。
225
284
  adapter_install_hooks() {
@@ -74,6 +74,45 @@ adapter_check_integrity() {
74
74
  _CODEX_EVENTS="SessionStart UserPromptSubmit PreToolUse PermissionRequest PostToolUse Stop"
75
75
  _CODEX_LEGACY_TOML="${CODEX_HOME:-$HOME/.codex}/hooks.toml"
76
76
 
77
+ # 明细检查(仅 install wrapper 的 check --json 调用;与 claude adapter 同构)。
78
+ # 差异:无 cc-switch 分支(hooks.json 即文件真源,mode 恒 file)、6 事件、
79
+ # 匹配串为 "tmux-ai-status codex"。失败输出空 events 降级片段,不 exit 非 0。
80
+ adapter_check_detail() {
81
+ local src="$ADAPTER_HOOKS_FILE" pairs rows line ev p a
82
+ if [ ! -f "$src" ]; then
83
+ printf '{"mode":"file","events":{},"plugin":null,"registeredPaths":[]}\n'
84
+ return 0
85
+ fi
86
+ pairs=$(jq -r '.hooks as $h |
87
+ ["SessionStart","UserPromptSubmit","PreToolUse","PermissionRequest","PostToolUse","Stop"] as $events |
88
+ $events[] as $e |
89
+ $h[$e][]?.hooks[]?.command // empty |
90
+ select(contains("tmux-ai-status codex")) | split(" ")[0] | "\($e)\t\(.)"' "$src" 2>/dev/null)
91
+ rows=""
92
+ while IFS= read -r line; do
93
+ [ -n "$line" ] || continue
94
+ ev="${line%%$'\t'*}"
95
+ p="${line#*$'\t'}"
96
+ if [ -x "$p" ]; then a=1; else a=0; fi
97
+ rows="${rows}${ev}"$'\t'"${p}"$'\t'"${a}"$'\n'
98
+ done <<EOF
99
+ $pairs
100
+ EOF
101
+ jq -R -s --arg mode "file" --arg events "$_CODEX_EVENTS" '
102
+ (reduce ($events | split(" ")[]) as $e
103
+ ({}; .[$e] = {registered: 0, alive: 0, scriptPaths: []})) as $base
104
+ | reduce (split("\n") | .[] | select(length > 0) | split("\t")) as $r (
105
+ $base;
106
+ .[$r[0]] = {
107
+ registered: (.[$r[0]].registered + 1),
108
+ alive: (.[$r[0]].alive + ($r[2] | tonumber)),
109
+ scriptPaths: ((.[$r[0]].scriptPaths + [$r[1]]) | unique)
110
+ })
111
+ | {mode: $mode, events: ., plugin: null,
112
+ registeredPaths: ([.[] | .scriptPaths[]] | unique)}
113
+ ' <<<"$rows"
114
+ }
115
+
77
116
  # 从每个事件数组剔除本插件 group(command 含 tmux-ai-status),事件/hooks 清空后删键。
78
117
  _codex_strip_managed() {
79
118
  jq '
@@ -99,12 +138,14 @@ _codex_read_existing() {
99
138
  # 安装:改写 hooks.json(剥离旧 managed group 后为每事件追加本插件 group),保留他人 hook。
100
139
  # no-op if valid:事件齐全且脚本存活时不改写——重写即 trusted_hash 失效(command 路径
101
140
  # 变化 → codex 启动要重新 Trust all),跨安装源重复安装因此保持既有授信。
141
+ # TMUX_AI_FORCE_INSTALL=1 旁路 no-op 门禁(漂移收敛专用:hooks 指向其他安装源但脚本
142
+ # 仍存活时 integrity 恒过,普通 install 永远收敛不了;代价由调用方确认 re-trust)。
102
143
  # 清理旧 hooks.toml(0.144 不再读取)。trust 无法在 bash 复现,靠用户首启 codex TUI 授信。
103
144
  adapter_install_hooks() {
104
145
  _install_require_jq
105
146
  mkdir -p "$(dirname "$ADAPTER_HOOKS_FILE")" 2>/dev/null || true
106
147
  [ -f "$_CODEX_LEGACY_TOML" ] && rm -f "$_CODEX_LEGACY_TOML" && echo "移除旧 hooks.toml(0.144 不再读取)"
107
- if adapter_check_integrity; then
148
+ if adapter_check_integrity && [ -z "${TMUX_AI_FORCE_INSTALL:-}" ]; then
108
149
  echo "Codex hooks already installed and valid at $ADAPTER_HOOKS_FILE (no-op, trust preserved)"
109
150
  return 0
110
151
  fi
@@ -56,6 +56,32 @@ adapter_check_integrity() {
56
56
  grep -q 'request_id: p.requestID' "$ADAPTER_HOOKS_FILE" 2>/dev/null
57
57
  }
58
58
 
59
+ # 明细检查(仅 install wrapper 的 check --json 调用)。plugin 模型无逐事件注册数,
60
+ # 明细为 integrity 五段判定拆成的布尔 + HOOK_SCRIPT 提取与存活。失败输出降级片段。
61
+ adapter_check_detail() {
62
+ local exists="false" marker="false" script="" alive="false"
63
+ local pa="false" qa="false" pr="false" rid="false"
64
+ if [ -f "$ADAPTER_HOOKS_FILE" ]; then
65
+ exists="true"
66
+ if grep -q "$_OPENCODE_MARKER" "$ADAPTER_HOOKS_FILE" 2>/dev/null; then marker="true"; fi
67
+ script=$(sed -n 's/^const HOOK_SCRIPT = "\(.*\)"$/\1/p' "$ADAPTER_HOOKS_FILE" 2>/dev/null | head -n 1)
68
+ if [ -n "$script" ] && [ -x "$script" ]; then alive="true"; fi
69
+ if grep -q 'case "permission.asked"' "$ADAPTER_HOOKS_FILE" 2>/dev/null; then pa="true"; fi
70
+ if grep -q 'case "question.asked"' "$ADAPTER_HOOKS_FILE" 2>/dev/null; then qa="true"; fi
71
+ if grep -q 'case "permission.replied"' "$ADAPTER_HOOKS_FILE" 2>/dev/null; then pr="true"; fi
72
+ if grep -q 'request_id: p.requestID' "$ADAPTER_HOOKS_FILE" 2>/dev/null; then rid="true"; fi
73
+ fi
74
+ jq -cn --arg path "$ADAPTER_HOOKS_FILE" --argjson exists "$exists" --argjson marker "$marker" \
75
+ --arg script "$script" --argjson alive "$alive" \
76
+ --argjson pa "$pa" --argjson qa "$qa" --argjson pr "$pr" --argjson rid "$rid" '
77
+ {mode: "plugin", events: {},
78
+ plugin: {path: $path, exists: $exists, marker: $marker,
79
+ hookScript: $script, hookScriptAlive: $alive,
80
+ structure: {permissionAsked: $pa, questionAsked: $qa,
81
+ permissionReplied: $pr, requestId: $rid}},
82
+ registeredPaths: (if ($script | length) > 0 then [$script] else [] end)}'
83
+ }
84
+
59
85
  # 生成 plugin 文件内容。$1 = tmux-ai-status 绝对路径。
60
86
  _opencode_plugin_content() {
61
87
  local hook_script="$1"
@@ -149,8 +175,9 @@ EOF
149
175
 
150
176
  # 安装:写出/覆盖 plugin 文件。整文件替换(非合并)——同目录下他人 plugin 文件不受影响。
151
177
  # no-op if valid:文件完整且 HOOK_SCRIPT 存活时不重写(避免跨安装源无谓覆盖)。
178
+ # TMUX_AI_FORCE_INSTALL=1 旁路 no-op 门禁(漂移收敛专用;重写无需重新授信)。
152
179
  adapter_install_hooks() {
153
- if adapter_check_integrity; then
180
+ if adapter_check_integrity && [ -z "${TMUX_AI_FORCE_INSTALL:-}" ]; then
154
181
  echo "opencode plugin already installed and valid at $ADAPTER_HOOKS_FILE (no-op)"
155
182
  return 0
156
183
  fi
@@ -1,11 +1,13 @@
1
1
  #!/bin/bash
2
2
  # install-claude-hooks.sh — Claude Code hooks 安装/卸载/检查 wrapper
3
- # 用法: install-claude-hooks.sh [install|uninstall|check|auto]
3
+ # 用法: install-claude-hooks.sh [install|uninstall|check [--json]|auto]
4
4
  #
5
5
  # 薄 wrapper:source 公共骨架 + claude adapter,dispatch 到 adapter_install_hooks /
6
6
  # adapter_uninstall_hooks。实际逻辑在 scripts/adapters/claude.sh。
7
7
  # check:输出 ok/missing(exit 0/1),供 TUI 侦测在 cc-switch 模式下走 bash 侧
8
8
  # 完整性判定(sqlite 真源),与非 cc-switch 的文件判定共用同一真相。
9
+ # check --json:单行紧凑明细 JSON(mode/events/plugin/registeredPaths/suppress/drift),
10
+ # 同样 exit 0/1,供 tmuxclihook check 子命令与 TUI 渲染(见 lib-install-hooks.sh)。
9
11
  # auto:tmux 入口加载时的自动安装——存在卸载意图标记(suppressed-claude)时不装。
10
12
  # 显式 uninstall 成功写标记、显式 install 成功清标记(见 lib-install-hooks.sh)。
11
13
 
@@ -22,7 +24,9 @@ case "${1:-install}" in
22
24
  _hooks_suppress_write claude
23
25
  ;;
24
26
  check)
25
- if adapter_check_integrity; then
27
+ if [ "${2:-}" = "--json" ]; then
28
+ _wrapper_check_json claude
29
+ elif adapter_check_integrity; then
26
30
  echo ok
27
31
  else
28
32
  echo missing
@@ -1,10 +1,11 @@
1
1
  #!/bin/bash
2
2
  # install-codex-hooks.sh — Codex CLI hooks 安装/卸载/检查 wrapper(需 codex ≥ 0.144)
3
- # 用法: install-codex-hooks.sh [install|uninstall|check]
3
+ # 用法: install-codex-hooks.sh [install|uninstall|check [--json]]
4
4
  #
5
5
  # 薄 wrapper:source 公共骨架 + codex adapter,dispatch 到 adapter_install_hooks /
6
6
  # adapter_uninstall_hooks。实际逻辑在 scripts/adapters/codex.sh。
7
7
  # check:输出 ok/missing(exit 0/1),供 TUI 侦测走 bash 侧完整性判定。
8
+ # check --json:单行紧凑明细 JSON(mode/events/suppress/drift),同样 exit 0/1。
8
9
  # 显式 uninstall 成功写标记、显式 install 成功清标记(见 lib-install-hooks.sh)。
9
10
 
10
11
  set -o errexit
@@ -20,7 +21,9 @@ case "${1:-install}" in
20
21
  _hooks_suppress_write codex
21
22
  ;;
22
23
  check)
23
- if adapter_check_integrity; then
24
+ if [ "${2:-}" = "--json" ]; then
25
+ _wrapper_check_json codex
26
+ elif adapter_check_integrity; then
24
27
  echo ok
25
28
  else
26
29
  echo missing
@@ -1,10 +1,11 @@
1
1
  #!/bin/bash
2
2
  # install-opencode-hooks.sh — opencode plugin 安装/卸载/检查 wrapper
3
- # 用法: install-opencode-hooks.sh [install|uninstall|check]
3
+ # 用法: install-opencode-hooks.sh [install|uninstall|check [--json]]
4
4
  #
5
5
  # 薄 wrapper:source 公共骨架 + opencode adapter,dispatch 到 adapter_install_hooks /
6
6
  # adapter_uninstall_hooks。实际逻辑在 scripts/adapters/opencode.sh。
7
7
  # check:输出 ok/missing(exit 0/1),供 TUI 侦测走 bash 侧完整性判定。
8
+ # check --json:单行紧凑明细 JSON(mode/plugin/suppress/drift),同样 exit 0/1。
8
9
  # 显式 uninstall 成功写标记、显式 install 成功清标记(见 lib-install-hooks.sh)。
9
10
 
10
11
  set -o errexit
@@ -20,7 +21,9 @@ case "${1:-install}" in
20
21
  _hooks_suppress_write opencode
21
22
  ;;
22
23
  check)
23
- if adapter_check_integrity; then
24
+ if [ "${2:-}" = "--json" ]; then
25
+ _wrapper_check_json opencode
26
+ elif adapter_check_integrity; then
24
27
  echo ok
25
28
  else
26
29
  echo missing
@@ -37,3 +37,41 @@ _hooks_suppress_write() {
37
37
  date +%s > "$(_hooks_suppress_file "$1")" 2>/dev/null || true
38
38
  }
39
39
  _hooks_suppress_clear() { rm -f "$(_hooks_suppress_file "$1")" 2>/dev/null || true; }
40
+
41
+ # --- check --json:完整性明细 JSON(wrapper 专用,事件热路径不触达)---
42
+ # stdout = 单行紧凑 JSON;exit code 与默认 check 一致(ok→0 / missing→1)。
43
+ # status 判定复用 adapter_check_integrity 本体(同一真源,与 ok/missing 永不发散);
44
+ # suppress 在 wrapper 层判定(adapter 双上下文不依赖本文件,见各 adapter 头注释);
45
+ # drift 参照系 = 执行中 wrapper 自身的 _LIB_DIR:Node 侧经 execa 调用的正是其解析出的
46
+ # SCRIPTS_DIR 下的 wrapper,故 _LIB_DIR 即当前 source 的 scripts dir;手动用旧 source
47
+ # 的 wrapper 跑 check 得到「相对该 source 无漂移」也是正确语义(它正是那个源的收敛执行者)。
48
+ _wrapper_check_json() { # $1=tool id
49
+ local tool="$1" status="missing" detail suppress
50
+ if ! command -v jq >/dev/null 2>&1; then
51
+ printf '{"tool":"%s","status":"missing","mode":"unknown","suppress":false,"events":{},"plugin":null,"registeredPaths":[],"drift":{"drifted":false,"expected":"","currentScriptsDir":"%s","stale":[]},"error":"jq not found"}\n' "$tool" "$_LIB_DIR"
52
+ exit 1
53
+ fi
54
+ detail=$(adapter_check_detail 2>/dev/null) || detail=""
55
+ [ -n "$detail" ] || detail='{}'
56
+ if adapter_check_integrity >/dev/null 2>&1; then
57
+ status="ok"
58
+ fi
59
+ if [ -f "$(_hooks_suppress_file "$tool")" ]; then
60
+ suppress="true"
61
+ else
62
+ suppress="false"
63
+ fi
64
+ jq -cn --arg tool "$tool" --arg status "$status" --argjson detail "$detail" \
65
+ --argjson suppress "$suppress" --arg dir "$_LIB_DIR" '
66
+ ($detail + {tool: $tool, status: $status, suppress: $suppress})
67
+ | .drift = {
68
+ expected: ($dir + "/tmux-ai-status"),
69
+ currentScriptsDir: $dir,
70
+ stale: (($detail.registeredPaths // [])
71
+ | map(select(. != ($dir + "/tmux-ai-status")) ) | unique)
72
+ }
73
+ | .drift.drifted = ((.drift.stale | length) > 0)'
74
+ if [ "$status" != "ok" ]; then
75
+ exit 1
76
+ fi
77
+ }
@@ -604,6 +604,13 @@ _maybe_repair_hooks() {
604
604
  local installer="${ADAPTER_INSTALLER}"
605
605
  if ! _check_hooks_integrity; then
606
606
  _ai_log "REPAIR: hooks integrity check failed, reinstalling (${TOOL_ID:-claude})"
607
- [ -x "$installer" ] && "$installer" >/dev/null 2>&1 || true
607
+ # 浮出提示与 SessionStart 修复路径(tmux-ai-status)对齐:修复不再完全静默。
608
+ # 本函数仅经 _run_refresh 被 tmux hook 事件触发(run-shell 上下文,不在 AI CLI
609
+ # 同步 hook 路径);display-message 无 -p 写 client tty 而非调用方 stdout。
610
+ if [ -x "$installer" ] && "$installer" >/dev/null 2>&1; then
611
+ tmux display-message "✓ ${TOOL_ID:-claude} hooks 已自动修复(检测到缺失)" 2>/dev/null || true
612
+ else
613
+ tmux display-message "⚠ ${TOOL_ID:-claude} hooks 自动修复失败,请运行 tmuxclihook 检查" 2>/dev/null || true
614
+ fi
608
615
  fi
609
616
  }
@@ -29,11 +29,12 @@ _save_pane_id
29
29
  _maybe_cleanup_stale
30
30
 
31
31
  # cc-switch 注入的 provider 标签(launch env,CLI 的 hooks 进程继承)→
32
- # per-pane option,供 build_all_status 聚合渲染。env 随 CLI 进程定死,幂等
33
- # 重写无害;非 switch 启动(env 缺失)时显式清除残留,避免换 AI 进程后 pane
34
- # 显示上一个 provider。
32
+ # per-pane option,供 build_all_status 聚合渲染。**仅 codex 捕获**:claude 的
33
+ # provider 标签由 wrapper 在 CLI 内的 statusline 显示,tmux 状态栏刻意不重复
34
+ # 渲染——claude/opencode 的事件一律走清理分支(顺带清掉同 pane 之前的残留)。
35
+ # codex 无 env(非 switch 启动)同样清理。env 随 CLI 进程定死,幂等重写无害。
35
36
  if [ -n "$TMUX_PANE" ]; then
36
- if [ -n "$CC_SWITCH_PROVIDER" ]; then
37
+ if [ "$TOOL_ID" = "codex" ] && [ -n "$CC_SWITCH_PROVIDER" ]; then
37
38
  tmux set-option -pt "$TMUX_PANE" @ai_provider "$CC_SWITCH_PROVIDER" 2>/dev/null || true
38
39
  if [ -n "$CC_SWITCH_PROVIDER_ID" ]; then
39
40
  tmux set-option -pt "$TMUX_PANE" @ai_provider_id "$CC_SWITCH_PROVIDER_ID" 2>/dev/null || true
@@ -0,0 +1,159 @@
1
+ // check-detail.js — wrapper `check --json` 的解析、规范化与人类可读渲染
2
+ //
3
+ // `tmuxclihook check` 子命令与 TUI「侦测 AI CLI」明细共用;判定逻辑全在 bash 侧
4
+ // (adapter_check_detail + lib-install-hooks.sh 的 _wrapper_check_json),本模块只做
5
+ // 解析与渲染,不重复实现判定(避免两处真源)。status 永远以 bash 的判定为准。
6
+ import { execa } from 'execa';
7
+
8
+ const OK = '✓';
9
+ const NO = '✗';
10
+ const WARN = '⚠';
11
+
12
+ const MODE_LABEL = {
13
+ file: '配置文件',
14
+ ccswitch: 'cc-switch db(通用配置)',
15
+ plugin: 'opencode plugin 文件',
16
+ unknown: '未知',
17
+ };
18
+
19
+ function defaultRun(tool) {
20
+ return execa('bash', [tool.installer, 'check', '--json'], { reject: false, timeout: 15000 });
21
+ }
22
+
23
+ function baseDetail(tool) {
24
+ return {
25
+ ok: false,
26
+ tool: tool.id,
27
+ label: tool.label,
28
+ status: 'unknown',
29
+ mode: 'unknown',
30
+ suppress: false,
31
+ events: {},
32
+ plugin: null,
33
+ registeredPaths: [],
34
+ drift: null,
35
+ error: null,
36
+ raw: null,
37
+ };
38
+ }
39
+
40
+ // 调某工具 wrapper 的 check --json 并解析规范化。
41
+ // 返回 baseDetail 的超集;status='unknown' 表示 wrapper 输出无法采信
42
+ // (脚本缺失 / 输出非 JSON / jq 缺失的 error JSON),error 字段给出原因。
43
+ // run 可注入供单测(沿用 env.test.js 的依赖注入风格)。
44
+ export async function checkToolDetail(tool, { run } = {}) {
45
+ const runFn = run || defaultRun;
46
+ let res;
47
+ try {
48
+ res = await runFn(tool);
49
+ } catch (err) {
50
+ return { ...baseDetail(tool), error: err.shortMessage || String(err) };
51
+ }
52
+ const stdout = String(res.stdout ?? '').trim();
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(stdout);
56
+ } catch {
57
+ return {
58
+ ...baseDetail(tool),
59
+ error: `wrapper 输出不是合法 JSON(exit ${res.exitCode ?? '?'}):${stdout.slice(0, 200)}`,
60
+ raw: stdout,
61
+ };
62
+ }
63
+ if (parsed.error) {
64
+ return {
65
+ ...baseDetail(tool),
66
+ status: parsed.status === 'ok' ? 'ok' : 'unknown',
67
+ mode: parsed.mode || 'unknown',
68
+ suppress: Boolean(parsed.suppress),
69
+ events: parsed.events || {},
70
+ plugin: parsed.plugin ?? null,
71
+ registeredPaths: parsed.registeredPaths || [],
72
+ drift: parsed.drift || null,
73
+ error: parsed.error,
74
+ raw: stdout,
75
+ };
76
+ }
77
+ const status = parsed.status === 'ok' ? 'ok' : 'missing';
78
+ return {
79
+ ...baseDetail(tool),
80
+ ok: status === 'ok' && res.exitCode === 0,
81
+ status,
82
+ mode: parsed.mode || 'unknown',
83
+ suppress: Boolean(parsed.suppress),
84
+ events: parsed.events || {},
85
+ plugin: parsed.plugin ?? null,
86
+ registeredPaths: parsed.registeredPaths || [],
87
+ drift: parsed.drift || null,
88
+ raw: stdout,
89
+ };
90
+ }
91
+
92
+ // 渲染单工具明细为人类可读行。drift 行用 ⚠ 前缀(脚本仍可用,功能未坏,
93
+ // 但指向的安装源不是当前 source——收敛入口在 TUI 侧)。
94
+ export function renderToolDetail(detail) {
95
+ const lines = [];
96
+ const modeNote = MODE_LABEL[detail.mode] || detail.mode;
97
+ if (detail.status === 'ok') {
98
+ lines.push(` ${OK} hooks 完整(真源:${modeNote})`);
99
+ } else if (detail.status === 'missing') {
100
+ lines.push(` ${NO} hooks 缺失或损坏(真源:${modeNote})`);
101
+ } else {
102
+ lines.push(` ${NO} 无法判定:${detail.error || '未知错误'}`);
103
+ }
104
+
105
+ if (detail.suppress) {
106
+ lines.push(` ${WARN} 存在卸载意图标记(suppressed-${detail.tool}):自动修复已被抑制`);
107
+ }
108
+
109
+ // 逐事件缺失明细(cc-switch/文件模式的 tools)
110
+ if (detail.status === 'missing') {
111
+ const broken = [];
112
+ for (const [ev, e] of Object.entries(detail.events || {})) {
113
+ if (!e || e.registered === 0) broken.push(` ${NO} ${ev}:未注册`);
114
+ else if (e.alive === 0) {
115
+ broken.push(` ${NO} ${ev}:脚本不可执行(${(e.scriptPaths || []).join(', ') || '未知路径'})`);
116
+ }
117
+ }
118
+ if (broken.length > 0) {
119
+ lines.push(' 缺失明细:');
120
+ lines.push(...broken);
121
+ }
122
+ }
123
+
124
+ // opencode plugin 明细(无逐事件概念,异常按布尔汇总)
125
+ if (detail.plugin && detail.status === 'missing') {
126
+ const pl = detail.plugin;
127
+ if (!pl.exists) {
128
+ lines.push(` ${NO} plugin 文件不存在:${pl.path}`);
129
+ } else {
130
+ const marks = [];
131
+ if (!pl.marker) marks.push('缺本插件标记');
132
+ if (!pl.hookScriptAlive) marks.push('HOOK_SCRIPT 不可执行');
133
+ for (const [k, v] of Object.entries(pl.structure || {})) {
134
+ if (!v) marks.push(`缺结构片段 ${k}`);
135
+ }
136
+ if (marks.length > 0) lines.push(` ${NO} plugin 异常:${marks.join('、')}`);
137
+ }
138
+ }
139
+
140
+ // 注册路径与漂移
141
+ if (detail.drift?.drifted) {
142
+ lines.push(` ${WARN} 漂移:hooks 指向其他安装源(脚本仍可用,check 仍判完整)`);
143
+ for (const s of detail.drift.stale || []) lines.push(` 旧路径:${s}`);
144
+ lines.push(` 当前 source 路径:${detail.drift.expected}`);
145
+ } else if (detail.status !== 'unknown' && (detail.registeredPaths || []).length > 0) {
146
+ for (const rp of detail.registeredPaths) lines.push(` 注册路径:${rp}`);
147
+ }
148
+
149
+ return lines;
150
+ }
151
+
152
+ // 多工具汇总(tmuxclihook check 的 exit code 与 TUI 收敛入口判定用)
153
+ export function summarizeDetails(details) {
154
+ return {
155
+ hasMissing: details.some((d) => d.status === 'missing'),
156
+ hasDrift: details.some((d) => d.drift?.drifted),
157
+ hasError: details.some((d) => d.status === 'unknown'),
158
+ };
159
+ }
package/src/esc.js ADDED
@@ -0,0 +1,33 @@
1
+ // esc.js — 把 Esc 键转译为合成 ctrl+c
2
+ //
3
+ // clack 0.7 的 Prompt 仅把 \x03(ctrl+c)映射为 cancel(core 的 onKeypress 里
4
+ // 唯一的 cancel 触发是 ch === "\x03"),Esc 默认无任何效果——各子菜单的
5
+ // 「Esc 返回」此前只是文案承诺,从未生效。这里在 stdin 上挂全局 keypress
6
+ // 监听,看到 escape 键就向 stdin 注入合成 \x03,复用 clack 既有且经实测的
7
+ // ctrl+c cancel 路径:主菜单 Esc → isCancel → 退出;子菜单 Esc → isCancel →
8
+ // 返回主菜单;confirm/text Esc → isCancel → 各自的取消分支。
9
+ //
10
+ // spinner 期间不转译(与改动前一致:Esc 静默)。原因:clack 的 block() 给
11
+ // \x03 装了 process.exit(0) 的监听,若在 spinner 期间转译,Esc 会硬退出 TUI
12
+ // 且不恢复 raw mode;ctrl+c 行为不变(仍走该路径,与既有行为一致)。
13
+
14
+ let spinnerDepth = 0;
15
+
16
+ export function setSpinnerActive(on) {
17
+ spinnerDepth += on ? 1 : -1;
18
+ if (spinnerDepth < 0) spinnerDepth = 0;
19
+ }
20
+
21
+ // 返回解绑函数(进程退出即回收,正常无需调用;导出以便单测)。
22
+ export function enableEscapeCancel() {
23
+ const onKeypress = (ch, key) => {
24
+ if (spinnerDepth === 0 && key?.name === 'escape') {
25
+ // 直接 emit 合成 keypress(ch='\x03'):clack 的 onKeypress 以 ch === '\x03'
26
+ // 判定 cancel,对它来说这与真实 ctrl+c 完全同构。不走 stdin.write——
27
+ // raw mode(-echo)下向 fd 0 写入不会回环成输入。
28
+ process.stdin.emit('keypress', '\x03', { name: 'c', ctrl: true, sequence: '\x03' });
29
+ }
30
+ };
31
+ process.stdin.on('keypress', onKeypress);
32
+ return () => process.stdin.off('keypress', onKeypress);
33
+ }
package/src/hooks.js CHANGED
@@ -4,13 +4,19 @@ import { existsSync } from 'node:fs';
4
4
  import { toolById } from './adapters-meta.js';
5
5
  import { hooksInstalledAsync } from './detect.js';
6
6
 
7
- // 安装某工具 hooks(调薄 wrapper install-<tool>-hooks.sh)
8
- export async function installHooks(id) {
7
+ // 安装某工具 hooks(调薄 wrapper install-<tool>-hooks.sh)。
8
+ // force: 传 TMUX_AI_FORCE_INSTALL=1 旁路 codex/opencode 的 no-op if valid 门禁——
9
+ // 漂移收敛专用(hooks 指向其他安装源但脚本存活时 integrity 恒过,普通 install
10
+ // 永远收敛不了);codex 重写即 trusted_hash 失效,force 前须由调用方确认代价。
11
+ export async function installHooks(id, { force = false } = {}) {
9
12
  const tool = toolById(id);
10
13
  if (!tool) return { ok: false, output: `未知工具: ${id}` };
11
14
  if (!existsSync(tool.installer)) return { ok: false, output: `找不到安装脚本: ${tool.installer}` };
12
15
  try {
13
- const { stdout } = await execa('bash', [tool.installer], { timeout: 30000 });
16
+ const { stdout } = await execa('bash', [tool.installer], {
17
+ timeout: 30000,
18
+ env: force ? { TMUX_AI_FORCE_INSTALL: '1' } : undefined,
19
+ });
14
20
  return { ok: true, output: stdout };
15
21
  } catch (err) {
16
22
  return { ok: false, output: err.stderr || err.shortMessage || String(err) };