tmuxclihook 0.1.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/LICENSE +21 -0
- package/README.md +71 -0
- package/bin/cli.js +274 -0
- package/package.json +51 -0
- package/src/adapters-meta.js +82 -0
- package/src/detect.js +124 -0
- package/src/env.js +112 -0
- package/src/hooks.js +42 -0
- package/src/purge.js +43 -0
- package/src/symlink.js +73 -0
- package/src/tmuxconf.js +43 -0
- package/src/tui.js +218 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mark liu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# tmux-ai-status 安装器
|
|
2
|
+
|
|
3
|
+
`tmuxclihook` 是 tmux-ai-hooks-status 的交互式 TUI,负责环境侦测/修复、AI CLI 侦测、hooks 装卸修、TPM 软链校验/创建,以及完整卸载编排。启动 TUI 本身需要 Node.js 18+;它不能在 Node.js 缺失时自举运行。
|
|
4
|
+
|
|
5
|
+
## 运行
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 完整功能:从仓库副本运行
|
|
9
|
+
cd installer
|
|
10
|
+
npm install
|
|
11
|
+
npm start
|
|
12
|
+
|
|
13
|
+
# 插件已经载入 tmux 后
|
|
14
|
+
prefix + I
|
|
15
|
+
|
|
16
|
+
# npm 入口(环境与 AI CLI 检查)
|
|
17
|
+
npx tmuxclihook
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
hooks 和 tmux 软链操作依赖仓库根目录中的 `scripts/` 与插件入口文件。npm 包当前适合独立运行环境/CLI 检查;完整安装操作应从仓库副本或 `prefix + I` 启动。
|
|
21
|
+
|
|
22
|
+
## 功能
|
|
23
|
+
|
|
24
|
+
| 菜单项 | 说明 |
|
|
25
|
+
|--------|------|
|
|
26
|
+
| 环境检查 | 侦测 tmux(≥3.1) / jq / bash(任意版本) / node(≥18),显示 `✓ ready`、`✗ missing`、`⚠ outdated`,并在确认后安全修复 |
|
|
27
|
+
| 侦测 AI CLI | 扫描 Claude Code / Codex / opencode,显示版本、最低版本和 hooks 状态;不自动安装或升级 AI CLI |
|
|
28
|
+
| 安装 hooks | 选工具(claude/codex/opencode/全部)→ 调 `scripts/install-<tool>-hooks.sh` |
|
|
29
|
+
| 卸载 hooks | 对称卸载本插件 hooks,保留其他工具注册的 hook |
|
|
30
|
+
| 修复 hooks | 完整性检查 → 缺失则重装 |
|
|
31
|
+
| tmux 软链 | 校验 `~/.tmux/plugins/` 是否有指向本仓库的软链(含旧名),缺失可创建;创建后若 `~/.tmux.conf` 未声明插件,可选追加 `@plugin` 并 `tmux source-file` 重载 |
|
|
32
|
+
| 完整卸载插件 | 停止 Codex monitor、清除聚合状态 `@ai_all_status`、删除插件软链(含旧名,不删仓库);`.tmux.conf` 删声明与 `tmux kill-server` 因破坏性仅打印手动指引 |
|
|
33
|
+
|
|
34
|
+
## 环境自动修复规则
|
|
35
|
+
|
|
36
|
+
Doctor 只通过 Homebrew 执行明确且可验证的操作:
|
|
37
|
+
|
|
38
|
+
- 缺失依赖:可选择 `brew install <formula>`;
|
|
39
|
+
- 版本过旧且 formula 已由 Homebrew 管理:可选择 `brew upgrade <formula>`;
|
|
40
|
+
- 版本过旧但不是 Homebrew 管理:只显示手动升级建议;
|
|
41
|
+
- 未检测到 Homebrew:只显示手动建议,不自动安装 Homebrew;
|
|
42
|
+
- bash 已存在即视为满足要求,不主动升级 macOS 自带 bash;
|
|
43
|
+
- Claude Code / Codex CLI 始终只检测,不自动安装或升级;
|
|
44
|
+
- 单项修复失败不会阻止后续项目,执行后自动重新检查全部环境依赖。
|
|
45
|
+
|
|
46
|
+
## 架构
|
|
47
|
+
|
|
48
|
+
TUI 不重实现 bash hooks 逻辑——hooks 装/卸经 `execa` 调用薄 wrapper `scripts/install-<tool>-hooks.sh`(内部走 `scripts/adapters/<tool>.sh`)。工具元数据(bin/minVersion/hooksFile)在 `src/adapters-meta.js`,与 bash adapter 一一对应。
|
|
49
|
+
|
|
50
|
+
环境职责分离:
|
|
51
|
+
|
|
52
|
+
- `src/detect.js`:只探测并分类 `ready` / `missing` / `outdated`;
|
|
53
|
+
- `src/env.js`:检查 Homebrew 所有权、生成 install/upgrade/manual 计划并执行白名单动作;
|
|
54
|
+
- `bin/cli.js`:确认选择、逐项执行、报告失败并复检。
|
|
55
|
+
|
|
56
|
+
新增 AI CLI:在 `src/adapters-meta.js` 加一条元数据,并增加对应 bash adapter/wrapper。
|
|
57
|
+
|
|
58
|
+
## 开发与测试
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm run check
|
|
62
|
+
npm test
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
测试通过依赖注入模拟 Homebrew,不会真的执行安装、升级或网络操作。
|
|
66
|
+
|
|
67
|
+
## 发布(npm)
|
|
68
|
+
|
|
69
|
+
由 GitHub Actions 自动发布:push 到 `main` 且 `installer/package.json` 的 `version` 比 npm 上已发布版本新时,`.github/workflows/publish-installer.yml` 自动执行 `npm publish --provenance --access public`、打 `installer-v<version>` tag 并创建 Release。
|
|
70
|
+
|
|
71
|
+
发布前置:仓库 Settings → Secrets 配置 `NPM_TOKEN`(npm automation token)。手动发布版本:修改 `package.json` 的 `version` 后提交并 push 到 main。
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli.js — tmux-ai-status 交互式安装 TUI(@clack/prompts)
|
|
3
|
+
//
|
|
4
|
+
// 主菜单:环境检查 / 侦测 CLI / 安装 / 卸载 / 修复 hooks / tmux 软链。
|
|
5
|
+
// 实际 hooks 逻辑委托 bash wrapper(scripts/install-<tool>-hooks.sh)。
|
|
6
|
+
|
|
7
|
+
import * as p from '@clack/prompts';
|
|
8
|
+
import { checkEnv, detectClis, interactiveShell } from '../src/detect.js';
|
|
9
|
+
import { planBrewRemediation, runBrewAction } from '../src/env.js';
|
|
10
|
+
import { installHooks, uninstallHooks, repairHooks } from '../src/hooks.js';
|
|
11
|
+
import { checkSymlink, createSymlink, checkTmuxConf } from '../src/symlink.js';
|
|
12
|
+
import { appendPluginDeclaration, reloadTmux } from '../src/tmuxconf.js';
|
|
13
|
+
import { stopMonitor, clearAggregateStatus, removeSymlinks } from '../src/purge.js';
|
|
14
|
+
import { TOOLS, repoExists, REPO_ROOT } from '../src/adapters-meta.js';
|
|
15
|
+
import { note, padDisplay } from '../src/tui.js';
|
|
16
|
+
|
|
17
|
+
const OK = '✓';
|
|
18
|
+
const NO = '✗';
|
|
19
|
+
|
|
20
|
+
function renderEnvResults(results, title = '环境依赖') {
|
|
21
|
+
const lines = results.map((result) => {
|
|
22
|
+
const mark = result.status === 'ready' ? OK : result.status === 'outdated' ? '⚠' : NO;
|
|
23
|
+
return ` ${mark} ${padDisplay(result.name, 6)} ${padDisplay(result.detail, 16)} (要求 ${result.min})`;
|
|
24
|
+
});
|
|
25
|
+
note(lines.join('\n'), title);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function doctor() {
|
|
29
|
+
const s = p.spinner();
|
|
30
|
+
s.start('检查环境依赖');
|
|
31
|
+
const results = await checkEnv();
|
|
32
|
+
s.stop('环境检查完成');
|
|
33
|
+
|
|
34
|
+
renderEnvResults(results);
|
|
35
|
+
|
|
36
|
+
// 交互 shell 说明:消除「我用 zsh 但 Doctor 检查 bash」的困惑
|
|
37
|
+
const sh = interactiveShell();
|
|
38
|
+
note(
|
|
39
|
+
` 当前交互 shell:${sh.name}(${sh.path})\n` +
|
|
40
|
+
` 说明:hook 脚本 shebang 为 #!/bin/bash,由 AI CLI 触发时以 bash 执行,\n` +
|
|
41
|
+
` 与你的交互 shell(${sh.name})无关。macOS 自带 bash 3.2 即可,无需升级。`,
|
|
42
|
+
'shell 说明',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const failed = results.filter((result) => result.status !== 'ready');
|
|
46
|
+
if (failed.length === 0) {
|
|
47
|
+
p.log.success('所有依赖就绪');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const plan = await planBrewRemediation(results);
|
|
52
|
+
const manual = plan.filter((entry) => entry.action === 'manual');
|
|
53
|
+
const actionable = plan.filter((entry) => entry.action !== 'manual');
|
|
54
|
+
|
|
55
|
+
if (manual.length > 0) {
|
|
56
|
+
p.log.warn('以下依赖不会自动修改:');
|
|
57
|
+
manual.forEach((entry) => {
|
|
58
|
+
p.log.message(` ${entry.name}:${entry.reason}`);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (actionable.length === 0) {
|
|
63
|
+
p.log.info('当前没有可由 Homebrew 自动修复的依赖');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const entryByKey = new Map(actionable.map((entry) => [
|
|
68
|
+
`${entry.action}:${entry.pkg}`,
|
|
69
|
+
entry,
|
|
70
|
+
]));
|
|
71
|
+
const pick = await p.multiselect({
|
|
72
|
+
message: '选择要自动修复的环境依赖',
|
|
73
|
+
options: actionable.map((entry) => ({
|
|
74
|
+
value: `${entry.action}:${entry.pkg}`,
|
|
75
|
+
label: `${entry.name} — ${entry.command}`,
|
|
76
|
+
})),
|
|
77
|
+
required: false,
|
|
78
|
+
});
|
|
79
|
+
if (p.isCancel(pick) || !pick || pick.length === 0) {
|
|
80
|
+
p.log.info('跳过自动修复。可手动执行:');
|
|
81
|
+
actionable.forEach((entry) => p.log.message(` ${entry.command}`));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const key of pick) {
|
|
86
|
+
const entry = entryByKey.get(key);
|
|
87
|
+
if (!entry) continue;
|
|
88
|
+
|
|
89
|
+
const s2 = p.spinner();
|
|
90
|
+
s2.start(entry.command);
|
|
91
|
+
const result = await runBrewAction(entry.action, entry.pkg);
|
|
92
|
+
if (result.ok) s2.stop(`${OK} ${entry.name} 修复命令执行完成`);
|
|
93
|
+
else s2.stop(`${NO} ${entry.name} 修复失败: ${result.output}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const verifySpinner = p.spinner();
|
|
97
|
+
verifySpinner.start('重新检查环境依赖');
|
|
98
|
+
const finalResults = await checkEnv();
|
|
99
|
+
verifySpinner.stop('环境复检完成');
|
|
100
|
+
renderEnvResults(finalResults, '修复后环境');
|
|
101
|
+
|
|
102
|
+
if (finalResults.every((result) => result.status === 'ready')) {
|
|
103
|
+
p.log.success('所有依赖就绪');
|
|
104
|
+
} else {
|
|
105
|
+
p.log.warn('仍有依赖需要处理,请查看上方复检结果');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function detectCliMenu() {
|
|
110
|
+
const s = p.spinner();
|
|
111
|
+
s.start('侦测已安装的 AI CLI');
|
|
112
|
+
const rows = await detectClis();
|
|
113
|
+
s.stop('侦测完成');
|
|
114
|
+
|
|
115
|
+
const lines = rows.map((r) => {
|
|
116
|
+
if (!r.installed) return ` ${NO} ${padDisplay(r.label, 14)} 未安装`;
|
|
117
|
+
const verMark = r.meetsMin ? OK : '⚠';
|
|
118
|
+
const minNote = r.minVersion && !r.meetsMin ? ` (需 ≥ ${r.minVersion})` : '';
|
|
119
|
+
const hookNote = r.hooksInstalled ? '已装 hooks' : '未装 hooks';
|
|
120
|
+
return ` ${verMark} ${padDisplay(r.label, 14)} v${r.version}${minNote} ${hookNote}`;
|
|
121
|
+
});
|
|
122
|
+
note(lines.join('\n'), 'AI CLI');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function pickTool(message) {
|
|
126
|
+
const rows = await detectClis();
|
|
127
|
+
const opts = TOOLS.map((t) => {
|
|
128
|
+
const row = rows.find((r) => r.id === t.id);
|
|
129
|
+
const hint = !row?.installed ? '未安装' : row.hooksInstalled ? '已装 hooks' : '未装 hooks';
|
|
130
|
+
return { value: t.id, label: t.label, hint };
|
|
131
|
+
});
|
|
132
|
+
opts.push({ value: '__all__', label: '全部', hint: TOOLS.map((t) => t.id).join(' + ') });
|
|
133
|
+
opts.push({ value: '__back__', label: '← 返回主菜单' });
|
|
134
|
+
const sel = await p.select({ message, options: opts });
|
|
135
|
+
return p.isCancel(sel) || sel === '__back__' ? null : sel;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function runHookAction(action, verb) {
|
|
139
|
+
const sel = await pickTool(`选择要${verb}的工具(Esc 返回)`);
|
|
140
|
+
if (!sel) { p.log.info('返回主菜单'); return; }
|
|
141
|
+
const ids = sel === '__all__' ? TOOLS.map((t) => t.id) : [sel];
|
|
142
|
+
for (const id of ids) {
|
|
143
|
+
const s = p.spinner();
|
|
144
|
+
s.start(`${verb} ${id} hooks`);
|
|
145
|
+
const r = await action(id);
|
|
146
|
+
if (r.ok) {
|
|
147
|
+
s.stop(`${OK} ${id} ${verb}完成`);
|
|
148
|
+
if (r.output) p.log.message(r.output.trim());
|
|
149
|
+
} else {
|
|
150
|
+
s.stop(`${NO} ${id} ${verb}失败`);
|
|
151
|
+
p.log.error(r.output || '未知错误');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function symlinkMenu() {
|
|
157
|
+
const st = checkSymlink();
|
|
158
|
+
const conf = checkTmuxConf();
|
|
159
|
+
const lines = [
|
|
160
|
+
` 软链 ${PLUGIN_LINE(st)}`,
|
|
161
|
+
` .tmux.conf: ${conf.exists ? (conf.referenced ? `${OK} 已引用插件` : `${NO} 未引用(需手动加 @plugin 或 run)`) : `${NO} 不存在`}`,
|
|
162
|
+
];
|
|
163
|
+
note(lines.join('\n'), 'tmux 软链');
|
|
164
|
+
|
|
165
|
+
// 步骤 1:软链缺失/错误则询问创建
|
|
166
|
+
if (st.status !== 'ok') {
|
|
167
|
+
const go = await p.confirm({ message: `创建软链 ${PLUGIN_LINK_PATH()} → 仓库?` });
|
|
168
|
+
if (p.isCancel(go) || !go) return;
|
|
169
|
+
const r = createSymlink();
|
|
170
|
+
if (r.ok) p.log.success(`${OK} 软链已创建:${r.from} → ${r.to}`);
|
|
171
|
+
else { p.log.error(`${NO} 创建失败:${r.output}`); return; }
|
|
172
|
+
} else {
|
|
173
|
+
p.log.success('软链正常');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 步骤 2:.tmux.conf 未引用插件则询问追加声明 + reload(闭合"软链建好但没加载")
|
|
177
|
+
if (!conf.referenced) {
|
|
178
|
+
const decl = await p.confirm({
|
|
179
|
+
message: `.tmux.conf 未声明插件,追加 set -g @plugin 'tmux-ai-hooks-status' 并重载?`,
|
|
180
|
+
});
|
|
181
|
+
if (p.isCancel(decl) || !decl) return;
|
|
182
|
+
const a = appendPluginDeclaration();
|
|
183
|
+
if (!a.ok) { p.log.error(`${NO} 写入 .tmux.conf 失败:${a.output}`); return; }
|
|
184
|
+
if (a.appended) p.log.success(`${OK} 已追加插件声明到 ${a.path}`);
|
|
185
|
+
else p.log.info('已引用插件,跳过写入');
|
|
186
|
+
const rl = await reloadTmux();
|
|
187
|
+
if (rl.ok) p.log.success(`${OK} 已重载 tmux 配置`);
|
|
188
|
+
else p.log.warn(`重载失败(tmux 可能未运行):${rl.output}\n请手动执行 tmux source-file ~/.tmux.conf`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function purgeMenu() {
|
|
193
|
+
p.log.warn('完整卸载:停止 monitor、清聚合状态、删软链。hooks 请先在「卸载 hooks」菜单移除。');
|
|
194
|
+
const go = await p.confirm({ message: '继续完整卸载插件运行态?(不删仓库、不改 .tmux.conf、不 kill-server)' });
|
|
195
|
+
if (p.isCancel(go) || !go) { p.log.info('已取消'); return; }
|
|
196
|
+
|
|
197
|
+
const mon = await stopMonitor();
|
|
198
|
+
if (mon.ok) p.log.success(`${OK} 已停止 Codex monitor`);
|
|
199
|
+
else p.log.warn(`monitor stop 失败(可能未运行):${mon.output}`);
|
|
200
|
+
|
|
201
|
+
const clr = await clearAggregateStatus();
|
|
202
|
+
if (clr.ok) p.log.success(`${OK} 已清除 @ai_all_status`);
|
|
203
|
+
else p.log.warn(`清除聚合状态失败(tmux 可能未运行):${clr.output}`);
|
|
204
|
+
|
|
205
|
+
const rm = removeSymlinks();
|
|
206
|
+
if (rm.removed.length > 0) p.log.success(`${OK} 已删除软链:${rm.removed.join(', ')}`);
|
|
207
|
+
else p.log.info('无软链可删除');
|
|
208
|
+
|
|
209
|
+
note(
|
|
210
|
+
" 以下破坏性步骤请手动执行:\n" +
|
|
211
|
+
" 1. 从 ~/.tmux.conf 删除:set -g @plugin 'tmux-ai-hooks-status'\n" +
|
|
212
|
+
" 2. 重启 tmux server 使状态行/hooks/按键完全卸载:tmux kill-server\n" +
|
|
213
|
+
" (kill-server 会关闭当前所有 session,请先保存工作)",
|
|
214
|
+
'手动收尾',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function PLUGIN_LINE(st) {
|
|
219
|
+
if (st.status === 'ok') return `${OK} ${st.note || '指向本仓库'}`;
|
|
220
|
+
if (st.status === 'missing') return `${NO} 不存在`;
|
|
221
|
+
return `${NO} 错误(${st.reason || '指向其他位置'})`;
|
|
222
|
+
}
|
|
223
|
+
function PLUGIN_LINK_PATH() {
|
|
224
|
+
return '~/.tmux/plugins/tmux-ai-hooks-status';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function main() {
|
|
228
|
+
p.intro('tmux-ai-status 安装器');
|
|
229
|
+
|
|
230
|
+
if (!repoExists) {
|
|
231
|
+
p.log.warn(`未定位到仓库 scripts/(期望 ${REPO_ROOT})。若通过 npx 运行,请在仓库内或已装插件目录下运行。`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
while (true) {
|
|
235
|
+
const choice = await p.select({
|
|
236
|
+
message: '选择操作(Esc 在子菜单返回主页面)',
|
|
237
|
+
options: [
|
|
238
|
+
{ value: 'doctor', label: '环境检查', hint: 'tmux / jq / bash / node' },
|
|
239
|
+
{ value: 'detect', label: '侦测 AI CLI', hint: '版本 + hooks 状态' },
|
|
240
|
+
{ value: 'install', label: '安装 hooks' },
|
|
241
|
+
{ value: 'uninstall', label: '卸载 hooks' },
|
|
242
|
+
{ value: 'repair', label: '修复 hooks', hint: '完整性检查 + 缺失重装' },
|
|
243
|
+
{ value: 'symlink', label: 'tmux 软链校验' },
|
|
244
|
+
{ value: 'purge', label: '完整卸载插件', hint: 'monitor stop + 清状态 + 删软链' },
|
|
245
|
+
{ value: 'exit', label: '退出' },
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
// 主菜单是顶层:Esc(isCancel)不退出程序,留在主菜单;退出仅走「退出」项。
|
|
249
|
+
if (p.isCancel(choice)) {
|
|
250
|
+
p.log.info('已在主菜单(退出请选「退出」项)');
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (choice === 'exit') break;
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
if (choice === 'doctor') await doctor();
|
|
257
|
+
else if (choice === 'detect') await detectCliMenu();
|
|
258
|
+
else if (choice === 'install') await runHookAction(installHooks, '安装');
|
|
259
|
+
else if (choice === 'uninstall') await runHookAction(uninstallHooks, '卸载');
|
|
260
|
+
else if (choice === 'repair') await runHookAction(repairHooks, '修复');
|
|
261
|
+
else if (choice === 'symlink') await symlinkMenu();
|
|
262
|
+
else if (choice === 'purge') await purgeMenu();
|
|
263
|
+
} catch (err) {
|
|
264
|
+
p.log.error(String(err));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
p.outro('完成');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
main().catch((err) => {
|
|
272
|
+
console.error(err);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tmuxclihook",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "交互式 TUI 安装器:环境侦测、AI CLI 侦测、tmux-ai-status hooks 装/卸/修、TPM 软链校验",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tmuxclihook": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"check": "for f in bin/cli.js src/*.js; do node --check \"$f\"; done && echo 'all js syntax OK'",
|
|
16
|
+
"start": "node bin/cli.js",
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@clack/prompts": "^0.7.0",
|
|
24
|
+
"execa": "^9.0.0",
|
|
25
|
+
"picocolors": "^1.1.1"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"tmux",
|
|
29
|
+
"claude-code",
|
|
30
|
+
"codex",
|
|
31
|
+
"opencode",
|
|
32
|
+
"hooks",
|
|
33
|
+
"installer",
|
|
34
|
+
"tui"
|
|
35
|
+
],
|
|
36
|
+
"author": "liu ziyuan",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/liuziyuan/tmux-claude-hooks-status",
|
|
41
|
+
"directory": "installer"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/liuziyuan/tmux-claude-hooks-status/tree/main/installer#readme",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/liuziyuan/tmux-claude-hooks-status/issues"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public",
|
|
49
|
+
"provenance": true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// adapters-meta.js — AI CLI 工具元数据 + 仓库路径定位
|
|
2
|
+
//
|
|
3
|
+
// TUI 侧的工具清单(对应 scripts/adapters/*.sh)。新增 CLI 时在此加一条 + 加 bash adapter。
|
|
4
|
+
// bin/minVersion/hooksFile 用于侦测;installer 指向瘦 wrapper(真实逻辑在 bash adapter)。
|
|
5
|
+
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
// installer/ 的上一级即仓库根
|
|
14
|
+
export const REPO_ROOT = resolve(__dirname, '..', '..');
|
|
15
|
+
export const SCRIPTS_DIR = join(REPO_ROOT, 'scripts');
|
|
16
|
+
|
|
17
|
+
const HOME = homedir();
|
|
18
|
+
|
|
19
|
+
// 工具元数据表。version 解析用正则从 `<bin> --version` 输出提取。
|
|
20
|
+
export const TOOLS = [
|
|
21
|
+
{
|
|
22
|
+
id: 'claude',
|
|
23
|
+
label: 'Claude Code',
|
|
24
|
+
bin: 'claude',
|
|
25
|
+
versionArgs: ['--version'],
|
|
26
|
+
versionRe: /(\d+\.\d+\.\d+)/, // "2.1.210 (Claude Code)"
|
|
27
|
+
minVersion: null, // 无硬性最低要求
|
|
28
|
+
hooksFile: join(process.env.CLAUDE_CONFIG_DIR || join(HOME, '.claude'), 'settings.json'),
|
|
29
|
+
installer: join(SCRIPTS_DIR, 'install-claude-hooks.sh'),
|
|
30
|
+
hookMarker: 'tmux-ai-status',
|
|
31
|
+
minHookCount: 10,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'codex',
|
|
35
|
+
label: 'Codex CLI',
|
|
36
|
+
bin: 'codex',
|
|
37
|
+
versionArgs: ['--version'],
|
|
38
|
+
versionRe: /(\d+\.\d+\.\d+)/, // "codex-cli 0.144.4"
|
|
39
|
+
minVersion: '0.144.0', // hooks.json + trust 起于 0.144
|
|
40
|
+
hooksFile: join(process.env.CODEX_HOME || join(HOME, '.codex'), 'hooks.json'),
|
|
41
|
+
installer: join(SCRIPTS_DIR, 'install-codex-hooks.sh'),
|
|
42
|
+
hookMarker: 'tmux-ai-status codex',
|
|
43
|
+
minHookCount: 6,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'opencode',
|
|
47
|
+
label: 'opencode',
|
|
48
|
+
bin: 'opencode',
|
|
49
|
+
versionArgs: ['--version'],
|
|
50
|
+
versionRe: /(\d+\.\d+\.\d+)/,
|
|
51
|
+
minVersion: null, // plugin 系统本地文件跳过版本兼容检查
|
|
52
|
+
// hooksFile 实为 JS/TS plugin 文件(非 JSON hooks 配置),install/uninstall 整文件写入/删除
|
|
53
|
+
hooksFile: join(
|
|
54
|
+
process.env.OPENCODE_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(HOME, '.config'), 'opencode'),
|
|
55
|
+
'plugins',
|
|
56
|
+
'tmux-ai-status.ts'
|
|
57
|
+
),
|
|
58
|
+
installer: join(SCRIPTS_DIR, 'install-opencode-hooks.sh'),
|
|
59
|
+
hookMarker: 'tmux-ai-status (managed by tmux-ai-hooks-status)',
|
|
60
|
+
minHookCount: 1,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export function toolById(id) {
|
|
65
|
+
return TOOLS.find((t) => t.id === id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 语义版本比较:a >= b ? 返回 true。仅比较 x.y.z 三段。
|
|
69
|
+
export function versionGte(a, b) {
|
|
70
|
+
if (!a || !b) return true;
|
|
71
|
+
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
|
72
|
+
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
|
73
|
+
for (let i = 0; i < 3; i++) {
|
|
74
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
75
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// TPM 软链路径(仓库目录名)
|
|
81
|
+
export const PLUGIN_LINK = join(HOME, '.tmux', 'plugins', 'tmux-ai-hooks-status');
|
|
82
|
+
export const repoExists = existsSync(SCRIPTS_DIR);
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// detect.js — 环境依赖侦测 + AI CLI 侦测
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { TOOLS, versionGte } from './adapters-meta.js';
|
|
5
|
+
|
|
6
|
+
// 探测某命令是否存在 + 版本
|
|
7
|
+
async function probe(bin, args, re) {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await execa(bin, args, { reject: false, timeout: 8000 });
|
|
10
|
+
const m = re ? String(stdout).match(re) : null;
|
|
11
|
+
return { found: true, version: m ? m[1] : null, raw: String(stdout).trim() };
|
|
12
|
+
} catch {
|
|
13
|
+
return { found: false, version: null, raw: null };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function envResult({ name, found, meetsMinimum, detail, min, fixCmd, pkg }) {
|
|
18
|
+
const status = !found ? 'missing' : meetsMinimum ? 'ready' : 'outdated';
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
status,
|
|
22
|
+
ok: status === 'ready',
|
|
23
|
+
detail,
|
|
24
|
+
min,
|
|
25
|
+
fixCmd,
|
|
26
|
+
pkg,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 环境依赖检查(Doctor)。返回逐项结果。
|
|
31
|
+
export async function checkEnv({ probeCommand = probe } = {}) {
|
|
32
|
+
const results = [];
|
|
33
|
+
|
|
34
|
+
// tmux ≥ 3.1
|
|
35
|
+
const tmux = await probeCommand('tmux', ['-V'], /(\d+\.\d+)/);
|
|
36
|
+
results.push(envResult({
|
|
37
|
+
name: 'tmux',
|
|
38
|
+
found: tmux.found,
|
|
39
|
+
meetsMinimum: versionGte((tmux.version || '0') + '.0', '3.1.0'),
|
|
40
|
+
detail: tmux.found ? `v${tmux.version}` : '未安装',
|
|
41
|
+
min: '≥ 3.1',
|
|
42
|
+
fixCmd: 'brew install tmux',
|
|
43
|
+
pkg: 'tmux',
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// jq(唯一硬依赖)
|
|
47
|
+
const jq = await probeCommand('jq', ['--version'], /(\d+\.\d+)/);
|
|
48
|
+
results.push(envResult({
|
|
49
|
+
name: 'jq',
|
|
50
|
+
found: jq.found,
|
|
51
|
+
meetsMinimum: jq.found,
|
|
52
|
+
detail: jq.found ? (jq.raw || '已安装') : '未安装',
|
|
53
|
+
min: '任意版本',
|
|
54
|
+
fixCmd: 'brew install jq',
|
|
55
|
+
pkg: 'jq',
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// bash:脚本 shebang 为 #!/bin/bash,实测零 bash4 专属特性,macOS 自带 3.2 即可运行。
|
|
59
|
+
// 只要存在可执行 bash 即通过(不校验版本)。
|
|
60
|
+
const bash = await probeCommand('bash', ['--version'], /version (\d+\.\d+)/);
|
|
61
|
+
results.push(envResult({
|
|
62
|
+
name: 'bash',
|
|
63
|
+
found: bash.found,
|
|
64
|
+
meetsMinimum: bash.found,
|
|
65
|
+
detail: bash.found ? `v${bash.version}` : '未安装',
|
|
66
|
+
min: '任意版本',
|
|
67
|
+
fixCmd: 'brew install bash',
|
|
68
|
+
pkg: 'bash',
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
// node(TUI 自身运行环境,必在)
|
|
72
|
+
const node = await probeCommand('node', ['-v'], /v?(\d+\.\d+\.\d+)/);
|
|
73
|
+
results.push(envResult({
|
|
74
|
+
name: 'node',
|
|
75
|
+
found: node.found,
|
|
76
|
+
meetsMinimum: versionGte(node.version || '0.0.0', '18.0.0'),
|
|
77
|
+
detail: node.found ? `v${node.version}` : '未安装',
|
|
78
|
+
min: '≥ 18',
|
|
79
|
+
fixCmd: 'brew install node',
|
|
80
|
+
pkg: 'node',
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 当前交互 shell(用于 Doctor 说明:hook 用 bash 执行,与交互 shell 无关)。
|
|
87
|
+
export function interactiveShell() {
|
|
88
|
+
const sh = process.env.SHELL || '';
|
|
89
|
+
const name = sh.split('/').pop() || '未知';
|
|
90
|
+
return { path: sh || '未知', name };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 侦测某工具是否已装本插件 hooks
|
|
94
|
+
function hooksInstalled(tool) {
|
|
95
|
+
try {
|
|
96
|
+
if (!existsSync(tool.hooksFile)) return false;
|
|
97
|
+
const raw = readFileSync(tool.hooksFile, 'utf8');
|
|
98
|
+
const count = (raw.match(new RegExp(tool.hookMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
|
99
|
+
return count >= tool.minHookCount;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 侦测所有已装 AI CLI
|
|
106
|
+
export async function detectClis() {
|
|
107
|
+
const rows = [];
|
|
108
|
+
for (const tool of TOOLS) {
|
|
109
|
+
const p = await probe(tool.bin, tool.versionArgs, tool.versionRe);
|
|
110
|
+
const meetsMin = p.found ? versionGte(p.version || '0.0.0', tool.minVersion || '0.0.0') : false;
|
|
111
|
+
rows.push({
|
|
112
|
+
id: tool.id,
|
|
113
|
+
label: tool.label,
|
|
114
|
+
installed: p.found,
|
|
115
|
+
version: p.version,
|
|
116
|
+
meetsMin,
|
|
117
|
+
minVersion: tool.minVersion,
|
|
118
|
+
hooksInstalled: p.found ? hooksInstalled(tool) : false,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return rows;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { hooksInstalled };
|
package/src/env.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// env.js — 环境依赖修复(仅限 Homebrew 安全安装/升级)
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
|
|
4
|
+
const BREW_ACTIONS = new Set(['install', 'upgrade']);
|
|
5
|
+
|
|
6
|
+
// brew 是否可用。命令存在但退出非 0 也视为不可用。
|
|
7
|
+
export async function hasBrew({ runner = execa } = {}) {
|
|
8
|
+
try {
|
|
9
|
+
const result = await runner('brew', ['--version'], { reject: false, timeout: 5000 });
|
|
10
|
+
return result.exitCode === 0;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// formula 是否由当前 Homebrew 安装并管理。
|
|
17
|
+
export async function brewManages(pkg, { runner = execa } = {}) {
|
|
18
|
+
try {
|
|
19
|
+
const result = await runner('brew', ['list', '--versions', pkg], {
|
|
20
|
+
reject: false,
|
|
21
|
+
timeout: 8000,
|
|
22
|
+
});
|
|
23
|
+
return result.exitCode === 0;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 将 Doctor 结果转换成 install / upgrade / manual 计划。
|
|
30
|
+
export async function planBrewRemediation(
|
|
31
|
+
results,
|
|
32
|
+
{ brewAvailable, manages = (pkg) => brewManages(pkg) } = {},
|
|
33
|
+
) {
|
|
34
|
+
const available = brewAvailable ?? await hasBrew();
|
|
35
|
+
const plan = [];
|
|
36
|
+
|
|
37
|
+
for (const result of results) {
|
|
38
|
+
if (result.status === 'ready') continue;
|
|
39
|
+
|
|
40
|
+
if (!available) {
|
|
41
|
+
plan.push({
|
|
42
|
+
...result,
|
|
43
|
+
action: 'manual',
|
|
44
|
+
command: null,
|
|
45
|
+
reason: '未检测到 Homebrew,请使用原安装方式手动处理',
|
|
46
|
+
});
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (result.status === 'missing') {
|
|
51
|
+
plan.push({
|
|
52
|
+
...result,
|
|
53
|
+
action: 'install',
|
|
54
|
+
command: `brew install ${result.pkg}`,
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (result.status === 'outdated' && await manages(result.pkg)) {
|
|
60
|
+
plan.push({
|
|
61
|
+
...result,
|
|
62
|
+
action: 'upgrade',
|
|
63
|
+
command: `brew upgrade ${result.pkg}`,
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
plan.push({
|
|
69
|
+
...result,
|
|
70
|
+
action: 'manual',
|
|
71
|
+
command: null,
|
|
72
|
+
reason: `不是由 Homebrew 管理,请按原安装方式升级至 ${result.min}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return plan;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 执行经过白名单校验的 brew install/upgrade。返回 {ok, output?}。
|
|
80
|
+
export async function runBrewAction(
|
|
81
|
+
action,
|
|
82
|
+
pkg,
|
|
83
|
+
{ onLine, runner = execa } = {},
|
|
84
|
+
) {
|
|
85
|
+
if (!BREW_ACTIONS.has(action)) {
|
|
86
|
+
throw new Error(`Unsupported brew action: ${action}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const subprocess = runner('brew', [action, pkg]);
|
|
91
|
+
if (onLine) {
|
|
92
|
+
subprocess.stdout?.on('data', (data) => onLine(String(data)));
|
|
93
|
+
subprocess.stderr?.on('data', (data) => onLine(String(data)));
|
|
94
|
+
}
|
|
95
|
+
const result = await subprocess;
|
|
96
|
+
if (result.exitCode != null && result.exitCode !== 0) {
|
|
97
|
+
return { ok: false, output: result.stderr || result.stdout || `brew ${action} exited ${result.exitCode}` };
|
|
98
|
+
}
|
|
99
|
+
return { ok: true };
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { ok: false, output: err.shortMessage || String(err) };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 兼容旧调用方。
|
|
106
|
+
export async function brewInstall(pkg, onLine, options = {}) {
|
|
107
|
+
return runBrewAction('install', pkg, { ...options, onLine });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function brewUpgrade(pkg, onLine, options = {}) {
|
|
111
|
+
return runBrewAction('upgrade', pkg, { ...options, onLine });
|
|
112
|
+
}
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// hooks.js — 调 bash wrapper 完成 hooks 装/卸/修
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { toolById } from './adapters-meta.js';
|
|
5
|
+
import { hooksInstalled } from './detect.js';
|
|
6
|
+
|
|
7
|
+
// 安装某工具 hooks(调薄 wrapper install-<tool>-hooks.sh)
|
|
8
|
+
export async function installHooks(id) {
|
|
9
|
+
const tool = toolById(id);
|
|
10
|
+
if (!tool) return { ok: false, output: `未知工具: ${id}` };
|
|
11
|
+
if (!existsSync(tool.installer)) return { ok: false, output: `找不到安装脚本: ${tool.installer}` };
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await execa('bash', [tool.installer], { timeout: 30000 });
|
|
14
|
+
return { ok: true, output: stdout };
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return { ok: false, output: err.stderr || err.shortMessage || String(err) };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 卸载某工具 hooks
|
|
21
|
+
export async function uninstallHooks(id) {
|
|
22
|
+
const tool = toolById(id);
|
|
23
|
+
if (!tool) return { ok: false, output: `未知工具: ${id}` };
|
|
24
|
+
if (!existsSync(tool.installer)) return { ok: false, output: `找不到安装脚本: ${tool.installer}` };
|
|
25
|
+
try {
|
|
26
|
+
const { stdout } = await execa('bash', [tool.installer, 'uninstall'], { timeout: 30000 });
|
|
27
|
+
return { ok: true, output: stdout };
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return { ok: false, output: err.stderr || err.shortMessage || String(err) };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 修复某工具 hooks:完整性检查(JS 侧 hooksInstalled)→ 缺失则重装
|
|
34
|
+
export async function repairHooks(id) {
|
|
35
|
+
const tool = toolById(id);
|
|
36
|
+
if (!tool) return { ok: false, output: `未知工具: ${id}` };
|
|
37
|
+
if (hooksInstalled(tool)) {
|
|
38
|
+
return { ok: true, output: `${tool.label} hooks 完整,无需修复`, repaired: false };
|
|
39
|
+
}
|
|
40
|
+
const r = await installHooks(id);
|
|
41
|
+
return { ...r, repaired: r.ok };
|
|
42
|
+
}
|
package/src/purge.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// purge.js — 完整卸载编排:monitor stop + 清聚合状态 + 删软链(不删仓库/不动 conf/不 kill-server)
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { rmSync, lstatSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { PLUGIN_LINK, SCRIPTS_DIR } from './adapters-meta.js';
|
|
6
|
+
|
|
7
|
+
// 旧名软链(历史安装)
|
|
8
|
+
const PLUGIN_LINK_LEGACY = join(dirname(PLUGIN_LINK), 'tmux-claude-hooks-status');
|
|
9
|
+
|
|
10
|
+
// 停止 Codex lifecycle monitor
|
|
11
|
+
export async function stopMonitor() {
|
|
12
|
+
try {
|
|
13
|
+
await execa('bash', [join(SCRIPTS_DIR, 'tmux-ai-monitor'), 'stop'], { timeout: 10000 });
|
|
14
|
+
return { ok: true };
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return { ok: false, output: err.stderr || err.shortMessage || String(err) };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 清除聚合状态选项 @ai_all_status
|
|
21
|
+
export async function clearAggregateStatus() {
|
|
22
|
+
try {
|
|
23
|
+
await execa('tmux', ['set-option', '-gu', '@ai_all_status'], { timeout: 10000 });
|
|
24
|
+
return { ok: true };
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return { ok: false, output: err.stderr || err.shortMessage || String(err) };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 删除插件软链(canonical + 旧名);不删真实仓库
|
|
31
|
+
export function removeSymlinks() {
|
|
32
|
+
const removed = [];
|
|
33
|
+
for (const link of [PLUGIN_LINK, PLUGIN_LINK_LEGACY]) {
|
|
34
|
+
let present = false;
|
|
35
|
+
try { lstatSync(link); present = true; } catch { /* 不存在 */ }
|
|
36
|
+
if (!present) continue;
|
|
37
|
+
try {
|
|
38
|
+
rmSync(link, { force: true });
|
|
39
|
+
removed.push(link);
|
|
40
|
+
} catch { /* 忽略:权限等 */ }
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, removed };
|
|
43
|
+
}
|
package/src/symlink.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// symlink.js — TPM 软链校验/创建 + .tmux.conf 引用检查
|
|
2
|
+
import { existsSync, lstatSync, readlinkSync, symlinkSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { PLUGIN_LINK, REPO_ROOT } from './adapters-meta.js';
|
|
6
|
+
|
|
7
|
+
const PLUGINS_DIR = dirname(PLUGIN_LINK);
|
|
8
|
+
|
|
9
|
+
// 扫 ~/.tmux/plugins/ 找任何指向本仓库的软链(含旧名 tmux-claude-hooks-status)。
|
|
10
|
+
// 返回匹配的软链绝对路径,无则 null。
|
|
11
|
+
function findRepoLink() {
|
|
12
|
+
try {
|
|
13
|
+
for (const name of readdirSync(PLUGINS_DIR)) {
|
|
14
|
+
const full = join(PLUGINS_DIR, name);
|
|
15
|
+
try {
|
|
16
|
+
const st = lstatSync(full);
|
|
17
|
+
if (st.isSymbolicLink() && readlinkSync(full) === REPO_ROOT) return full;
|
|
18
|
+
// 真实目录且含入口文件也算(非软链安装)
|
|
19
|
+
if (st.isDirectory() && full === REPO_ROOT) return full;
|
|
20
|
+
} catch { /* skip */ }
|
|
21
|
+
}
|
|
22
|
+
} catch { /* plugins dir 不存在 */ }
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 检查软链状态:missing | wrong | ok
|
|
27
|
+
export function checkSymlink() {
|
|
28
|
+
// 先看是否已有任意指向本仓库的软链(含旧名)
|
|
29
|
+
const existing = findRepoLink();
|
|
30
|
+
if (existing) {
|
|
31
|
+
const note = existing === PLUGIN_LINK ? '指向本仓库' : `指向本仓库(软链名 ${existing.split('/').pop()})`;
|
|
32
|
+
return { status: 'ok', target: REPO_ROOT, link: existing, note };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 无匹配软链:检查 canonical 路径是否被占(悬空/指向他处)
|
|
36
|
+
try {
|
|
37
|
+
const st = lstatSync(PLUGIN_LINK);
|
|
38
|
+
if (st.isSymbolicLink()) {
|
|
39
|
+
let target = null;
|
|
40
|
+
try { target = readlinkSync(PLUGIN_LINK); } catch { /* dangling */ }
|
|
41
|
+
return { status: 'wrong', target, reason: target ? '指向其他位置' : '悬空软链' };
|
|
42
|
+
}
|
|
43
|
+
return { status: 'wrong', target: PLUGIN_LINK, reason: '同名目录(非软链)' };
|
|
44
|
+
} catch {
|
|
45
|
+
return { status: 'missing', target: null };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 创建软链 PLUGIN_LINK → REPO_ROOT
|
|
50
|
+
export function createSymlink() {
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(dirname(PLUGIN_LINK), { recursive: true });
|
|
53
|
+
// 已存在悬空软链先删
|
|
54
|
+
try { lstatSync(PLUGIN_LINK); } catch { /* not exist, ok */ }
|
|
55
|
+
symlinkSync(REPO_ROOT, PLUGIN_LINK, 'dir');
|
|
56
|
+
return { ok: true, from: PLUGIN_LINK, to: REPO_ROOT };
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { ok: false, output: err.code === 'EEXIST' ? '软链已存在' : String(err) };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 检查 ~/.tmux.conf 是否引用本插件
|
|
63
|
+
export function checkTmuxConf() {
|
|
64
|
+
const conf = join(homedir(), '.tmux.conf');
|
|
65
|
+
if (!existsSync(conf)) return { exists: false, referenced: false };
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(conf, 'utf8');
|
|
68
|
+
const referenced = /tmux-ai-hooks-status|tmux-claude-hooks-status/.test(raw);
|
|
69
|
+
return { exists: true, referenced };
|
|
70
|
+
} catch {
|
|
71
|
+
return { exists: true, referenced: false };
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/tmuxconf.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// tmuxconf.js — 写入 .tmux.conf 的 @plugin 声明 + reload tmux(symlink.js 保持只读职责)
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { checkTmuxConf } from './symlink.js';
|
|
7
|
+
|
|
8
|
+
const PLUGIN_DECL = "set -g @plugin 'tmux-ai-hooks-status'";
|
|
9
|
+
|
|
10
|
+
function confPath() {
|
|
11
|
+
return join(homedir(), '.tmux.conf');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 若 .tmux.conf 未引用本插件,追加 @plugin 声明。已引用则 no-op。
|
|
15
|
+
export function appendPluginDeclaration() {
|
|
16
|
+
const path = confPath();
|
|
17
|
+
const { referenced } = checkTmuxConf();
|
|
18
|
+
if (referenced) return { ok: true, appended: false, path };
|
|
19
|
+
try {
|
|
20
|
+
// 保证与前文有换行分隔
|
|
21
|
+
let prefix = '';
|
|
22
|
+
if (existsSync(path)) {
|
|
23
|
+
const raw = readFileSync(path, 'utf8');
|
|
24
|
+
if (raw.length > 0 && !raw.endsWith('\n')) prefix = '\n';
|
|
25
|
+
}
|
|
26
|
+
appendFileSync(path, `${prefix}${PLUGIN_DECL}\n`);
|
|
27
|
+
return { ok: true, appended: true, path };
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return { ok: false, appended: false, path, output: String(err) };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 重载 tmux 配置(source-file 重跑插件加载;refresh-client 仅重绘不够)。
|
|
34
|
+
// tmux 未运行时捕获错误返回提示,不抛。
|
|
35
|
+
export async function reloadTmux() {
|
|
36
|
+
const path = confPath();
|
|
37
|
+
try {
|
|
38
|
+
await execa('tmux', ['source-file', path], { timeout: 10000 });
|
|
39
|
+
return { ok: true };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { ok: false, output: err.stderr || err.shortMessage || String(err) };
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// tui.js — Unicode-safe terminal layout helpers.
|
|
2
|
+
//
|
|
3
|
+
// JavaScript string length counts UTF-16 code units, not terminal columns. CJK
|
|
4
|
+
// characters and emoji commonly occupy two columns, while combining marks and
|
|
5
|
+
// ANSI escape sequences occupy none. All box geometry must therefore use
|
|
6
|
+
// displayWidth() rather than String#length or String#padEnd().
|
|
7
|
+
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
|
|
10
|
+
const ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
|
|
11
|
+
const MARK = /^\p{Mark}+$/u;
|
|
12
|
+
const EMOJI_PRESENTATION = /\p{Emoji_Presentation}|\p{Regional_Indicator}/u;
|
|
13
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
14
|
+
const WORD_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
15
|
+
const TAB_SIZE = 4;
|
|
16
|
+
|
|
17
|
+
function stripAnsi(value) {
|
|
18
|
+
return String(value).replace(ANSI_PATTERN, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isZeroWidth(codePoint, character) {
|
|
22
|
+
return (
|
|
23
|
+
codePoint === 0x200d || // zero-width joiner
|
|
24
|
+
codePoint === 0x200c || // zero-width non-joiner
|
|
25
|
+
codePoint === 0xfe0e ||
|
|
26
|
+
codePoint === 0xfe0f || // variation selectors
|
|
27
|
+
(codePoint >= 0x1f3fb && codePoint <= 0x1f3ff) || // emoji modifiers
|
|
28
|
+
(codePoint >= 0xe0020 && codePoint <= 0xe007f) || // emoji tags
|
|
29
|
+
MARK.test(character)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Adapted from the Unicode ranges used by is-fullwidth-code-point. Ambiguous
|
|
34
|
+
// width characters intentionally remain one column, matching common terminals.
|
|
35
|
+
function isFullWidthCodePoint(codePoint) {
|
|
36
|
+
return codePoint >= 0x1100 && (
|
|
37
|
+
codePoint <= 0x115f ||
|
|
38
|
+
codePoint === 0x2329 ||
|
|
39
|
+
codePoint === 0x232a ||
|
|
40
|
+
(codePoint >= 0x2e80 && codePoint <= 0x303e) ||
|
|
41
|
+
(codePoint >= 0x3040 && codePoint <= 0xa4cf) ||
|
|
42
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
43
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
44
|
+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
|
45
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
46
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
47
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
48
|
+
(codePoint >= 0x1b000 && codePoint <= 0x1b2ff) ||
|
|
49
|
+
(codePoint >= 0x1f200 && codePoint <= 0x1f251) ||
|
|
50
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function graphemeWidth(grapheme, currentColumn = 0) {
|
|
55
|
+
if (grapheme === '\t') return TAB_SIZE - (currentColumn % TAB_SIZE);
|
|
56
|
+
if (EMOJI_PRESENTATION.test(grapheme) || grapheme.includes('\ufe0f') || grapheme.includes('\u200d')) return 2;
|
|
57
|
+
|
|
58
|
+
let width = 0;
|
|
59
|
+
for (const character of grapheme) {
|
|
60
|
+
const codePoint = character.codePointAt(0);
|
|
61
|
+
if (
|
|
62
|
+
codePoint === 0 ||
|
|
63
|
+
codePoint < 0x20 ||
|
|
64
|
+
(codePoint >= 0x7f && codePoint < 0xa0) ||
|
|
65
|
+
isZeroWidth(codePoint, character)
|
|
66
|
+
) continue;
|
|
67
|
+
width += isFullWidthCodePoint(codePoint) ? 2 : 1;
|
|
68
|
+
}
|
|
69
|
+
return width;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function graphemes(value) {
|
|
73
|
+
return [...GRAPHEME_SEGMENTER.segment(stripAnsi(value))].map(({ segment }) => segment);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function displayWidth(value) {
|
|
77
|
+
let maximum = 0;
|
|
78
|
+
for (const line of stripAnsi(value).split('\n')) {
|
|
79
|
+
let width = 0;
|
|
80
|
+
for (const grapheme of graphemes(line)) {
|
|
81
|
+
width += graphemeWidth(grapheme, width);
|
|
82
|
+
}
|
|
83
|
+
maximum = Math.max(maximum, width);
|
|
84
|
+
}
|
|
85
|
+
return maximum;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function padDisplay(value, targetWidth) {
|
|
89
|
+
const text = String(value);
|
|
90
|
+
return text + ' '.repeat(Math.max(0, targetWidth - displayWidth(text)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function takeDisplay(value, maximumWidth) {
|
|
94
|
+
let result = '';
|
|
95
|
+
let width = 0;
|
|
96
|
+
for (const grapheme of graphemes(value)) {
|
|
97
|
+
const nextWidth = graphemeWidth(grapheme, width);
|
|
98
|
+
if (width + nextWidth > maximumWidth) break;
|
|
99
|
+
result += grapheme;
|
|
100
|
+
width += nextWidth;
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function wrapDisplay(value, maximumWidth, options = {}) {
|
|
106
|
+
if (!Number.isFinite(maximumWidth) || maximumWidth < 1) {
|
|
107
|
+
throw new RangeError('maximumWidth must be a positive number');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const continuationIndent = stripAnsi(options.continuationIndent || '');
|
|
111
|
+
const indent = takeDisplay(continuationIndent, Math.max(0, maximumWidth - 2));
|
|
112
|
+
const indentWidth = displayWidth(indent);
|
|
113
|
+
const wrapped = [];
|
|
114
|
+
|
|
115
|
+
for (const originalLine of stripAnsi(value).split('\n')) {
|
|
116
|
+
const sourceLine = continuationIndent && originalLine.startsWith(continuationIndent)
|
|
117
|
+
? indent + originalLine.slice(continuationIndent.length)
|
|
118
|
+
: originalLine;
|
|
119
|
+
let line = '';
|
|
120
|
+
let width = 0;
|
|
121
|
+
|
|
122
|
+
const pushLine = () => {
|
|
123
|
+
wrapped.push(line.trimEnd());
|
|
124
|
+
line = indent;
|
|
125
|
+
width = indentWidth;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const appendHardWrapped = (token) => {
|
|
129
|
+
for (const grapheme of graphemes(token)) {
|
|
130
|
+
const nextWidth = graphemeWidth(grapheme, width);
|
|
131
|
+
if (width > indentWidth && width + nextWidth > maximumWidth) pushLine();
|
|
132
|
+
line += grapheme;
|
|
133
|
+
width += graphemeWidth(grapheme, width);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
for (const { segment: token } of WORD_SEGMENTER.segment(sourceLine)) {
|
|
138
|
+
const tokenWidth = displayWidth(token);
|
|
139
|
+
if (width + tokenWidth <= maximumWidth) {
|
|
140
|
+
line += token;
|
|
141
|
+
width += tokenWidth;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (line.trim().length > 0) pushLine();
|
|
146
|
+
if (/^\s+$/u.test(token)) continue;
|
|
147
|
+
|
|
148
|
+
if (width + tokenWidth <= maximumWidth) {
|
|
149
|
+
line += token;
|
|
150
|
+
width += tokenWidth;
|
|
151
|
+
} else {
|
|
152
|
+
appendHardWrapped(token);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
wrapped.push(line.trimEnd());
|
|
156
|
+
}
|
|
157
|
+
return wrapped;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function truncateDisplay(value, maximumWidth) {
|
|
161
|
+
if (displayWidth(value) <= maximumWidth) return String(value);
|
|
162
|
+
if (maximumWidth <= 1) return '…'.slice(0, maximumWidth);
|
|
163
|
+
|
|
164
|
+
let result = '';
|
|
165
|
+
let width = 0;
|
|
166
|
+
for (const grapheme of graphemes(value)) {
|
|
167
|
+
const nextWidth = graphemeWidth(grapheme, width);
|
|
168
|
+
if (width + nextWidth > maximumWidth - 1) break;
|
|
169
|
+
result += grapheme;
|
|
170
|
+
width += nextWidth;
|
|
171
|
+
}
|
|
172
|
+
return `${result}…`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function renderNote(message = '', title = '', options = {}) {
|
|
176
|
+
const requestedColumns = Number(options.columns);
|
|
177
|
+
const columns = Number.isFinite(requestedColumns) && requestedColumns > 0
|
|
178
|
+
? Math.max(10, Math.floor(requestedColumns))
|
|
179
|
+
: 80;
|
|
180
|
+
const color = options.color !== false;
|
|
181
|
+
const style = color
|
|
182
|
+
? { gray: pc.gray, green: pc.green, dim: pc.dim, reset: pc.reset }
|
|
183
|
+
: { gray: String, green: String, dim: String, reset: String };
|
|
184
|
+
|
|
185
|
+
const rawLines = String(message).split('\n');
|
|
186
|
+
const naturalWidth = Math.max(
|
|
187
|
+
displayWidth(title),
|
|
188
|
+
...rawLines.map(displayWidth),
|
|
189
|
+
);
|
|
190
|
+
const boxWidth = Math.min(columns, Math.max(10, naturalWidth + 6));
|
|
191
|
+
const contentWidth = boxWidth - 4;
|
|
192
|
+
const visibleTitle = truncateDisplay(title, Math.max(1, contentWidth - 2));
|
|
193
|
+
const titleWidth = displayWidth(visibleTitle);
|
|
194
|
+
const horizontal = '─'.repeat(Math.max(contentWidth - titleWidth - 1, 1));
|
|
195
|
+
const bodyLines = rawLines.flatMap((line) => {
|
|
196
|
+
const continuationIndent = line.match(/^ */u)?.[0] || '';
|
|
197
|
+
return wrapDisplay(line, contentWidth, { continuationIndent });
|
|
198
|
+
});
|
|
199
|
+
const framedLines = ['', ...bodyLines, ''].map((line) =>
|
|
200
|
+
`${style.gray('│')} ${style.dim(padDisplay(line, contentWidth))}${style.gray('│')}`,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return [
|
|
204
|
+
style.gray('│'),
|
|
205
|
+
`${style.green('◇')} ${style.reset(visibleTitle)} ${style.gray(`${horizontal}╮`)}`,
|
|
206
|
+
...framedLines,
|
|
207
|
+
style.gray(`├${'─'.repeat(contentWidth + 2)}╯`),
|
|
208
|
+
'',
|
|
209
|
+
].join('\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function note(message = '', title = '', options = {}) {
|
|
213
|
+
const stream = options.stream || process.stdout;
|
|
214
|
+
stream.write(renderNote(message, title, {
|
|
215
|
+
columns: options.columns ?? stream.columns,
|
|
216
|
+
color: options.color,
|
|
217
|
+
}));
|
|
218
|
+
}
|