tmuxclihook 0.4.1 → 0.5.1

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
@@ -9,9 +9,16 @@
9
9
  npm install -g tmuxclihook
10
10
  tmuxclihook
11
11
 
12
- # 检查 npm registry 并将全局安装更新到最新版
12
+ # 按当前安装方式(npm/volta)检查并更新到最新版
13
13
  tmuxclihook update
14
14
 
15
+ # 升级并直接删除不生效的另一份安装(默认会交互确认)
16
+ tmuxclihook update --prune
17
+
18
+ # hooks 健康明细(非交互):每个 CLI 的完整性、判定真源、注册路径、漂移警告
19
+ # 可选参数 claude|codex|opencode 只看一个;exit 1 = 有缺失或漂移
20
+ tmuxclihook check
21
+
15
22
  # 查看版本 / 帮助(version、--version、-v 与 help、--help、-h 等价)
16
23
  tmuxclihook --version
17
24
  tmuxclihook --help
@@ -30,16 +37,18 @@ npm start
30
37
 
31
38
  hooks 安装、tmux 集成写入的路径由「source」决定:环境变量 `TMUXCLIHOOK_SOURCE` > 持久化配置 `~/.config/tmuxclihook/config.json`(TUI「source 管理」菜单写入)> 包内自带 scripts/(生产默认)> 仓库根 scripts/(`cd installer && npm start` 时的开发回落)。见下方「source 管理」一节。
32
39
 
33
- `tmuxclihook update` 会显示当前版本,通过 `npm view tmuxclihook version` 查询最新版;版本不同时直接执行 `npm install -g tmuxclihook@latest`,版本相同则不做修改。网络查询或安装失败时命令以非零状态退出。
40
+ `tmuxclihook update` 会显示当前版本,通过 `npm view tmuxclihook version` 查询最新版;版本相同则不做修改。需要升级时,它按 PATH 上第一个可执行的 `tmuxclihook` 判断当前生效的安装方式:入口位于 `$VOLTA_HOME/bin/`(默认 `~/.volta/bin/`)或 volta 的 packages 镜像内时,只执行 `volta install tmuxclihook@latest`;其余入口(npm 全局、nvm/fnm 等 node 工具链目录)只执行 `npm install -g tmuxclihook@latest`。不会同时写入两套安装。网络查询或安装失败时命令以非零状态退出。
41
+
42
+ 安装完成后会按 PATH 自检实际生效版本。若生效入口不是 volta、自检仍未反映新版,且本机可执行 volta,则补执行一次 `volta install tmuxclihook@latest` 作为兜底并复检;仍不一致时打印警告(不影响退出码)。
34
43
 
35
- 安装成功后会做一次生效自检:按 PATH 解析实际会被运行的 `tmuxclihook`,读取其真实版本与最新版比对。若本机使用 volta,`npm install -g` 只会更新 node 工具链目录里的副本、不会更新 volta 的 shim 镜像(实测 volta 2.0.2:shim npm 的全局 prefix 即工具链目录),自检发现这种情况时会自动补执行 `volta install tmuxclihook@latest` 使更新生效并复检;volta 缺失或修正失败时打警告并提示手动命令。`npm install` 始终是标准安装方式,无需因 volta 改变习惯。
44
+ 升级确认成功后,update 会检查另一套安装(volta packages 镜像或当前 npm 全局 prefix)是否还留有副本。发现副本时:TTY 下询问是否删除(默认不删);非交互环境只打印警告;`--prune` 跳过询问直接删除。删除前会校验该副本未被任何 hooks 与 `~/.tmux.conf` 引用——任一检查无法确认就保留并提示先重装 hooks。**生效入口永远不删**,清理失败不影响升级结果。也可以手动收敛:卸载不再使用的那份(`npm uninstall -g tmuxclihook` 或 `volta uninstall tmuxclihook`),再从生效入口重新安装 hooks。
36
45
 
37
46
  ## 功能
38
47
 
39
48
  | 菜单项 | 说明 |
40
49
  |--------|------|
41
50
  | 环境检查 | 侦测 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 真源)判定 |
51
+ | 侦测 AI CLI | 扫描 Claude Code / Codex / opencode,显示版本、最低版本和 hooks 健康明细(判定真源、缺失原因、注册路径、漂移警告,见下节);不自动安装或升级 AI CLI。判定全部走 bash 侧 wrapper `check --json`(cc-switch 模式在 bash 内查 sqlite 真源),TUI 只渲染 |
43
52
  | 安装 hooks | 选工具(claude/codex/opencode/全部)→ 调 `scripts/install-<tool>-hooks.sh`(路径随当前 source 解析) |
44
53
  | 卸载 hooks | 对称卸载本插件 hooks,保留其他工具注册的 hook |
45
54
  | 修复 hooks | 完整性检查 → 缺失则重装 |
@@ -47,6 +56,27 @@ hooks 安装、tmux 集成写入的路径由「source」决定:环境变量 `T
47
56
  | source 管理 | 查看当前生效 source(来源 + 失效警告);设置本地仓库路径用于开发调试;清除持久化配置恢复默认 |
48
57
  | 完整卸载插件 | 停止 Codex monitor、清除聚合状态 `@ai_all_status`、清理历史遗留软链(不删仓库);`.tmux.conf` 删声明与 `tmux kill-server` 因破坏性仅打印手动指引 |
49
58
 
59
+ ### hooks 健康明细与漂移收敛
60
+
61
+ `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 自修复的既有契约不受影响。
62
+
63
+ **漂移(drift)**:hooks 指向其他安装源但脚本仍存活(典型场景:本地调试切 source 后、volta 换路径后旧包仍可用)。此时功能未坏、check 仍判 `ok`,但 `tmuxclihook check` 与 TUI 会给出 ⚠ 警告并列出旧路径。漂移参照系是**执行中的 wrapper 自身路径**——`tmuxclihook`/TUI 经当前 source 调 wrapper,警告即「已装 hooks 不是当前 source 写的」。
64
+
65
+ **发现漂移后的收敛(策略不对称)**:
66
+
67
+ | 配置 | 收敛代价 | 方式 |
68
+ |------|---------|------|
69
+ | Claude Code hooks | 无(install 幂等强收敛) | TUI 选择即收敛 |
70
+ | Codex hooks | 重写 `hooks.json` 使 `trusted_hash` 失效,需重新 Trust all | TUI 显式确认后收敛(`TMUX_AI_FORCE_INSTALL=1` 旁路 no-op 门禁) |
71
+ | opencode plugin | 无信任代价(整文件重写) | TUI 确认后收敛 |
72
+ | `.tmux.conf` 声明 | 无 | 复用「tmux 集成」的原地替换 + 重载 |
73
+
74
+ 非交互场景下 `tmuxclihook check` 只报告并 exit 1,收敛动作需回 TUI(或手动跑对应 `install-<tool>-hooks.sh`;codex 记得带 `TMUX_AI_FORCE_INSTALL=1`)。
75
+
76
+ ### Esc 导航
77
+
78
+ clack 0.7 只把 ctrl+c 映射为取消(Esc 默认无效果),安装器在 TUI 内把 Esc 转译为合成取消:**主菜单 Esc 退出**、子菜单 Esc 返回主菜单、确认/输入框 Esc 取消。spinner 期间 Esc 静默(避免误触硬退出);ctrl+c 行为不变。
79
+
50
80
  ### cc-switch 模式(Claude Code)
51
81
 
52
82
  检测到 `~/.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,14 +458,50 @@ 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
- updatePackage().then((ok) => {
494
+ updatePackage({ prune: process.argv.includes('--prune') }).then((ok) => {
371
495
  if (!ok) process.exitCode = 1;
372
496
  }).catch((err) => {
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,7 +510,9 @@ if (arg === 'update') {
381
510
  '\n' +
382
511
  '用法:\n' +
383
512
  ' tmuxclihook 打开交互式 TUI 安装器\n' +
384
- ' tmuxclihook update 检查并更新到 npm 最新版本\n' +
513
+ ' tmuxclihook check hooks 健康明细(可跟 claude|codex|opencode;exit 1 = 有缺失或漂移)\n' +
514
+ ' tmuxclihook update 按当前安装方式(npm/volta)检查并更新到最新版本\n' +
515
+ ' tmuxclihook update --prune 升级后直接删除不生效的另一份安装(默认交互确认)\n' +
385
516
  ' tmuxclihook version 显示版本(--version / -v 等价)\n' +
386
517
  ' tmuxclihook help 显示本帮助(--help / -h 等价)',
387
518
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmuxclihook",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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
  }
@@ -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) };
package/src/update.js CHANGED
@@ -1,5 +1,6 @@
1
- import { readFileSync, accessSync, constants as fsConstants, realpathSync } from 'node:fs';
2
- import { delimiter, dirname, join } from 'node:path';
1
+ import { readFileSync, accessSync, existsSync, constants as fsConstants, realpathSync } from 'node:fs';
2
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { execa } from 'execa';
4
5
 
5
6
  const PACKAGE_NAME = 'tmuxclihook';
@@ -9,12 +10,13 @@ export const CURRENT_VERSION = packageJson.version;
9
10
 
10
11
  // —— 生效版本自检 ————————————————————————————————————————————————
11
12
  //
12
- // `npm install -g` 在版本管理器(volta 等)环境下不会更新其管理的 shim:
13
- // volta 的 `~/.volta/bin` shim 指向 packages/ 镜像,而 npm -g 只写 node
14
- // 工具链目录,两者互不感知(实测 0.3.0 → 0.3.2 升级时用户命令仍解析到
15
- // 旧版)。生效收敛由 repairWithVolta 无条件完成(见下);本节自检只是
16
- // 事后确认:按 update 进程的 PATH 解析一份,尽力读取真实版本并与
17
- // registry latest 比对,无法确认或版本不符时打警告。
13
+ // `npm install -g` 在版本管理器(volta 等)环境下只写 node 工具链目录,
14
+ // 不更新 volta 的 shim 镜像(shim 指向 packages/)。因此升级前先按 PATH
15
+ // 上第一个可执行 tmuxclihook 判断「当前生效入口」属于哪套安装机制,只走
16
+ // 对应那一套(见 detectInstallSource / updatePackage),避免同一版本被写
17
+ // 成两份、hooks 指向其中一份而 PATH 命中另一份造成漂移。
18
+ // 本节自检只是事后确认:按 update 进程的 PATH 解析一份,尽力读取真实版本
19
+ // 并与 registry latest 比对,无法确认或版本不符时打警告。
18
20
  // 验证是尽力而为的提示,不改变 update 的返回值(install 本身已成功)。
19
21
 
20
22
  function findOnPath(name, env) {
@@ -31,6 +33,200 @@ function findOnPath(name, env) {
31
33
  return null;
32
34
  }
33
35
 
36
+ // realpath 失败时退回字面绝对路径,保证包含判断仍可进行(不抛)。
37
+ function realOrResolved(p) {
38
+ if (!p) return null;
39
+ try {
40
+ return realpathSync(p);
41
+ } catch {
42
+ return resolve(p);
43
+ }
44
+ }
45
+
46
+ // child 是否位于 parent 目录内(含相等)。用 relative 而非 startsWith,
47
+ // 避免 `$VOLTA_HOME/bin-other` 被误判为 `$VOLTA_HOME/bin` 的子路径。
48
+ function isPathInside(child, parent) {
49
+ const rel = relative(parent, child);
50
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
51
+ }
52
+
53
+ const VOLTA_SHIM_NAMES = new Set(['volta-shim', 'volta-shim.exe']);
54
+
55
+ // 判断 PATH 上第一个可执行的 tmuxclihook 来自哪套安装机制:
56
+ // 'volta' — $VOLTA_HOME/bin 下的 shim、或 volta packages 镜像
57
+ // 'npm' — npm 全局 / nvm / fnm 等 node 工具链目录
58
+ // 'unknown' — PATH 上找不到可执行入口
59
+ // 跟随「当前生效入口」是 update 的核心语义:只升级用户实际会执行的那一份。
60
+ export function detectInstallSource(env = process.env) {
61
+ try {
62
+ const entry = findOnPath(PACKAGE_NAME, env);
63
+ if (!entry) return 'unknown';
64
+
65
+ const voltaHome = env.VOLTA_HOME || (env.HOME ? join(env.HOME, '.volta') : null);
66
+ const realEntry = realOrResolved(entry);
67
+
68
+ if (voltaHome) {
69
+ // macOS 的 /var → /private/var 等:原始路径与 canonical 路径都要比对
70
+ const homes = [voltaHome];
71
+ const realHome = realOrResolved(voltaHome);
72
+ if (realHome && realHome !== voltaHome) homes.push(realHome);
73
+
74
+ for (const home of homes) {
75
+ const binDir = join(home, 'bin');
76
+ const pkgDir = join(home, 'tools', 'image', 'packages', PACKAGE_NAME);
77
+ for (const candidate of [entry, realEntry]) {
78
+ if (!candidate) continue;
79
+ if (isPathInside(candidate, binDir) || isPathInside(candidate, pkgDir)) return 'volta';
80
+ }
81
+ }
82
+ }
83
+
84
+ if (realEntry && VOLTA_SHIM_NAMES.has(basename(realEntry))) return 'volta';
85
+ return 'npm';
86
+ } catch {
87
+ return 'unknown';
88
+ }
89
+ }
90
+
91
+ // —— 非生效副本检测与可选收敛 ——————————————————————————————————————
92
+ //
93
+ // update 只升级生效入口,不主动动另一套安装。历史遗留的双份副本会让后续
94
+ // 手动交叉升级「升级到不生效的那份」,因此提供可选收敛:只删同时满足以下
95
+ // 条件的副本——不是生效入口、未被 hooks 引用、未被 ~/.tmux.conf 引用、
96
+ // 位于已知标准位置。TTY 下交互确认(默认 No);--prune 跳过确认;
97
+ // 非交互环境只警告,绝不静默删除。
98
+
99
+ const TOOL_IDS = ['claude', 'codex', 'opencode'];
100
+
101
+ function voltaPackagesRoot(env) {
102
+ const voltaHome = env.VOLTA_HOME || (env.HOME ? join(env.HOME, '.volta') : null);
103
+ return voltaHome ? join(voltaHome, 'tools', 'image', 'packages', PACKAGE_NAME) : null;
104
+ }
105
+
106
+ async function readNpmGlobalPrefix(run) {
107
+ try {
108
+ const r = await run('npm', ['prefix', '-g']);
109
+ return (r.stdout || '').trim() || null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // 返回「非生效 + 位于标准位置」的副本。source 为 unknown 时不做任何
116
+ // 检测——找不到生效入口时删除任何一份都可能让用户彻底没有命令。
117
+ export async function detectStaleInstalls(source, env = process.env, run = execa) {
118
+ if (source === 'unknown') return [];
119
+
120
+ const stale = [];
121
+ if (source === 'npm') {
122
+ const voltaRoot = voltaPackagesRoot(env);
123
+ if (voltaRoot && existsSync(voltaRoot)) stale.push({ kind: 'volta', root: voltaRoot });
124
+ }
125
+ if (source === 'volta') {
126
+ const prefix = await readNpmGlobalPrefix(run);
127
+ const npmRoot = prefix ? join(prefix, 'lib', 'node_modules', PACKAGE_NAME) : null;
128
+ if (npmRoot && existsSync(npmRoot)) stale.push({ kind: 'npm', root: npmRoot });
129
+ }
130
+ return stale;
131
+ }
132
+
133
+ // hooks 是否引用该副本。走三个 wrapper 的 check --json(与 TUI 同源),
134
+ // 输出里出现目标路径即视为引用。任何异常都按「有引用」处理,宁可不动。
135
+ async function hooksReference(root, env, run) {
136
+ const scriptsDir = fileURLToPath(new URL('../scripts/', import.meta.url));
137
+ for (const tool of TOOL_IDS) {
138
+ const script = join(scriptsDir, `install-${tool}-hooks.sh`);
139
+ if (!existsSync(script)) continue;
140
+ try {
141
+ const r = await run('bash', [script, 'check', '--json'], { env });
142
+ if ((r.stdout || '').includes(root)) return tool;
143
+ } catch (err) {
144
+ if ((err.stdout || '').includes(root)) return tool;
145
+ return tool;
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function tmuxConfReferences(root, env) {
152
+ if (!env.HOME) return true;
153
+ try {
154
+ return readFileSync(join(env.HOME, '.tmux.conf'), 'utf8').includes(root);
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ async function uninstallStale(item, run) {
161
+ try {
162
+ if (item.kind === 'volta') {
163
+ await run('volta', ['uninstall', PACKAGE_NAME], { stdio: 'inherit' });
164
+ } else {
165
+ await run('npm', ['uninstall', '-g', PACKAGE_NAME], { stdio: 'inherit' });
166
+ }
167
+ return true;
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ // 交互确认的默认实现:读一行,默认 No。测试通过 options.confirm 注入。
174
+ async function defaultConfirm(question) {
175
+ const { createInterface } = await import('node:readline/promises');
176
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
177
+ try {
178
+ const answer = await rl.question(`${question} [y/N] `);
179
+ return /^y(es)?$/i.test(answer.trim());
180
+ } finally {
181
+ rl.close();
182
+ }
183
+ }
184
+
185
+ // 清理非生效副本。返回是否实际删除过任何副本。
186
+ export async function cleanupStale(options) {
187
+ const {
188
+ source, env, run, log, prune, isTTY, confirm, checkHooks, checkTmuxConf,
189
+ } = options;
190
+
191
+ const stale = await options.detectStale(source, env, run);
192
+ if (stale.length === 0) return false;
193
+
194
+ let removed = false;
195
+ for (const item of stale) {
196
+ const label = `${item.kind} install at ${item.root}`;
197
+
198
+ const hookTool = await checkHooks(item.root, env, run);
199
+ if (hookTool) {
200
+ log.error(`Not removing ${label}: still referenced by ${hookTool} hooks. Reinstall hooks from the effective entry first.`);
201
+ continue;
202
+ }
203
+ if (checkTmuxConf(item.root, env)) {
204
+ log.error(`Not removing ${label}: still referenced by ~/.tmux.conf.`);
205
+ continue;
206
+ }
207
+
208
+ if (!prune) {
209
+ if (!isTTY) {
210
+ log.error(`Another install found: ${label}. Run "tmuxclihook update --prune" in a terminal to remove it.`);
211
+ continue;
212
+ }
213
+ const yes = await confirm(`Remove the non-effective ${label}?`);
214
+ if (!yes) {
215
+ log.log(`Keeping ${label}.`);
216
+ continue;
217
+ }
218
+ }
219
+
220
+ if (await uninstallStale(item, run)) {
221
+ log.log(`Removed ${label}.`);
222
+ removed = true;
223
+ } else {
224
+ log.error(`Failed to remove ${label}.`);
225
+ }
226
+ }
227
+ return removed;
228
+ }
229
+
34
230
  // npm 布局下入口是 .../lib/node_modules/<pkg>/bin/cli.js(或其 symlink):
35
231
  // realpath 后向上最多 3 层找 name 匹配的 package.json 读版本。版本管理器
36
232
  // shim 是独立二进制,向上找不到包 → 返回 null。
@@ -94,14 +290,11 @@ function warnNotEffective(log, result) {
94
290
  log.error(`If you use volta, run: volta install ${PACKAGE_NAME}@latest`);
95
291
  }
96
292
 
97
- // volta 生效修正:`npm install -g` 只写 node 工具链目录,不更新 volta 的
98
- // shim 镜像(实测 volta 2.0.2:shim npm 的全局 prefix 就是工具链目录),
99
- // 用户的命令经 ~/.volta/bin shim 解析时仍是旧版。因此只要检测到 volta
100
- // 就无条件补一次 volta install 完成生效(幂等,已是最新时秒回)。不走
101
- // 「自检失败才修」的条件路径:自检按 update 进程自己的 PATH 找第一份,
102
- // 可能恰好命中 npm -g 刚写的那份而误判 ok(实测 0.3.3 → 0.3.4:verify
103
- // 判 ok,修复从未触发,用户命令仍是旧版)。npm install -g 是安装语义,
104
- // volta install 是生效语义,两者都执行;用户无需改变安装习惯。
293
+ // volta 安装路径:当 PATH 上的生效入口来自 volta(shim 或 packages 镜像)
294
+ // 时,升级只能走 `volta install`——`npm install -g` 只写 node 工具链目录,
295
+ // 不会更新 volta 的 packages 镜像。此函数现在是 volta 分支的唯一升级动作,
296
+ // 不再作为 npm 安装后的无条件补丁(双写会制造两份副本并引发漂移)。
297
+ // volta 不可执行时返回 false,由调用方决定如何报告。
105
298
  export async function repairWithVolta(run, log, env = process.env) {
106
299
  const voltaHome = env.VOLTA_HOME || join(env.HOME || '', '.volta');
107
300
  const voltaBin = findOnPath('volta', env) || join(voltaHome, 'bin', 'volta');
@@ -124,9 +317,21 @@ export async function updatePackage(options = {}) {
124
317
  const run = options.run || execa;
125
318
  const log = options.log || console;
126
319
  const currentVersion = options.currentVersion || CURRENT_VERSION;
320
+ const env = options.env || process.env;
127
321
  // 显式传 null 跳过自检/修正(测试用);默认真实现
128
322
  const verify = options.verify === undefined ? verifyEffectiveVersion : options.verify;
129
323
  const repairVolta = options.repairVolta === undefined ? repairWithVolta : options.repairVolta;
324
+ const detectSource = options.detectSource === undefined ? detectInstallSource : options.detectSource;
325
+ // 非生效副本收敛:默认开启,TTY 下交互确认;--prune 跳过确认。
326
+ const cleanup = options.cleanup !== false;
327
+ const prune = options.prune === true;
328
+ const isTTY = options.isTTY === undefined
329
+ ? Boolean(process.stdin.isTTY && process.stdout.isTTY)
330
+ : options.isTTY;
331
+ const confirm = options.confirm || defaultConfirm;
332
+ const detectStale = options.detectStale || detectStaleInstalls;
333
+ const checkHooks = options.checkHooks || hooksReference;
334
+ const checkTmuxConf = options.checkTmuxConf || tmuxConfReferences;
130
335
 
131
336
  log.log(`Current version: v${currentVersion}`);
132
337
 
@@ -145,28 +350,61 @@ export async function updatePackage(options = {}) {
145
350
  }
146
351
 
147
352
  log.log(`Updating to v${latestVersion}...`);
148
- try {
149
- await run('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], { stdio: 'inherit' });
150
- } catch {
151
- return false;
353
+
354
+ // 跟随当前生效入口:PATH 上第一个 tmuxclihook 属于哪套机制,就只升级那套。
355
+ // volta 入口走 volta install(npm -g 写不到 packages 镜像);其余入口走 npm -g。
356
+ // 不再双写——那会留下两份同版本副本,配置指向一份而命令执行另一份即产生漂移。
357
+ const source = typeof detectSource === 'function' ? detectSource(env) : 'unknown';
358
+
359
+ if (source === 'volta') {
360
+ if (repairVolta) {
361
+ const ok = await repairVolta(run, log, env);
362
+ if (!ok) {
363
+ log.error('Failed to update via volta. Please check your volta installation.');
364
+ return false;
365
+ }
366
+ }
367
+ } else {
368
+ try {
369
+ await run('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], { stdio: 'inherit' });
370
+ } catch {
371
+ return false;
372
+ }
152
373
  }
153
374
 
154
375
  log.log(`Done. Updated to v${latestVersion}`);
155
376
 
156
- // 生效收敛:有 volta 就无条件跑(幂等;无 volta 时静默跳过,纯 npm 场景
157
- // npm -g 本身已生效)。任何命令行执行 update 都能升级生效的关键。
158
- if (repairVolta) {
159
- await repairVolta(run, log);
377
+ if (!verify) return true;
378
+
379
+ // 事后确认(尽力而为):正常情况下生效入口应已是新版。
380
+ let result = await verify(latestVersion, env);
381
+
382
+ // 兜底:非 volta 来源、自检未过、且本机确有 volta 时补一次并复检。
383
+ // 仅在异常场景触发(例如 npm 全局 bin 不在 PATH),保持「不双写」语义。
384
+ if (result.status !== 'ok' && source !== 'volta' && repairVolta) {
385
+ const repaired = await repairVolta(run, log, env);
386
+ if (repaired) result = await verify(latestVersion, env);
160
387
  }
161
388
 
162
- if (verify) {
163
- // 事后确认(尽力而为):两处落点都应已是新版,命中哪份都该 ok;
164
- // 不 ok(如 PATH 上另有第三份旧安装)只警告,不影响返回值。
165
- const result = await verify(latestVersion);
166
- if (result.status === 'ok') {
167
- log.log(`Verified: tmuxclihook on your PATH now resolves to v${latestVersion}.`);
168
- } else {
169
- warnNotEffective(log, result);
389
+ if (result.status === 'ok') {
390
+ log.log(`Verified: tmuxclihook on your PATH now resolves to v${latestVersion}.`);
391
+ } else {
392
+ warnNotEffective(log, result);
393
+ }
394
+
395
+ // 升级已确认成功后再收敛历史副本;清理失败不影响升级结果,也不改变退出码。
396
+ if (cleanup && result.status === 'ok') {
397
+ try {
398
+ const removed = await cleanupStale({
399
+ source, env, run, log, prune, isTTY, confirm, checkHooks, checkTmuxConf, detectStale,
400
+ });
401
+ // 删过东西就再确认一次生效入口,避免误删导致命令不可用。
402
+ if (removed && verify) {
403
+ const after = await verify(latestVersion, env);
404
+ if (after.status !== 'ok') warnNotEffective(log, after);
405
+ }
406
+ } catch (err) {
407
+ log.error(`Cleanup skipped: ${err}`);
170
408
  }
171
409
  }
172
410