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 +26 -1
- package/bin/cli.js +144 -14
- package/package.json +1 -1
- package/scripts/adapters/claude.sh +59 -0
- package/scripts/adapters/codex.sh +42 -1
- package/scripts/adapters/opencode.sh +28 -1
- package/scripts/install-claude-hooks.sh +6 -2
- package/scripts/install-codex-hooks.sh +5 -2
- package/scripts/install-opencode-hooks.sh +5 -2
- package/scripts/lib-install-hooks.sh +38 -0
- package/scripts/lib-tmux-ai-status.sh +8 -1
- package/scripts/tmux-ai-status +5 -4
- package/src/check-detail.js +159 -0
- package/src/esc.js +33 -0
- package/src/hooks.js +9 -3
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/scripts/tmux-ai-status
CHANGED
|
@@ -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
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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
|
-
|
|
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], {
|
|
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) };
|