specops 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.
Files changed (74) hide show
  1. package/README.md +630 -0
  2. package/bin/install.js +2089 -0
  3. package/bin/specops.js +35 -0
  4. package/dist/__e2e__/01-state-engine.e2e.test.d.ts +10 -0
  5. package/dist/__e2e__/01-state-engine.e2e.test.js +1 -0
  6. package/dist/acceptance/lazyDetector.d.ts +10 -0
  7. package/dist/acceptance/lazyDetector.js +1 -0
  8. package/dist/acceptance/lazyDetector.test.d.ts +1 -0
  9. package/dist/acceptance/lazyDetector.test.js +1 -0
  10. package/dist/acceptance/reporter.d.ts +12 -0
  11. package/dist/acceptance/reporter.js +1 -0
  12. package/dist/acceptance/reporter.test.d.ts +1 -0
  13. package/dist/acceptance/reporter.test.js +1 -0
  14. package/dist/acceptance/runner.d.ts +26 -0
  15. package/dist/acceptance/runner.js +2 -0
  16. package/dist/acceptance/runner.test.d.ts +1 -0
  17. package/dist/acceptance/runner.test.js +1 -0
  18. package/dist/acceptance/types.d.ts +44 -0
  19. package/dist/acceptance/types.js +1 -0
  20. package/dist/cli.d.ts +2 -0
  21. package/dist/cli.js +2 -0
  22. package/dist/context/index.d.ts +2 -0
  23. package/dist/context/index.js +1 -0
  24. package/dist/context/promptTemplate.d.ts +2 -0
  25. package/dist/context/promptTemplate.js +1 -0
  26. package/dist/context/promptTemplate.test.d.ts +1 -0
  27. package/dist/context/promptTemplate.test.js +1 -0
  28. package/dist/context/techContextLoader.d.ts +2 -0
  29. package/dist/context/techContextLoader.js +1 -0
  30. package/dist/context/techContextLoader.test.d.ts +1 -0
  31. package/dist/context/techContextLoader.test.js +1 -0
  32. package/dist/engine.d.ts +35 -0
  33. package/dist/engine.js +1 -0
  34. package/dist/evolution/distiller.d.ts +22 -0
  35. package/dist/evolution/distiller.js +1 -0
  36. package/dist/evolution/index.d.ts +8 -0
  37. package/dist/evolution/index.js +1 -0
  38. package/dist/evolution/memoryGraph.d.ts +34 -0
  39. package/dist/evolution/memoryGraph.js +1 -0
  40. package/dist/evolution/selector.d.ts +30 -0
  41. package/dist/evolution/selector.js +1 -0
  42. package/dist/evolution/signals.d.ts +18 -0
  43. package/dist/evolution/signals.js +1 -0
  44. package/dist/evolution/solidify.d.ts +20 -0
  45. package/dist/evolution/solidify.js +1 -0
  46. package/dist/evolution/store.d.ts +37 -0
  47. package/dist/evolution/store.js +1 -0
  48. package/dist/evolution/types.d.ts +350 -0
  49. package/dist/evolution/types.js +1 -0
  50. package/dist/init.d.ts +19 -0
  51. package/dist/init.js +1 -0
  52. package/dist/machines/agentMachine.d.ts +57 -0
  53. package/dist/machines/agentMachine.js +1 -0
  54. package/dist/machines/agentMachine.test.d.ts +1 -0
  55. package/dist/machines/agentMachine.test.js +1 -0
  56. package/dist/machines/supervisorMachine.d.ts +103 -0
  57. package/dist/machines/supervisorMachine.js +1 -0
  58. package/dist/machines/supervisorMachine.test.d.ts +1 -0
  59. package/dist/machines/supervisorMachine.test.js +1 -0
  60. package/dist/persistence/schema.d.ts +95 -0
  61. package/dist/persistence/schema.js +1 -0
  62. package/dist/persistence/stateFile.d.ts +17 -0
  63. package/dist/persistence/stateFile.js +1 -0
  64. package/dist/persistence/stateFile.test.d.ts +1 -0
  65. package/dist/persistence/stateFile.test.js +1 -0
  66. package/dist/plugin-engine.d.ts +42 -0
  67. package/dist/plugin-engine.js +1 -0
  68. package/dist/plugin.d.ts +9 -0
  69. package/dist/plugin.js +1 -0
  70. package/dist/types/index.d.ts +49 -0
  71. package/dist/types/index.js +1 -0
  72. package/dist/utils/id.d.ts +1 -0
  73. package/dist/utils/id.js +1 -0
  74. package/package.json +42 -0
package/bin/install.js ADDED
@@ -0,0 +1,2089 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+ const crypto = require('crypto');
8
+
9
+ // 颜色
10
+ const cyan = '\x1b[36m';
11
+ const green = '\x1b[32m';
12
+ const yellow = '\x1b[33m';
13
+ const dim = '\x1b[2m';
14
+ const reset = '\x1b[0m';
15
+
16
+ // 从 package.json 获取版本
17
+ const pkg = require('../package.json');
18
+
19
+ // 解析参数
20
+ const args = process.argv.slice(2);
21
+ const hasGlobal = args.includes('--global') || args.includes('-g');
22
+ const hasLocal = args.includes('--local') || args.includes('-l');
23
+ const hasOpencode = args.includes('--opencode');
24
+ const hasClaude = args.includes('--claude');
25
+ const hasGemini = args.includes('--gemini');
26
+ const hasCodex = args.includes('--codex');
27
+ const hasBoth = args.includes('--both'); // 旧版标志, 保持兼容
28
+ const hasAll = args.includes('--all');
29
+ const hasUninstall = args.includes('--uninstall') || args.includes('-u');
30
+
31
+ // 运行时选择 - 可通过标志或交互提示设置
32
+ let selectedRuntimes = [];
33
+ if (hasAll) {
34
+ selectedRuntimes = ['claude', 'opencode', 'gemini', 'codex'];
35
+ } else if (hasBoth) {
36
+ selectedRuntimes = ['claude', 'opencode'];
37
+ } else {
38
+ if (hasOpencode) selectedRuntimes.push('opencode');
39
+ if (hasClaude) selectedRuntimes.push('claude');
40
+ if (hasGemini) selectedRuntimes.push('gemini');
41
+ if (hasCodex) selectedRuntimes.push('codex');
42
+ }
43
+
44
+ // 获取运行时的目录名辅助函数 (用于本地/项目安装)
45
+ function getDirName(runtime) {
46
+ if (runtime === 'opencode') return '.opencode';
47
+ if (runtime === 'gemini') return '.gemini';
48
+ if (runtime === 'codex') return '.codex';
49
+ return '.claude';
50
+ }
51
+
52
+ /**
53
+ * 获取运行时相对于主目录的配置目录路径
54
+ * 用于模板化使用 path.join(homeDir, '<configDir>', ...) 的钩子
55
+ * @param {string} runtime - 'claude', 'opencode', 'gemini', 或 'codex'
56
+ * @param {boolean} isGlobal - 是否为全局安装
57
+ */
58
+ function getConfigDirFromHome(runtime, isGlobal) {
59
+ if (!isGlobal) {
60
+ // 本地安装使用相同的目录名模式
61
+ return `'${getDirName(runtime)}'`;
62
+ }
63
+ // 全局安装 - OpenCode 使用 XDG 路径结构
64
+ if (runtime === 'opencode') {
65
+ // OpenCode: ~/.config/opencode -> '.config', 'opencode'
66
+ // 返回逗号分隔格式用于 path.join() 替换
67
+ return "'.config', 'opencode'";
68
+ }
69
+ if (runtime === 'gemini') return "'.gemini'";
70
+ if (runtime === 'codex') return "'.codex'";
71
+ return "'.claude'";
72
+ }
73
+
74
+ /**
75
+ * 获取 OpenCode 的全局配置目录
76
+ * OpenCode 遵循 XDG 基础目录规范, 使用 ~/.config/opencode/
77
+ * 优先级: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
78
+ */
79
+ function getOpencodeGlobalDir() {
80
+ // 1. 显式 OPENCODE_CONFIG_DIR 环境变量
81
+ if (process.env.OPENCODE_CONFIG_DIR) {
82
+ return expandTilde(process.env.OPENCODE_CONFIG_DIR);
83
+ }
84
+
85
+ // 2. OPENCODE_CONFIG 环境变量 (使用其目录)
86
+ if (process.env.OPENCODE_CONFIG) {
87
+ return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
88
+ }
89
+
90
+ // 3. XDG_CONFIG_HOME/opencode
91
+ if (process.env.XDG_CONFIG_HOME) {
92
+ return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
93
+ }
94
+
95
+ // 4. 默认: ~/.config/opencode (XDG 默认)
96
+ return path.join(os.homedir(), '.config', 'opencode');
97
+ }
98
+
99
+ /**
100
+ * 获取运行时的全局配置目录
101
+ * @param {string} runtime - 'claude', 'opencode', 'gemini', 或 'codex'
102
+ * @param {string|null} explicitDir - 来自 --config-dir 标志的显式目录
103
+ */
104
+ function getGlobalDir(runtime, explicitDir = null) {
105
+ if (runtime === 'opencode') {
106
+ // 对于 OpenCode, --config-dir 覆盖环境变量
107
+ if (explicitDir) {
108
+ return expandTilde(explicitDir);
109
+ }
110
+ return getOpencodeGlobalDir();
111
+ }
112
+
113
+ if (runtime === 'gemini') {
114
+ // Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
115
+ if (explicitDir) {
116
+ return expandTilde(explicitDir);
117
+ }
118
+ if (process.env.GEMINI_CONFIG_DIR) {
119
+ return expandTilde(process.env.GEMINI_CONFIG_DIR);
120
+ }
121
+ return path.join(os.homedir(), '.gemini');
122
+ }
123
+
124
+ if (runtime === 'codex') {
125
+ // Codex: --config-dir > CODEX_HOME > ~/.codex
126
+ if (explicitDir) {
127
+ return expandTilde(explicitDir);
128
+ }
129
+ if (process.env.CODEX_HOME) {
130
+ return expandTilde(process.env.CODEX_HOME);
131
+ }
132
+ return path.join(os.homedir(), '.codex');
133
+ }
134
+
135
+ // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
136
+ if (explicitDir) {
137
+ return expandTilde(explicitDir);
138
+ }
139
+ if (process.env.CLAUDE_CONFIG_DIR) {
140
+ return expandTilde(process.env.CLAUDE_CONFIG_DIR);
141
+ }
142
+ return path.join(os.homedir(), '.claude');
143
+ }
144
+
145
+ const banner = '\n' +
146
+ cyan + ' ██████╗ ███████╗██████╗\n' +
147
+ ' ██╔════╝ ██╔════╝██╔══██╗\n' +
148
+ ' ██║ ███╗███████╗██║ ██║\n' +
149
+ ' ██║ ██║╚════██║██║ ██║\n' +
150
+ ' ╚██████╔╝███████║██████╔╝\n' +
151
+ ' ╚═════╝ ╚══════╝╚═════╝' + reset + '\n' +
152
+ '\n' +
153
+ ' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' +
154
+ ' 元提示、上下文工程和规格驱动开发系统\n' +
155
+ ' 适用于 Claude Code、OpenCode、Gemini 和 Codex, 由 TÂCHES 开发。\n';
156
+
157
+ // 解析 --config-dir 参数
158
+ function parseConfigDirArg() {
159
+ const configDirIndex = args.findIndex(arg => arg === '--config-dir' || arg === '-c');
160
+ if (configDirIndex !== -1) {
161
+ const nextArg = args[configDirIndex + 1];
162
+ // 如果 --config-dir 没有值或下一个参数是另一个标志则报错
163
+ if (!nextArg || nextArg.startsWith('-')) {
164
+ console.error(` ${yellow}--config-dir 需要一个路径参数${reset}`);
165
+ process.exit(1);
166
+ }
167
+ return nextArg;
168
+ }
169
+ // 同时处理 --config-dir=value 格式
170
+ const configDirArg = args.find(arg => arg.startsWith('--config-dir=') || arg.startsWith('-c='));
171
+ if (configDirArg) {
172
+ const value = configDirArg.split('=')[1];
173
+ if (!value) {
174
+ console.error(` ${yellow}--config-dir 需要一个非空路径${reset}`);
175
+ process.exit(1);
176
+ }
177
+ return value;
178
+ }
179
+ return null;
180
+ }
181
+ const explicitConfigDir = parseConfigDirArg();
182
+ const hasHelp = args.includes('--help') || args.includes('-h');
183
+ const forceStatusline = args.includes('--force-statusline');
184
+
185
+ console.log(banner);
186
+
187
+ // 如果请求帮助则显示
188
+ if (hasHelp) {
189
+ console.log(` ${yellow}用法:${reset} npx get-shit-done-cc [选项]\n\n ${yellow}选项:${reset}\n ${cyan}-g, --global${reset} 全局安装 (到配置目录)\n ${cyan}-l, --local${reset} 本地安装 (到当前目录)\n ${cyan}--claude${reset} 仅安装 Claude Code\n ${cyan}--opencode${reset} 仅安装 OpenCode\n ${cyan}--gemini${reset} 仅安装 Gemini\n ${cyan}--codex${reset} 仅安装 Codex\n ${cyan}--all${reset} 安装所有运行时\n ${cyan}-u, --uninstall${reset} 卸载 SpecOps (移除所有 SpecOps 文件)\n ${cyan}-c, --config-dir <path>${reset} 指定自定义配置目录\n ${cyan}-h, --help${reset} 显示此帮助信息\n ${cyan}--force-statusline${reset} 替换现有状态栏配置\n\n ${yellow}示例:${reset}\n ${dim}# 交互式安装 (提示选择运行时和位置)${reset}\n npx get-shit-done-cc\n\n ${dim}# 全局安装 Claude Code${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# 全局安装 Gemini${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# 全局安装 Codex${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# 全局安装所有运行时${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# 安装到自定义配置目录${reset}\n npx get-shit-done-cc --codex --global --config-dir ~/.codex-work\n\n ${dim}# 仅安装到当前项目${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# 全局卸载 Codex 的 SpecOps${reset}\n npx get-shit-done-cc --codex --global --uninstall\n\n ${yellow}说明:${reset}\n --config-dir 选项在你有多个配置时很有用。\n 它优先于 CLAUDE_CONFIG_DIR / GEMINI_CONFIG_DIR / CODEX_HOME 环境变量。\n`);
190
+ process.exit(0);
191
+ }
192
+
193
+ /**
194
+ * 展开 ~ 到主目录 (shell 不会在传递给 node 的环境变量中展开)
195
+ */
196
+ function expandTilde(filePath) {
197
+ if (filePath && filePath.startsWith('~/')) {
198
+ return path.join(os.homedir(), filePath.slice(2));
199
+ }
200
+ return filePath;
201
+ }
202
+
203
+ /**
204
+ * 使用正斜杠构建钩子命令路径, 实现跨平台兼容。
205
+ * 在 Windows 上, $HOME 不会被 cmd.exe/PowerShell 展开, 所以我们使用实际路径。
206
+ */
207
+ function buildHookCommand(configDir, hookName) {
208
+ // 使用正斜杠确保所有平台上的 Node.js 兼容性
209
+ const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
210
+ return `node "${hooksPath}"`;
211
+ }
212
+
213
+ /**
214
+ * 读取并解析 settings.json, 如果不存在则返回空对象
215
+ */
216
+ function readSettings(settingsPath) {
217
+ if (fs.existsSync(settingsPath)) {
218
+ try {
219
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
220
+ } catch (e) {
221
+ return {};
222
+ }
223
+ }
224
+ return {};
225
+ }
226
+
227
+ /**
228
+ * 以正确格式写入 settings.json
229
+ */
230
+ function writeSettings(settingsPath, settings) {
231
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
232
+ }
233
+
234
+ // 归属设置缓存 (安装期间每个运行时填充一次)
235
+ const attributionCache = new Map();
236
+
237
+ /**
238
+ * 获取运行时的提交归属设置
239
+ * @param {string} runtime - 'claude', 'opencode', 'gemini', 或 'codex'
240
+ * @returns {null|undefined|string} null = 移除, undefined = 保持默认, string = 自定义
241
+ */
242
+ function getCommitAttribution(runtime) {
243
+ // 如果有缓存值则返回
244
+ if (attributionCache.has(runtime)) {
245
+ return attributionCache.get(runtime);
246
+ }
247
+
248
+ let result;
249
+
250
+ if (runtime === 'opencode') {
251
+ const config = readSettings(path.join(getGlobalDir('opencode', null), 'opencode.json'));
252
+ result = config.disable_ai_attribution === true ? null : undefined;
253
+ } else if (runtime === 'gemini') {
254
+ // Gemini: 检查 gemini settings.json 的归属配置
255
+ const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
256
+ if (!settings.attribution || settings.attribution.commit === undefined) {
257
+ result = undefined;
258
+ } else if (settings.attribution.commit === '') {
259
+ result = null;
260
+ } else {
261
+ result = settings.attribution.commit;
262
+ }
263
+ } else if (runtime === 'claude') {
264
+ // Claude Code
265
+ const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
266
+ if (!settings.attribution || settings.attribution.commit === undefined) {
267
+ result = undefined;
268
+ } else if (settings.attribution.commit === '') {
269
+ result = null;
270
+ } else {
271
+ result = settings.attribution.commit;
272
+ }
273
+ } else {
274
+ // Codex 目前没有等效的归属设置
275
+ result = undefined;
276
+ }
277
+
278
+ // 缓存并返回
279
+ attributionCache.set(runtime, result);
280
+ return result;
281
+ }
282
+
283
+ /**
284
+ * 根据归属设置处理 Co-Authored-By 行
285
+ * @param {string} content - 要处理的文件内容
286
+ * @param {null|undefined|string} attribution - null=移除, undefined=保持, string=替换
287
+ * @returns {string} 处理后的内容
288
+ */
289
+ function processAttribution(content, attribution) {
290
+ if (attribution === null) {
291
+ // 移除 Co-Authored-By 行及其前面的空行
292
+ return content.replace(/(\r?\n){2}Co-Authored-By:.*$/gim, '');
293
+ }
294
+ if (attribution === undefined) {
295
+ return content;
296
+ }
297
+ // 替换为自定义归属 (转义 $ 防止反向引用注入)
298
+ const safeAttribution = attribution.replace(/\$/g, '$$$$');
299
+ return content.replace(/Co-Authored-By:.*$/gim, `Co-Authored-By: ${safeAttribution}`);
300
+ }
301
+
302
+ /**
303
+ * 将 Claude Code 前置元数据转换为 opencode 格式
304
+ * - 将 'allowed-tools:' 数组转换为 'permission:' 对象
305
+ * @param {string} content - 带 YAML 前置元数据的 Markdown 文件内容
306
+ * @returns {string} - 转换后前置元数据的内容
307
+ */
308
+ // 颜色名到十六进制映射, 用于 opencode 兼容性
309
+ const colorNameToHex = {
310
+ cyan: '#00FFFF',
311
+ red: '#FF0000',
312
+ green: '#00FF00',
313
+ blue: '#0000FF',
314
+ yellow: '#FFFF00',
315
+ magenta: '#FF00FF',
316
+ orange: '#FFA500',
317
+ purple: '#800080',
318
+ pink: '#FFC0CB',
319
+ white: '#FFFFFF',
320
+ black: '#000000',
321
+ gray: '#808080',
322
+ grey: '#808080',
323
+ };
324
+
325
+ // 工具名映射: Claude Code -> OpenCode
326
+ // OpenCode 使用小写工具名; 重命名工具的特殊映射
327
+ const claudeToOpencodeTools = {
328
+ AskUserQuestion: 'question',
329
+ SlashCommand: 'skill',
330
+ TodoWrite: 'todowrite',
331
+ WebFetch: 'webfetch',
332
+ WebSearch: 'websearch', // 插件/MCP - 保留兼容性
333
+ };
334
+
335
+ // 工具名映射: Claude Code -> Gemini CLI
336
+ // Gemini CLI 使用 snake_case 内置工具名
337
+ const claudeToGeminiTools = {
338
+ Read: 'read_file',
339
+ Write: 'write_file',
340
+ Edit: 'replace',
341
+ Bash: 'run_shell_command',
342
+ Glob: 'glob',
343
+ Grep: 'search_file_content',
344
+ WebSearch: 'google_web_search',
345
+ WebFetch: 'web_fetch',
346
+ TodoWrite: 'write_todos',
347
+ AskUserQuestion: 'ask_user',
348
+ };
349
+
350
+ /**
351
+ * 将 Claude Code 工具名转换为 OpenCode 格式
352
+ * - 应用特殊映射 (AskUserQuestion -> question 等)
353
+ * - 转换为小写 (MCP 工具保持原格式除外)
354
+ */
355
+ function convertToolName(claudeTool) {
356
+ // 先检查特殊映射
357
+ if (claudeToOpencodeTools[claudeTool]) {
358
+ return claudeToOpencodeTools[claudeTool];
359
+ }
360
+ // MCP 工具 (mcp__*) 保持原格式
361
+ if (claudeTool.startsWith('mcp__')) {
362
+ return claudeTool;
363
+ }
364
+ // 默认: 转换为小写
365
+ return claudeTool.toLowerCase();
366
+ }
367
+
368
+ /**
369
+ * 将 Claude Code 工具名转换为 Gemini CLI 格式
370
+ * - 应用 Claude→Gemini 映射 (Read→read_file, Bash→run_shell_command 等)
371
+ * - 过滤 MCP 工具 (mcp__*), 它们在 Gemini 中运行时自动发现
372
+ * - 过滤 Task, Agent 在 Gemini 中自动注册为工具
373
+ * @returns {string|null} Gemini 工具名, 如果工具应被排除则返回 null
374
+ */
375
+ function convertGeminiToolName(claudeTool) {
376
+ // MCP 工具: 排除, 运行时从 mcpServers 配置自动发现
377
+ if (claudeTool.startsWith('mcp__')) {
378
+ return null;
379
+ }
380
+ // Task: 排除, Agent 自动注册为可调用工具
381
+ if (claudeTool === 'Task') {
382
+ return null;
383
+ }
384
+ // 检查显式映射
385
+ if (claudeToGeminiTools[claudeTool]) {
386
+ return claudeToGeminiTools[claudeTool];
387
+ }
388
+ // 默认: 小写
389
+ return claudeTool.toLowerCase();
390
+ }
391
+
392
+ function toSingleLine(value) {
393
+ return value.replace(/\s+/g, ' ').trim();
394
+ }
395
+
396
+ function yamlQuote(value) {
397
+ return JSON.stringify(value);
398
+ }
399
+
400
+ function extractFrontmatterAndBody(content) {
401
+ if (!content.startsWith('---')) {
402
+ return { frontmatter: null, body: content };
403
+ }
404
+
405
+ const endIndex = content.indexOf('---', 3);
406
+ if (endIndex === -1) {
407
+ return { frontmatter: null, body: content };
408
+ }
409
+
410
+ return {
411
+ frontmatter: content.substring(3, endIndex).trim(),
412
+ body: content.substring(endIndex + 3),
413
+ };
414
+ }
415
+
416
+ function extractFrontmatterField(frontmatter, fieldName) {
417
+ const regex = new RegExp(`^${fieldName}:\\s*(.+)$`, 'm');
418
+ const match = frontmatter.match(regex);
419
+ if (!match) return null;
420
+ return match[1].trim().replace(/^['"]|['"]$/g, '');
421
+ }
422
+
423
+ function convertSlashCommandsToCodexSkillMentions(content) {
424
+ let converted = content.replace(/\/specops:([a-z0-9-]+)/gi, (_, commandName) => {
425
+ return `$specops-${String(commandName).toLowerCase()}`;
426
+ });
427
+ converted = converted.replace(/\/specops-help\b/g, '$specops-help');
428
+ return converted;
429
+ }
430
+
431
+ function convertClaudeToCodexMarkdown(content) {
432
+ let converted = convertSlashCommandsToCodexSkillMentions(content);
433
+ converted = converted.replace(/\$ARGUMENTS\b/g, '{{SpecOps_ARGS}}');
434
+ return converted;
435
+ }
436
+
437
+ function getCodexSkillAdapterHeader(skillName) {
438
+ const invocation = `$${skillName}`;
439
+ return `<codex_skill_adapter>
440
+ Codex skills 优先模式:
441
+ - 此 skill 通过提及 \`${invocation}\` 来调用。
442
+ - 将 \`${invocation}\` 之后的所有用户文本视为 \`{{SpecOps_ARGS}}\`。
443
+ - 如果没有参数, 将 \`{{SpecOps_ARGS}}\` 视为空。
444
+
445
+ 旧版编排兼容性:
446
+ - 引用的工作流文档中的任何 \`Task(...)\` 模式都是旧版语法。
447
+ - 用 Codex 协作工具实现等效行为: \`spawn_agent\`、\`wait\`、\`send_input\` 和 \`close_agent\`。
448
+ - 将旧版 \`subagent_type\` 名称视为生成消息中的角色提示。
449
+ </codex_skill_adapter>`;
450
+ }
451
+
452
+ function convertClaudeCommandToCodexSkill(content, skillName) {
453
+ const converted = convertClaudeToCodexMarkdown(content);
454
+ const { frontmatter, body } = extractFrontmatterAndBody(converted);
455
+ let description = `运行 SpecOps 工作流 ${skillName}。`;
456
+ if (frontmatter) {
457
+ const maybeDescription = extractFrontmatterField(frontmatter, 'description');
458
+ if (maybeDescription) {
459
+ description = maybeDescription;
460
+ }
461
+ }
462
+ description = toSingleLine(description);
463
+ const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
464
+ const adapter = getCodexSkillAdapterHeader(skillName);
465
+
466
+ return `---\nname: ${yamlQuote(skillName)}\ndescription: ${yamlQuote(description)}\nmetadata:\n short-description: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
467
+ }
468
+
469
+ /**
470
+ * 为 Gemini CLI 输出去除 HTML <sub> 标签
471
+ * 终端不支持下标, Gemini 会将其渲染为原始 HTML。
472
+ * 将 <sub>text</sub> 转换为斜体 *(text)* 以获得可读的终端输出。
473
+ */
474
+ function stripSubTags(content) {
475
+ return content.replace(/<sub>(.*?)<\/sub>/g, '*($1)*');
476
+ }
477
+
478
+ /**
479
+ * 将 Claude Code agent 前置元数据转换为 Gemini CLI 格式
480
+ * Gemini agent 使用带 YAML 前置元数据的 .md 文件, 与 Claude 相同,
481
+ * 但字段名和格式不同:
482
+ * - tools: 必须是 YAML 数组 (不是逗号分隔的字符串)
483
+ * - 工具名: 必须使用 Gemini 内置名称 (read_file, 不是 Read)
484
+ * - color: 必须移除 (会导致验证错误)
485
+ * - mcp__* 工具: 必须排除 (运行时自动发现)
486
+ */
487
+ function convertClaudeToGeminiAgent(content) {
488
+ if (!content.startsWith('---')) return content;
489
+
490
+ const endIndex = content.indexOf('---', 3);
491
+ if (endIndex === -1) return content;
492
+
493
+ const frontmatter = content.substring(3, endIndex).trim();
494
+ const body = content.substring(endIndex + 3);
495
+
496
+ const lines = frontmatter.split('\n');
497
+ const newLines = [];
498
+ let inAllowedTools = false;
499
+ const tools = [];
500
+
501
+ for (const line of lines) {
502
+ const trimmed = line.trim();
503
+
504
+ // 将 allowed-tools YAML 数组转换为工具列表
505
+ if (trimmed.startsWith('allowed-tools:')) {
506
+ inAllowedTools = true;
507
+ continue;
508
+ }
509
+
510
+ // 处理内联 tools: 字段 (逗号分隔字符串)
511
+ if (trimmed.startsWith('tools:')) {
512
+ const toolsValue = trimmed.substring(6).trim();
513
+ if (toolsValue) {
514
+ const parsed = toolsValue.split(',').map(t => t.trim()).filter(t => t);
515
+ for (const t of parsed) {
516
+ const mapped = convertGeminiToolName(t);
517
+ if (mapped) tools.push(mapped);
518
+ }
519
+ } else {
520
+ // tools: 没有值意味着后面跟着 YAML 数组
521
+ inAllowedTools = true;
522
+ }
523
+ continue;
524
+ }
525
+
526
+ // 去除 color 字段 (Gemini CLI 不支持, 会导致验证错误)
527
+ if (trimmed.startsWith('color:')) continue;
528
+
529
+ // 收集 allowed-tools/tools 数组项
530
+ if (inAllowedTools) {
531
+ if (trimmed.startsWith('- ')) {
532
+ const mapped = convertGeminiToolName(trimmed.substring(2).trim());
533
+ if (mapped) tools.push(mapped);
534
+ continue;
535
+ } else if (trimmed && !trimmed.startsWith('-')) {
536
+ inAllowedTools = false;
537
+ }
538
+ }
539
+
540
+ if (!inAllowedTools) {
541
+ newLines.push(line);
542
+ }
543
+ }
544
+
545
+ // 将工具添加为 YAML 数组 (Gemini 要求数组格式)
546
+ if (tools.length > 0) {
547
+ newLines.push('tools:');
548
+ for (const tool of tools) {
549
+ newLines.push(` - ${tool}`);
550
+ }
551
+ }
552
+
553
+ const newFrontmatter = newLines.join('\n').trim();
554
+
555
+ // 转义 agent body 中的 ${VAR} 模式以兼容 Gemini CLI。
556
+ // Gemini 的 templateString() 将所有 ${word} 模式视为模板变量,
557
+ // 无法解析时会抛出 "Template validation failed: Missing required input parameters"。
558
+ // SpecOps agent 在 bash 代码块中使用 ${PHASE}、${PLAN} 等作为 shell 变量,
559
+ // 转换为 $VAR (无花括号), 这在 bash 中等效且对 Gemini 的 /\$\{(\w+)\}/g 正则不可见。
560
+ const escapedBody = body.replace(/\$\{(\w+)\}/g, '$$$1');
561
+
562
+ return `---\n${newFrontmatter}\n---${stripSubTags(escapedBody)}`;
563
+ }
564
+
565
+ function convertClaudeToOpencodeFrontmatter(content) {
566
+ // 替换内容中的工具名引用 (适用于所有文件)
567
+ let convertedContent = content;
568
+ convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
569
+ convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill');
570
+ convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite');
571
+ // 将 /specops:command 替换为 /specops-command 用于 opencode (扁平命令结构)
572
+ convertedContent = convertedContent.replace(/\/specops:/g, '/specops-');
573
+ // 将 ~/.claude 替换为 ~/.config/opencode (OpenCode 的正确配置位置)
574
+ convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode');
575
+ // 将通用子 Agent 类型替换为 OpenCode 的等效 "general"
576
+ convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"');
577
+
578
+ // 检查内容是否有前置元数据
579
+ if (!convertedContent.startsWith('---')) {
580
+ return convertedContent;
581
+ }
582
+
583
+ // 找到前置元数据的结尾
584
+ const endIndex = convertedContent.indexOf('---', 3);
585
+ if (endIndex === -1) {
586
+ return convertedContent;
587
+ }
588
+
589
+ const frontmatter = convertedContent.substring(3, endIndex).trim();
590
+ const body = convertedContent.substring(endIndex + 3);
591
+
592
+ // 逐行解析前置元数据 (简单 YAML 解析)
593
+ const lines = frontmatter.split('\n');
594
+ const newLines = [];
595
+ let inAllowedTools = false;
596
+ const allowedTools = [];
597
+
598
+ for (const line of lines) {
599
+ const trimmed = line.trim();
600
+
601
+ // 检测 allowed-tools 数组的开始
602
+ if (trimmed.startsWith('allowed-tools:')) {
603
+ inAllowedTools = true;
604
+ continue;
605
+ }
606
+
607
+ // 检测内联 tools: 字段 (逗号分隔字符串)
608
+ if (trimmed.startsWith('tools:')) {
609
+ const toolsValue = trimmed.substring(6).trim();
610
+ if (toolsValue) {
611
+ // 解析逗号分隔的工具
612
+ const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t);
613
+ allowedTools.push(...tools);
614
+ }
615
+ continue;
616
+ }
617
+
618
+ // 移除 name: 字段 - opencode 使用文件名作为命令名
619
+ if (trimmed.startsWith('name:')) {
620
+ continue;
621
+ }
622
+
623
+ // 为 opencode 将颜色名转换为十六进制
624
+ if (trimmed.startsWith('color:')) {
625
+ const colorValue = trimmed.substring(6).trim().toLowerCase();
626
+ const hexColor = colorNameToHex[colorValue];
627
+ if (hexColor) {
628
+ newLines.push(`color: "${hexColor}"`);
629
+ } else if (colorValue.startsWith('#')) {
630
+ // 验证十六进制颜色格式 (#RGB 或 #RRGGBB)
631
+ if (/^#[0-9a-f]{3}$|^#[0-9a-f]{6}$/i.test(colorValue)) {
632
+ // 已经是有效的十六进制, 保持原样
633
+ newLines.push(line);
634
+ }
635
+ // 跳过无效的十六进制颜色
636
+ }
637
+ // 跳过未知的颜色名
638
+ continue;
639
+ }
640
+
641
+ // 收集 allowed-tools 项
642
+ if (inAllowedTools) {
643
+ if (trimmed.startsWith('- ')) {
644
+ allowedTools.push(trimmed.substring(2).trim());
645
+ continue;
646
+ } else if (trimmed && !trimmed.startsWith('-')) {
647
+ // 数组结束, 新字段开始
648
+ inAllowedTools = false;
649
+ }
650
+ }
651
+
652
+ // 保留其他字段 (包括 opencode 忽略的 name:)
653
+ if (!inAllowedTools) {
654
+ newLines.push(line);
655
+ }
656
+ }
657
+
658
+ // 如果有 allowed-tools 或 tools 则添加 tools 对象
659
+ if (allowedTools.length > 0) {
660
+ newLines.push('tools:');
661
+ for (const tool of allowedTools) {
662
+ newLines.push(` ${convertToolName(tool)}: true`);
663
+ }
664
+ }
665
+
666
+ // 重建前置元数据 (body 中的工具名已转换)
667
+ const newFrontmatter = newLines.join('\n').trim();
668
+ return `---\n${newFrontmatter}\n---${body}`;
669
+ }
670
+
671
+ /**
672
+ * 将 Claude Code markdown 命令转换为 Gemini TOML 格式
673
+ * @param {string} content - 带 YAML 前置元数据的 Markdown 文件内容
674
+ * @returns {string} - TOML 内容
675
+ */
676
+ function convertClaudeToGeminiToml(content) {
677
+ // 检查内容是否有前置元数据
678
+ if (!content.startsWith('---')) {
679
+ return `prompt = ${JSON.stringify(content)}\n`;
680
+ }
681
+
682
+ const endIndex = content.indexOf('---', 3);
683
+ if (endIndex === -1) {
684
+ return `prompt = ${JSON.stringify(content)}\n`;
685
+ }
686
+
687
+ const frontmatter = content.substring(3, endIndex).trim();
688
+ const body = content.substring(endIndex + 3).trim();
689
+
690
+ // 从前置元数据提取描述
691
+ let description = '';
692
+ const lines = frontmatter.split('\n');
693
+ for (const line of lines) {
694
+ const trimmed = line.trim();
695
+ if (trimmed.startsWith('description:')) {
696
+ description = trimmed.substring(12).trim();
697
+ break;
698
+ }
699
+ }
700
+
701
+ // 构造 TOML
702
+ let toml = '';
703
+ if (description) {
704
+ toml += `description = ${JSON.stringify(description)}\n`;
705
+ }
706
+
707
+ toml += `prompt = ${JSON.stringify(body)}\n`;
708
+
709
+ return toml;
710
+ }
711
+
712
+ /**
713
+ * 将命令复制到 OpenCode 的扁平结构
714
+ * OpenCode 期望: command/specops-help.md (以 /specops-help 调用)
715
+ * 源结构: commands/specops/help.md
716
+ *
717
+ * @param {string} srcDir - 源目录 (如 commands/specops/)
718
+ * @param {string} destDir - 目标目录 (如 command/)
719
+ * @param {string} prefix - 文件名前缀 (如 'specops')
720
+ * @param {string} pathPrefix - 文件引用的路径前缀
721
+ * @param {string} runtime - 目标运行时 ('claude' 或 'opencode')
722
+ */
723
+ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
724
+ if (!fs.existsSync(srcDir)) {
725
+ return;
726
+ }
727
+
728
+ // 复制新文件前移除旧的 specops-*.md 文件
729
+ if (fs.existsSync(destDir)) {
730
+ for (const file of fs.readdirSync(destDir)) {
731
+ if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
732
+ fs.unlinkSync(path.join(destDir, file));
733
+ }
734
+ }
735
+ } else {
736
+ fs.mkdirSync(destDir, { recursive: true });
737
+ }
738
+
739
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
740
+
741
+ for (const entry of entries) {
742
+ const srcPath = path.join(srcDir, entry.name);
743
+
744
+ if (entry.isDirectory()) {
745
+ // 递归进入子目录, 添加到前缀
746
+ // 如 commands/specops/debug/start.md -> command/specops-debug-start.md
747
+ copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
748
+ } else if (entry.name.endsWith('.md')) {
749
+ // 扁平化: help.md -> specops-help.md
750
+ const baseName = entry.name.replace('.md', '');
751
+ const destName = `${prefix}-${baseName}.md`;
752
+ const destPath = path.join(destDir, destName);
753
+
754
+ let content = fs.readFileSync(srcPath, 'utf8');
755
+ const globalClaudeRegex = /~\/\.claude\//g;
756
+ const localClaudeRegex = /\.\/\.claude\//g;
757
+ const opencodeDirRegex = /~\/\.opencode\//g;
758
+ content = content.replace(globalClaudeRegex, pathPrefix);
759
+ content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
760
+ content = content.replace(opencodeDirRegex, pathPrefix);
761
+ content = processAttribution(content, getCommitAttribution(runtime));
762
+ content = convertClaudeToOpencodeFrontmatter(content);
763
+
764
+ fs.writeFileSync(destPath, content);
765
+ }
766
+ }
767
+ }
768
+
769
+ function listCodexSkillNames(skillsDir, prefix = 'specops-') {
770
+ if (!fs.existsSync(skillsDir)) return [];
771
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
772
+ return entries
773
+ .filter(entry => entry.isDirectory() && entry.name.startsWith(prefix))
774
+ .filter(entry => fs.existsSync(path.join(skillsDir, entry.name, 'SKILL.md')))
775
+ .map(entry => entry.name)
776
+ .sort();
777
+ }
778
+
779
+ function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) {
780
+ if (!fs.existsSync(srcDir)) {
781
+ return;
782
+ }
783
+
784
+ fs.mkdirSync(skillsDir, { recursive: true });
785
+
786
+ // 移除之前的 SpecOps Codex skills 以避免旧版命令 skills 残留。
787
+ const existing = fs.readdirSync(skillsDir, { withFileTypes: true });
788
+ for (const entry of existing) {
789
+ if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) {
790
+ fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
791
+ }
792
+ }
793
+
794
+ function recurse(currentSrcDir, currentPrefix) {
795
+ const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true });
796
+
797
+ for (const entry of entries) {
798
+ const srcPath = path.join(currentSrcDir, entry.name);
799
+ if (entry.isDirectory()) {
800
+ recurse(srcPath, `${currentPrefix}-${entry.name}`);
801
+ continue;
802
+ }
803
+
804
+ if (!entry.name.endsWith('.md')) {
805
+ continue;
806
+ }
807
+
808
+ const baseName = entry.name.replace('.md', '');
809
+ const skillName = `${currentPrefix}-${baseName}`;
810
+ const skillDir = path.join(skillsDir, skillName);
811
+ fs.mkdirSync(skillDir, { recursive: true });
812
+
813
+ let content = fs.readFileSync(srcPath, 'utf8');
814
+ const globalClaudeRegex = /~\/\.claude\//g;
815
+ const localClaudeRegex = /\.\/\.claude\//g;
816
+ const codexDirRegex = /~\/\.codex\//g;
817
+ content = content.replace(globalClaudeRegex, pathPrefix);
818
+ content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
819
+ content = content.replace(codexDirRegex, pathPrefix);
820
+ content = processAttribution(content, getCommitAttribution(runtime));
821
+ content = convertClaudeCommandToCodexSkill(content, skillName);
822
+
823
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
824
+ }
825
+ }
826
+
827
+ recurse(srcDir, prefix);
828
+ }
829
+
830
+ /**
831
+ * 递归复制目录, 替换 .md 文件中的路径
832
+ * 先删除现有 destDir 以移除旧版本的孤立文件
833
+ * @param {string} srcDir - 源目录
834
+ * @param {string} destDir - 目标目录
835
+ * @param {string} pathPrefix - 文件引用的路径前缀
836
+ * @param {string} runtime - 目标运行时 ('claude', 'opencode', 'gemini', 'codex')
837
+ */
838
+ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false) {
839
+ const isOpencode = runtime === 'opencode';
840
+ const isCodex = runtime === 'codex';
841
+ const dirName = getDirName(runtime);
842
+
843
+ // 干净安装: 移除现有目标以防止孤立文件
844
+ if (fs.existsSync(destDir)) {
845
+ fs.rmSync(destDir, { recursive: true });
846
+ }
847
+ fs.mkdirSync(destDir, { recursive: true });
848
+
849
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
850
+
851
+ for (const entry of entries) {
852
+ const srcPath = path.join(srcDir, entry.name);
853
+ const destPath = path.join(destDir, entry.name);
854
+
855
+ if (entry.isDirectory()) {
856
+ copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand);
857
+ } else if (entry.name.endsWith('.md')) {
858
+ // 将 ~/.claude/ 和 ./.claude/ 替换为运行时适当的路径
859
+ let content = fs.readFileSync(srcPath, 'utf8');
860
+ const globalClaudeRegex = /~\/\.claude\//g;
861
+ const localClaudeRegex = /\.\/\.claude\//g;
862
+ content = content.replace(globalClaudeRegex, pathPrefix);
863
+ content = content.replace(localClaudeRegex, `./${dirName}/`);
864
+ content = processAttribution(content, getCommitAttribution(runtime));
865
+
866
+ // 为 opencode 兼容性转换前置元数据
867
+ if (isOpencode) {
868
+ content = convertClaudeToOpencodeFrontmatter(content);
869
+ fs.writeFileSync(destPath, content);
870
+ } else if (runtime === 'gemini') {
871
+ if (isCommand) {
872
+ // 为 Gemini 转换为 TOML (去除 <sub> 标签, 终端无法渲染下标)
873
+ content = stripSubTags(content);
874
+ const tomlContent = convertClaudeToGeminiToml(content);
875
+ // 将扩展名替换为 .toml
876
+ const tomlPath = destPath.replace(/\.md$/, '.toml');
877
+ fs.writeFileSync(tomlPath, tomlContent);
878
+ } else {
879
+ fs.writeFileSync(destPath, content);
880
+ }
881
+ } else if (isCodex) {
882
+ content = convertClaudeToCodexMarkdown(content);
883
+ fs.writeFileSync(destPath, content);
884
+ } else {
885
+ fs.writeFileSync(destPath, content);
886
+ }
887
+ } else {
888
+ fs.copyFileSync(srcPath, destPath);
889
+ }
890
+ }
891
+ }
892
+
893
+ /**
894
+ * 清理旧版本的孤立文件
895
+ */
896
+ function cleanupOrphanedFiles(configDir) {
897
+ const orphanedFiles = [
898
+ 'hooks/specops-notify.sh', // 在 v1.6.x 中移除
899
+ 'hooks/statusline.js', // 在 v1.9.0 中重命名为 specops-statusline.js
900
+ ];
901
+
902
+ for (const relPath of orphanedFiles) {
903
+ const fullPath = path.join(configDir, relPath);
904
+ if (fs.existsSync(fullPath)) {
905
+ fs.unlinkSync(fullPath);
906
+ console.log(` ${green}✓${reset} 已移除孤立文件 ${relPath}`);
907
+ }
908
+ }
909
+ }
910
+
911
+ /**
912
+ * 清理 settings.json 中的孤立钩子注册
913
+ */
914
+ function cleanupOrphanedHooks(settings) {
915
+ const orphanedHookPatterns = [
916
+ 'specops-notify.sh', // 在 v1.6.x 中移除
917
+ 'hooks/statusline.js', // 在 v1.9.0 中重命名为 specops-statusline.js
918
+ 'specops-intel-index.js', // 在 v1.9.2 中移除
919
+ 'specops-intel-session.js', // 在 v1.9.2 中移除
920
+ 'specops-intel-prune.js', // 在 v1.9.2 中移除
921
+ ];
922
+
923
+ let cleanedHooks = false;
924
+
925
+ // 检查所有钩子事件类型 (Stop, SessionStart 等)
926
+ if (settings.hooks) {
927
+ for (const eventType of Object.keys(settings.hooks)) {
928
+ const hookEntries = settings.hooks[eventType];
929
+ if (Array.isArray(hookEntries)) {
930
+ // 过滤掉包含孤立钩子的条目
931
+ const filtered = hookEntries.filter(entry => {
932
+ if (entry.hooks && Array.isArray(entry.hooks)) {
933
+ // 检查此条目中是否有钩子匹配孤立模式
934
+ const hasOrphaned = entry.hooks.some(h =>
935
+ h.command && orphanedHookPatterns.some(pattern => h.command.includes(pattern))
936
+ );
937
+ if (hasOrphaned) {
938
+ cleanedHooks = true;
939
+ return false; // 移除此条目
940
+ }
941
+ }
942
+ return true; // 保留此条目
943
+ });
944
+ settings.hooks[eventType] = filtered;
945
+ }
946
+ }
947
+ }
948
+
949
+ if (cleanedHooks) {
950
+ console.log(` ${green}✓${reset} 已移除孤立钩子注册`);
951
+ }
952
+
953
+ // 修复 #330: 如果 statusLine 指向旧的 statusline.js 路径则更新
954
+ if (settings.statusLine && settings.statusLine.command &&
955
+ settings.statusLine.command.includes('statusline.js') &&
956
+ !settings.statusLine.command.includes('specops-statusline.js')) {
957
+ // 将旧路径替换为新路径
958
+ settings.statusLine.command = settings.statusLine.command.replace(
959
+ /statusline\.js/,
960
+ 'specops-statusline.js'
961
+ );
962
+ console.log(` ${green}✓${reset} 已更新状态栏路径 (statusline.js → specops-statusline.js)`);
963
+ }
964
+
965
+ return settings;
966
+ }
967
+
968
+ /**
969
+ * 从指定目录为特定运行时卸载 SpecOps
970
+ * 仅移除 SpecOps 特定的文件/目录, 保留用户内容
971
+ * @param {boolean} isGlobal - 是否从全局或本地卸载
972
+ * @param {string} runtime - 目标运行时 ('claude', 'opencode', 'gemini', 'codex')
973
+ */
974
+ function uninstall(isGlobal, runtime = 'claude') {
975
+ const isOpencode = runtime === 'opencode';
976
+ const isCodex = runtime === 'codex';
977
+ const dirName = getDirName(runtime);
978
+
979
+ // 根据运行时和安装类型获取目标目录
980
+ const targetDir = isGlobal
981
+ ? getGlobalDir(runtime, explicitConfigDir)
982
+ : path.join(process.cwd(), dirName);
983
+
984
+ const locationLabel = isGlobal
985
+ ? targetDir.replace(os.homedir(), '~')
986
+ : targetDir.replace(process.cwd(), '.');
987
+
988
+ let runtimeLabel = 'Claude Code';
989
+ if (runtime === 'opencode') runtimeLabel = 'OpenCode';
990
+ if (runtime === 'gemini') runtimeLabel = 'Gemini';
991
+ if (runtime === 'codex') runtimeLabel = 'Codex';
992
+
993
+ console.log(` 正在从 ${cyan}${runtimeLabel}${reset} 卸载 SpecOps, 位置 ${cyan}${locationLabel}${reset}\n`);
994
+
995
+ // 检查目标目录是否存在
996
+ if (!fs.existsSync(targetDir)) {
997
+ console.log(` ${yellow}⚠${reset} 目录不存在: ${locationLabel}`);
998
+ console.log(` 没有需要卸载的内容。\n`);
999
+ return;
1000
+ }
1001
+
1002
+ let removedCount = 0;
1003
+
1004
+ // 1. 移除 SpecOps 命令/skills
1005
+ if (isOpencode) {
1006
+ // OpenCode: 移除 command/specops-*.md 文件
1007
+ const commandDir = path.join(targetDir, 'command');
1008
+ if (fs.existsSync(commandDir)) {
1009
+ const files = fs.readdirSync(commandDir);
1010
+ for (const file of files) {
1011
+ if (file.startsWith('specops-') && file.endsWith('.md')) {
1012
+ fs.unlinkSync(path.join(commandDir, file));
1013
+ removedCount++;
1014
+ }
1015
+ }
1016
+ console.log(` ${green}✓${reset} 已从 command/ 移除 SpecOps 命令`);
1017
+ }
1018
+ } else if (isCodex) {
1019
+ // Codex: 移除 skills/specops-*/SKILL.md skill 目录
1020
+ const skillsDir = path.join(targetDir, 'skills');
1021
+ if (fs.existsSync(skillsDir)) {
1022
+ let skillCount = 0;
1023
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
1024
+ for (const entry of entries) {
1025
+ if (entry.isDirectory() && entry.name.startsWith('specops-')) {
1026
+ fs.rmSync(path.join(skillsDir, entry.name), { recursive: true });
1027
+ skillCount++;
1028
+ }
1029
+ }
1030
+ if (skillCount > 0) {
1031
+ removedCount++;
1032
+ console.log(` ${green}✓${reset} 已移除 ${skillCount} 个 Codex skills`);
1033
+ }
1034
+ }
1035
+ } else {
1036
+ // Claude Code & Gemini: 移除 commands/specops/ 目录
1037
+ const specopsCommandsDir = path.join(targetDir, 'commands', 'specops');
1038
+ if (fs.existsSync(specopsCommandsDir)) {
1039
+ fs.rmSync(specopsCommandsDir, { recursive: true });
1040
+ removedCount++;
1041
+ console.log(` ${green}✓${reset} 已移除 commands/specops/`);
1042
+ }
1043
+ }
1044
+
1045
+ // 2. 移除 get-shit-done 目录
1046
+ const gsdDir = path.join(targetDir, 'get-shit-done');
1047
+ if (fs.existsSync(gsdDir)) {
1048
+ fs.rmSync(gsdDir, { recursive: true });
1049
+ removedCount++;
1050
+ console.log(` ${green}✓${reset} 已移除 get-shit-done/`);
1051
+ }
1052
+
1053
+ // 3. 移除 SpecOps agents (仅 specops-*.md 文件)
1054
+ const agentsDir = path.join(targetDir, 'agents');
1055
+ if (fs.existsSync(agentsDir)) {
1056
+ const files = fs.readdirSync(agentsDir);
1057
+ let agentCount = 0;
1058
+ for (const file of files) {
1059
+ if (file.startsWith('specops-') && file.endsWith('.md')) {
1060
+ fs.unlinkSync(path.join(agentsDir, file));
1061
+ agentCount++;
1062
+ }
1063
+ }
1064
+ if (agentCount > 0) {
1065
+ removedCount++;
1066
+ console.log(` ${green}✓${reset} 已移除 ${agentCount} 个 SpecOps agents`);
1067
+ }
1068
+ }
1069
+
1070
+ // 4. 移除 SpecOps 钩子
1071
+ const hooksDir = path.join(targetDir, 'hooks');
1072
+ if (fs.existsSync(hooksDir)) {
1073
+ const gsdHooks = ['specops-statusline.js', 'specops-check-update.js', 'specops-check-update.sh', 'specops-context-monitor.js'];
1074
+ let hookCount = 0;
1075
+ for (const hook of gsdHooks) {
1076
+ const hookPath = path.join(hooksDir, hook);
1077
+ if (fs.existsSync(hookPath)) {
1078
+ fs.unlinkSync(hookPath);
1079
+ hookCount++;
1080
+ }
1081
+ }
1082
+ if (hookCount > 0) {
1083
+ removedCount++;
1084
+ console.log(` ${green}✓${reset} 已移除 ${hookCount} 个 SpecOps 钩子`);
1085
+ }
1086
+ }
1087
+
1088
+ // 5. 移除 SpecOps package.json (CommonJS 模式标记)
1089
+ const pkgJsonPath = path.join(targetDir, 'package.json');
1090
+ if (fs.existsSync(pkgJsonPath)) {
1091
+ try {
1092
+ const content = fs.readFileSync(pkgJsonPath, 'utf8').trim();
1093
+ // 仅在是我们的最小 CommonJS 标记时移除
1094
+ if (content === '{"type":"commonjs"}') {
1095
+ fs.unlinkSync(pkgJsonPath);
1096
+ removedCount++;
1097
+ console.log(` ${green}✓${reset} 已移除 SpecOps package.json`);
1098
+ }
1099
+ } catch (e) {
1100
+ // 忽略读取错误
1101
+ }
1102
+ }
1103
+
1104
+ // 6. 清理 settings.json (移除 SpecOps 钩子和状态栏)
1105
+ const settingsPath = path.join(targetDir, 'settings.json');
1106
+ if (fs.existsSync(settingsPath)) {
1107
+ let settings = readSettings(settingsPath);
1108
+ let settingsModified = false;
1109
+
1110
+ // 如果引用了我们的钩子则移除 SpecOps 状态栏
1111
+ if (settings.statusLine && settings.statusLine.command &&
1112
+ settings.statusLine.command.includes('specops-statusline')) {
1113
+ delete settings.statusLine;
1114
+ settingsModified = true;
1115
+ console.log(` ${green}✓${reset} 已从设置中移除 SpecOps 状态栏`);
1116
+ }
1117
+
1118
+ // 从 SessionStart 移除 SpecOps 钩子
1119
+ if (settings.hooks && settings.hooks.SessionStart) {
1120
+ const before = settings.hooks.SessionStart.length;
1121
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => {
1122
+ if (entry.hooks && Array.isArray(entry.hooks)) {
1123
+ // 过滤掉 SpecOps 钩子
1124
+ const hasGsdHook = entry.hooks.some(h =>
1125
+ h.command && (h.command.includes('specops-check-update') || h.command.includes('specops-statusline'))
1126
+ );
1127
+ return !hasGsdHook;
1128
+ }
1129
+ return true;
1130
+ });
1131
+ if (settings.hooks.SessionStart.length < before) {
1132
+ settingsModified = true;
1133
+ console.log(` ${green}✓${reset} 已从设置中移除 SpecOps 钩子`);
1134
+ }
1135
+ // 清理空数组
1136
+ if (settings.hooks.SessionStart.length === 0) {
1137
+ delete settings.hooks.SessionStart;
1138
+ }
1139
+ }
1140
+
1141
+ // 从 PostToolUse 移除 SpecOps 钩子
1142
+ if (settings.hooks && settings.hooks.PostToolUse) {
1143
+ const before = settings.hooks.PostToolUse.length;
1144
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
1145
+ if (entry.hooks && Array.isArray(entry.hooks)) {
1146
+ const hasGsdHook = entry.hooks.some(h =>
1147
+ h.command && h.command.includes('specops-context-monitor')
1148
+ );
1149
+ return !hasGsdHook;
1150
+ }
1151
+ return true;
1152
+ });
1153
+ if (settings.hooks.PostToolUse.length < before) {
1154
+ settingsModified = true;
1155
+ console.log(` ${green}✓${reset} 已从设置中移除上下文监控器钩子`);
1156
+ }
1157
+ if (settings.hooks.PostToolUse.length === 0) {
1158
+ delete settings.hooks.PostToolUse;
1159
+ }
1160
+ }
1161
+
1162
+ // 清理空的 hooks 对象
1163
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
1164
+ delete settings.hooks;
1165
+ }
1166
+
1167
+ if (settingsModified) {
1168
+ writeSettings(settingsPath, settings);
1169
+ removedCount++;
1170
+ }
1171
+ }
1172
+
1173
+ // 6. 对于 OpenCode, 清理 opencode.json 中的权限
1174
+ if (isOpencode) {
1175
+ // 本地卸载清理 ./.opencode/opencode.json
1176
+ // 全局卸载清理 ~/.config/opencode/opencode.json
1177
+ const opencodeConfigDir = isGlobal
1178
+ ? getOpencodeGlobalDir()
1179
+ : path.join(process.cwd(), '.opencode');
1180
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
1181
+ if (fs.existsSync(configPath)) {
1182
+ try {
1183
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
1184
+ let modified = false;
1185
+
1186
+ // 移除 SpecOps 权限条目
1187
+ if (config.permission) {
1188
+ for (const permType of ['read', 'external_directory']) {
1189
+ if (config.permission[permType]) {
1190
+ const keys = Object.keys(config.permission[permType]);
1191
+ for (const key of keys) {
1192
+ if (key.includes('get-shit-done')) {
1193
+ delete config.permission[permType][key];
1194
+ modified = true;
1195
+ }
1196
+ }
1197
+ // 清理空对象
1198
+ if (Object.keys(config.permission[permType]).length === 0) {
1199
+ delete config.permission[permType];
1200
+ }
1201
+ }
1202
+ }
1203
+ if (Object.keys(config.permission).length === 0) {
1204
+ delete config.permission;
1205
+ }
1206
+ }
1207
+
1208
+ if (modified) {
1209
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1210
+ removedCount++;
1211
+ console.log(` ${green}✓${reset} 已从 opencode.json 移除 SpecOps 权限`);
1212
+ }
1213
+ } catch (e) {
1214
+ // 忽略 JSON 解析错误
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ if (removedCount === 0) {
1220
+ console.log(` ${yellow}⚠${reset} 未找到需要移除的 SpecOps 文件。`);
1221
+ }
1222
+
1223
+ console.log(`
1224
+ ${green}完成!${reset} SpecOps 已从 ${runtimeLabel} 卸载。
1225
+ 你的其他文件和设置已保留。
1226
+ `);
1227
+ }
1228
+
1229
+ /**
1230
+ * 解析 JSONC (带注释的 JSON), 去除注释和尾随逗号。
1231
+ * OpenCode 通过 jsonc-parser 支持 JSONC 格式, 所以用户可能有注释。
1232
+ * 这是一个轻量级内联解析器, 避免添加依赖。
1233
+ */
1234
+ function parseJsonc(content) {
1235
+ // 如果存在则去除 BOM
1236
+ if (content.charCodeAt(0) === 0xFEFF) {
1237
+ content = content.slice(1);
1238
+ }
1239
+
1240
+ // 移除单行和块注释, 同时保留字符串
1241
+ let result = '';
1242
+ let inString = false;
1243
+ let i = 0;
1244
+ while (i < content.length) {
1245
+ const char = content[i];
1246
+ const next = content[i + 1];
1247
+
1248
+ if (inString) {
1249
+ result += char;
1250
+ // 处理转义序列
1251
+ if (char === '\\' && i + 1 < content.length) {
1252
+ result += next;
1253
+ i += 2;
1254
+ continue;
1255
+ }
1256
+ if (char === '"') {
1257
+ inString = false;
1258
+ }
1259
+ i++;
1260
+ } else {
1261
+ if (char === '"') {
1262
+ inString = true;
1263
+ result += char;
1264
+ i++;
1265
+ } else if (char === '/' && next === '/') {
1266
+ // 跳过单行注释直到行尾
1267
+ while (i < content.length && content[i] !== '\n') {
1268
+ i++;
1269
+ }
1270
+ } else if (char === '/' && next === '*') {
1271
+ // 跳过块注释
1272
+ i += 2;
1273
+ while (i < content.length - 1 && !(content[i] === '*' && content[i + 1] === '/')) {
1274
+ i++;
1275
+ }
1276
+ i += 2; // 跳过结束的 */
1277
+ } else {
1278
+ result += char;
1279
+ i++;
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ // 移除 } 或 ] 前的尾随逗号
1285
+ result = result.replace(/,(\s*[}\]])/g, '$1');
1286
+
1287
+ return JSON.parse(result);
1288
+ }
1289
+
1290
+ /**
1291
+ * 配置 OpenCode 权限以允许读取 SpecOps 参考文档
1292
+ * 这防止 SpecOps 访问 get-shit-done 目录时出现权限提示
1293
+ * @param {boolean} isGlobal - 是否为全局或本地安装
1294
+ */
1295
+ function configureOpencodePermissions(isGlobal = true) {
1296
+ // 本地安装使用 ./.opencode/opencode.json
1297
+ // 全局安装使用 ~/.config/opencode/opencode.json
1298
+ const opencodeConfigDir = isGlobal
1299
+ ? getOpencodeGlobalDir()
1300
+ : path.join(process.cwd(), '.opencode');
1301
+ const configPath = path.join(opencodeConfigDir, 'opencode.json');
1302
+
1303
+ // 确保配置目录存在
1304
+ fs.mkdirSync(opencodeConfigDir, { recursive: true });
1305
+
1306
+ // 读取现有配置或创建空对象
1307
+ let config = {};
1308
+ if (fs.existsSync(configPath)) {
1309
+ try {
1310
+ const content = fs.readFileSync(configPath, 'utf8');
1311
+ config = parseJsonc(content);
1312
+ } catch (e) {
1313
+ // 无法解析 - 不要覆盖用户的配置
1314
+ console.log(` ${yellow}⚠${reset} 无法解析 opencode.json - 跳过权限配置`);
1315
+ console.log(` ${dim}原因: ${e.message}${reset}`);
1316
+ console.log(` ${dim}你的配置未被修改。如需要请手动修复语法。${reset}`);
1317
+ return;
1318
+ }
1319
+ }
1320
+
1321
+ // 确保权限结构存在
1322
+ if (!config.permission) {
1323
+ config.permission = {};
1324
+ }
1325
+
1326
+ // 使用实际配置目录构建 SpecOps 路径
1327
+ // 如果在默认位置则使用 ~ 简写, 否则使用完整路径
1328
+ const defaultConfigDir = path.join(os.homedir(), '.config', 'opencode');
1329
+ const gsdPath = opencodeConfigDir === defaultConfigDir
1330
+ ? '~/.config/opencode/get-shit-done/*'
1331
+ : `${opencodeConfigDir.replace(/\\/g, '/')}/get-shit-done/*`;
1332
+
1333
+ let modified = false;
1334
+
1335
+ // 配置读取权限
1336
+ if (!config.permission.read || typeof config.permission.read !== 'object') {
1337
+ config.permission.read = {};
1338
+ }
1339
+ if (config.permission.read[gsdPath] !== 'allow') {
1340
+ config.permission.read[gsdPath] = 'allow';
1341
+ modified = true;
1342
+ }
1343
+
1344
+ // 配置外部目录权限 (项目外路径的安全守卫)
1345
+ if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
1346
+ config.permission.external_directory = {};
1347
+ }
1348
+ if (config.permission.external_directory[gsdPath] !== 'allow') {
1349
+ config.permission.external_directory[gsdPath] = 'allow';
1350
+ modified = true;
1351
+ }
1352
+
1353
+ if (!modified) {
1354
+ return; // 已配置
1355
+ }
1356
+
1357
+ // 写回配置
1358
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
1359
+ console.log(` ${green}✓${reset} 已配置 SpecOps 文档的读取权限`);
1360
+ }
1361
+
1362
+ /**
1363
+ * 验证目录存在且包含文件
1364
+ */
1365
+ function verifyInstalled(dirPath, description) {
1366
+ if (!fs.existsSync(dirPath)) {
1367
+ console.error(` ${yellow}✗${reset} 安装 ${description} 失败: 目录未创建`);
1368
+ return false;
1369
+ }
1370
+ try {
1371
+ const entries = fs.readdirSync(dirPath);
1372
+ if (entries.length === 0) {
1373
+ console.error(` ${yellow}✗${reset} 安装 ${description} 失败: 目录为空`);
1374
+ return false;
1375
+ }
1376
+ } catch (e) {
1377
+ console.error(` ${yellow}✗${reset} 安装 ${description} 失败: ${e.message}`);
1378
+ return false;
1379
+ }
1380
+ return true;
1381
+ }
1382
+
1383
+ /**
1384
+ * 验证文件存在
1385
+ */
1386
+ function verifyFileInstalled(filePath, description) {
1387
+ if (!fs.existsSync(filePath)) {
1388
+ console.error(` ${yellow}✗${reset} 安装 ${description} 失败: 文件未创建`);
1389
+ return false;
1390
+ }
1391
+ return true;
1392
+ }
1393
+
1394
+ /**
1395
+ * 安装到指定目录的特定运行时
1396
+ * @param {boolean} isGlobal - 是否全局安装或本地安装
1397
+ * @param {string} runtime - 目标运行时 ('claude', 'opencode', 'gemini', 'codex')
1398
+ */
1399
+
1400
+ // ──────────────────────────────────────────────────────
1401
+ // 本地补丁持久化
1402
+ // ──────────────────────────────────────────────────────
1403
+
1404
+ const PATCHES_DIR_NAME = 'specops-local-patches';
1405
+ const MANIFEST_NAME = 'specops-file-manifest.json';
1406
+
1407
+ /**
1408
+ * 计算文件内容的 SHA256 哈希
1409
+ */
1410
+ function fileHash(filePath) {
1411
+ const content = fs.readFileSync(filePath);
1412
+ return crypto.createHash('sha256').update(content).digest('hex');
1413
+ }
1414
+
1415
+ /**
1416
+ * 递归收集目录中所有文件及其哈希
1417
+ */
1418
+ function generateManifest(dir, baseDir) {
1419
+ if (!baseDir) baseDir = dir;
1420
+ const manifest = {};
1421
+ if (!fs.existsSync(dir)) return manifest;
1422
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1423
+ for (const entry of entries) {
1424
+ const fullPath = path.join(dir, entry.name);
1425
+ const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
1426
+ if (entry.isDirectory()) {
1427
+ Object.assign(manifest, generateManifest(fullPath, baseDir));
1428
+ } else {
1429
+ manifest[relPath] = fileHash(fullPath);
1430
+ }
1431
+ }
1432
+ return manifest;
1433
+ }
1434
+
1435
+ /**
1436
+ * 安装后写入文件清单, 用于未来的修改检测
1437
+ */
1438
+ function writeManifest(configDir, runtime = 'claude') {
1439
+ const isOpencode = runtime === 'opencode';
1440
+ const isCodex = runtime === 'codex';
1441
+ const gsdDir = path.join(configDir, 'get-shit-done');
1442
+ const commandsDir = path.join(configDir, 'commands', 'specops');
1443
+ const opencodeCommandDir = path.join(configDir, 'command');
1444
+ const codexSkillsDir = path.join(configDir, 'skills');
1445
+ const agentsDir = path.join(configDir, 'agents');
1446
+ const manifest = { version: pkg.version, timestamp: new Date().toISOString(), files: {} };
1447
+
1448
+ const gsdHashes = generateManifest(gsdDir);
1449
+ for (const [rel, hash] of Object.entries(gsdHashes)) {
1450
+ manifest.files['get-shit-done/' + rel] = hash;
1451
+ }
1452
+ if (!isOpencode && !isCodex && fs.existsSync(commandsDir)) {
1453
+ const cmdHashes = generateManifest(commandsDir);
1454
+ for (const [rel, hash] of Object.entries(cmdHashes)) {
1455
+ manifest.files['commands/specops/' + rel] = hash;
1456
+ }
1457
+ }
1458
+ if (isOpencode && fs.existsSync(opencodeCommandDir)) {
1459
+ for (const file of fs.readdirSync(opencodeCommandDir)) {
1460
+ if (file.startsWith('specops-') && file.endsWith('.md')) {
1461
+ manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
1462
+ }
1463
+ }
1464
+ }
1465
+ if (isCodex && fs.existsSync(codexSkillsDir)) {
1466
+ for (const skillName of listCodexSkillNames(codexSkillsDir)) {
1467
+ const skillRoot = path.join(codexSkillsDir, skillName);
1468
+ const skillHashes = generateManifest(skillRoot);
1469
+ for (const [rel, hash] of Object.entries(skillHashes)) {
1470
+ manifest.files[`skills/${skillName}/${rel}`] = hash;
1471
+ }
1472
+ }
1473
+ }
1474
+ if (fs.existsSync(agentsDir)) {
1475
+ for (const file of fs.readdirSync(agentsDir)) {
1476
+ if (file.startsWith('specops-') && file.endsWith('.md')) {
1477
+ manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
1478
+ }
1479
+ }
1480
+ }
1481
+
1482
+ fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
1483
+ return manifest;
1484
+ }
1485
+
1486
+ /**
1487
+ * 通过与安装清单比较来检测用户修改的 SpecOps 文件。
1488
+ * 将修改的文件备份到 specops-local-patches/ 以便更新后重新应用。
1489
+ */
1490
+ function saveLocalPatches(configDir) {
1491
+ const manifestPath = path.join(configDir, MANIFEST_NAME);
1492
+ if (!fs.existsSync(manifestPath)) return [];
1493
+
1494
+ let manifest;
1495
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; }
1496
+
1497
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1498
+ const modified = [];
1499
+
1500
+ for (const [relPath, originalHash] of Object.entries(manifest.files || {})) {
1501
+ const fullPath = path.join(configDir, relPath);
1502
+ if (!fs.existsSync(fullPath)) continue;
1503
+ const currentHash = fileHash(fullPath);
1504
+ if (currentHash !== originalHash) {
1505
+ const backupPath = path.join(patchesDir, relPath);
1506
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
1507
+ fs.copyFileSync(fullPath, backupPath);
1508
+ modified.push(relPath);
1509
+ }
1510
+ }
1511
+
1512
+ if (modified.length > 0) {
1513
+ const meta = {
1514
+ backed_up_at: new Date().toISOString(),
1515
+ from_version: manifest.version,
1516
+ files: modified
1517
+ };
1518
+ fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2));
1519
+ console.log(' ' + yellow + 'i' + reset + ' 发现 ' + modified.length + ' 个本地修改的 SpecOps 文件, 已备份到 ' + PATCHES_DIR_NAME + '/');
1520
+ for (const f of modified) {
1521
+ console.log(' ' + dim + f + reset);
1522
+ }
1523
+ }
1524
+ return modified;
1525
+ }
1526
+
1527
+ /**
1528
+ * 安装后报告已备份的补丁, 供用户重新应用。
1529
+ */
1530
+ function reportLocalPatches(configDir, runtime = 'claude') {
1531
+ const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
1532
+ const metaPath = path.join(patchesDir, 'backup-meta.json');
1533
+ if (!fs.existsSync(metaPath)) return [];
1534
+
1535
+ let meta;
1536
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
1537
+
1538
+ if (meta.files && meta.files.length > 0) {
1539
+ const reapplyCommand = runtime === 'opencode'
1540
+ ? '/specops-reapply-patches'
1541
+ : runtime === 'codex'
1542
+ ? '$specops-reapply-patches'
1543
+ : '/specops:reapply-patches';
1544
+ console.log('');
1545
+ console.log(' ' + yellow + '检测到本地补丁' + reset + ' (来自 v' + meta.from_version + '):');
1546
+ for (const f of meta.files) {
1547
+ console.log(' ' + cyan + f + reset);
1548
+ }
1549
+ console.log('');
1550
+ console.log(' 你的修改已保存在 ' + cyan + PATCHES_DIR_NAME + '/' + reset);
1551
+ console.log(' 运行 ' + cyan + reapplyCommand + reset + ' 将它们合并到新版本。');
1552
+ console.log(' 或手动比较并合并文件。');
1553
+ console.log('');
1554
+ }
1555
+ return meta.files || [];
1556
+ }
1557
+
1558
+ function install(isGlobal, runtime = 'claude') {
1559
+ const isOpencode = runtime === 'opencode';
1560
+ const isGemini = runtime === 'gemini';
1561
+ const isCodex = runtime === 'codex';
1562
+ const dirName = getDirName(runtime);
1563
+ const src = path.join(__dirname, '..');
1564
+
1565
+ // 根据运行时和安装类型获取目标目录
1566
+ const targetDir = isGlobal
1567
+ ? getGlobalDir(runtime, explicitConfigDir)
1568
+ : path.join(process.cwd(), dirName);
1569
+
1570
+ const locationLabel = isGlobal
1571
+ ? targetDir.replace(os.homedir(), '~')
1572
+ : targetDir.replace(process.cwd(), '.');
1573
+
1574
+ // markdown 内容中文件引用的路径前缀
1575
+ // 全局安装: 使用完整路径
1576
+ // 本地安装: 使用相对路径
1577
+ const pathPrefix = isGlobal
1578
+ ? `${targetDir.replace(/\\/g, '/')}/`
1579
+ : `./${dirName}/`;
1580
+
1581
+ let runtimeLabel = 'Claude Code';
1582
+ if (isOpencode) runtimeLabel = 'OpenCode';
1583
+ if (isGemini) runtimeLabel = 'Gemini';
1584
+ if (isCodex) runtimeLabel = 'Codex';
1585
+
1586
+ console.log(` 正在为 ${cyan}${runtimeLabel}${reset} 安装到 ${cyan}${locationLabel}${reset}\n`);
1587
+
1588
+ // 跟踪安装失败
1589
+ const failures = [];
1590
+
1591
+ // 在被覆盖前保存任何本地修改的 SpecOps 文件
1592
+ saveLocalPatches(targetDir);
1593
+
1594
+ // 清理旧版本的孤立文件
1595
+ cleanupOrphanedFiles(targetDir);
1596
+
1597
+ // OpenCode 使用 command/ (扁平), Codex 使用 skills/, Claude/Gemini 使用 commands/specops/
1598
+ if (isOpencode) {
1599
+ // OpenCode: command/ 目录中的扁平结构
1600
+ const commandDir = path.join(targetDir, 'command');
1601
+ fs.mkdirSync(commandDir, { recursive: true });
1602
+
1603
+ // 将 commands/specops/*.md 复制为 command/specops-*.md (扁平化结构)
1604
+ const specopsSrc = path.join(src, 'commands', 'specops');
1605
+ copyFlattenedCommands(specopsSrc, commandDir, 'specops', pathPrefix, runtime);
1606
+ if (verifyInstalled(commandDir, 'command/specops-*')) {
1607
+ const count = fs.readdirSync(commandDir).filter(f => f.startsWith('specops-')).length;
1608
+ console.log(` ${green}✓${reset} 已安装 ${count} 个命令到 command/`);
1609
+ } else {
1610
+ failures.push('command/specops-*');
1611
+ }
1612
+ } else if (isCodex) {
1613
+ const skillsDir = path.join(targetDir, 'skills');
1614
+ const specopsSrc = path.join(src, 'commands', 'specops');
1615
+ copyCommandsAsCodexSkills(specopsSrc, skillsDir, 'specops', pathPrefix, runtime);
1616
+ const installedSkillNames = listCodexSkillNames(skillsDir);
1617
+ if (installedSkillNames.length > 0) {
1618
+ console.log(` ${green}✓${reset} 已安装 ${installedSkillNames.length} 个 skills 到 skills/`);
1619
+ } else {
1620
+ failures.push('skills/specops-*');
1621
+ }
1622
+ } else {
1623
+ // Claude Code & Gemini: commands/ 目录中的嵌套结构
1624
+ const commandsDir = path.join(targetDir, 'commands');
1625
+ fs.mkdirSync(commandsDir, { recursive: true });
1626
+
1627
+ const specopsSrc = path.join(src, 'commands', 'specops');
1628
+ const specopsDest = path.join(commandsDir, 'specops');
1629
+ copyWithPathReplacement(specopsSrc, specopsDest, pathPrefix, runtime, true);
1630
+ if (verifyInstalled(specopsDest, 'commands/specops')) {
1631
+ console.log(` ${green}✓${reset} 已安装 commands/specops`);
1632
+ } else {
1633
+ failures.push('commands/specops');
1634
+ }
1635
+ }
1636
+
1637
+ // 复制 get-shit-done skill 并替换路径
1638
+ const skillSrc = path.join(src, 'get-shit-done');
1639
+ const skillDest = path.join(targetDir, 'get-shit-done');
1640
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime);
1641
+ if (verifyInstalled(skillDest, 'get-shit-done')) {
1642
+ console.log(` ${green}✓${reset} 已安装 get-shit-done`);
1643
+ } else {
1644
+ failures.push('get-shit-done');
1645
+ }
1646
+
1647
+ // 复制 agents 到 agents 目录
1648
+ const agentsSrc = path.join(src, 'agents');
1649
+ if (fs.existsSync(agentsSrc)) {
1650
+ const agentsDest = path.join(targetDir, 'agents');
1651
+ fs.mkdirSync(agentsDest, { recursive: true });
1652
+
1653
+ // 复制新的之前移除旧的 SpecOps agents (specops-*.md)
1654
+ if (fs.existsSync(agentsDest)) {
1655
+ for (const file of fs.readdirSync(agentsDest)) {
1656
+ if (file.startsWith('specops-') && file.endsWith('.md')) {
1657
+ fs.unlinkSync(path.join(agentsDest, file));
1658
+ }
1659
+ }
1660
+ }
1661
+
1662
+ // 复制新 agents
1663
+ const agentEntries = fs.readdirSync(agentsSrc, { withFileTypes: true });
1664
+ for (const entry of agentEntries) {
1665
+ if (entry.isFile() && entry.name.endsWith('.md')) {
1666
+ let content = fs.readFileSync(path.join(agentsSrc, entry.name), 'utf8');
1667
+ // 始终替换 ~/.claude/ 因为它是仓库中的真实来源
1668
+ const dirRegex = /~\/\.claude\//g;
1669
+ content = content.replace(dirRegex, pathPrefix);
1670
+ content = processAttribution(content, getCommitAttribution(runtime));
1671
+ // 为运行时兼容性转换前置元数据
1672
+ if (isOpencode) {
1673
+ content = convertClaudeToOpencodeFrontmatter(content);
1674
+ } else if (isGemini) {
1675
+ content = convertClaudeToGeminiAgent(content);
1676
+ } else if (isCodex) {
1677
+ content = convertClaudeToCodexMarkdown(content);
1678
+ }
1679
+ fs.writeFileSync(path.join(agentsDest, entry.name), content);
1680
+ }
1681
+ }
1682
+ if (verifyInstalled(agentsDest, 'agents')) {
1683
+ console.log(` ${green}✓${reset} 已安装 agents`);
1684
+ } else {
1685
+ failures.push('agents');
1686
+ }
1687
+ }
1688
+
1689
+ // 复制 CHANGELOG.md
1690
+ const changelogSrc = path.join(src, 'CHANGELOG.md');
1691
+ const changelogDest = path.join(targetDir, 'get-shit-done', 'CHANGELOG.md');
1692
+ if (fs.existsSync(changelogSrc)) {
1693
+ fs.copyFileSync(changelogSrc, changelogDest);
1694
+ if (verifyFileInstalled(changelogDest, 'CHANGELOG.md')) {
1695
+ console.log(` ${green}✓${reset} 已安装 CHANGELOG.md`);
1696
+ } else {
1697
+ failures.push('CHANGELOG.md');
1698
+ }
1699
+ }
1700
+
1701
+ // 写入 VERSION 文件
1702
+ const versionDest = path.join(targetDir, 'get-shit-done', 'VERSION');
1703
+ fs.writeFileSync(versionDest, pkg.version);
1704
+ if (verifyFileInstalled(versionDest, 'VERSION')) {
1705
+ console.log(` ${green}✓${reset} 已写入 VERSION (${pkg.version})`);
1706
+ } else {
1707
+ failures.push('VERSION');
1708
+ }
1709
+
1710
+ if (!isCodex) {
1711
+ // 写入 package.json 以强制 CommonJS 模式用于 SpecOps 脚本
1712
+ // 防止项目有 "type": "module" 时出现 "require is not defined" 错误
1713
+ // Node.js 向上查找 package.json, 这阻止了从项目继承
1714
+ const pkgJsonDest = path.join(targetDir, 'package.json');
1715
+ fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
1716
+ console.log(` ${green}✓${reset} 已写入 package.json (CommonJS 模式)`);
1717
+
1718
+ // 从 dist/ 复制钩子 (与依赖一起打包)
1719
+ // 为目标运行时模板化路径 (将 '.claude' 替换为正确的配置目录)
1720
+ const hooksSrc = path.join(src, 'hooks', 'dist');
1721
+ if (fs.existsSync(hooksSrc)) {
1722
+ const hooksDest = path.join(targetDir, 'hooks');
1723
+ fs.mkdirSync(hooksDest, { recursive: true });
1724
+ const hookEntries = fs.readdirSync(hooksSrc);
1725
+ const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
1726
+ for (const entry of hookEntries) {
1727
+ const srcFile = path.join(hooksSrc, entry);
1728
+ if (fs.statSync(srcFile).isFile()) {
1729
+ const destFile = path.join(hooksDest, entry);
1730
+ // 模板化 .js 文件, 将 '.claude' 替换为运行时特定的配置目录
1731
+ if (entry.endsWith('.js')) {
1732
+ let content = fs.readFileSync(srcFile, 'utf8');
1733
+ content = content.replace(/'\.claude'/g, configDirReplacement);
1734
+ fs.writeFileSync(destFile, content);
1735
+ } else {
1736
+ fs.copyFileSync(srcFile, destFile);
1737
+ }
1738
+ }
1739
+ }
1740
+ if (verifyInstalled(hooksDest, 'hooks')) {
1741
+ console.log(` ${green}✓${reset} 已安装钩子 (已打包)`);
1742
+ } else {
1743
+ failures.push('hooks');
1744
+ }
1745
+ }
1746
+ }
1747
+
1748
+ if (failures.length > 0) {
1749
+ console.error(`\n ${yellow}安装不完整!${reset} 失败: ${failures.join(', ')}`);
1750
+ process.exit(1);
1751
+ }
1752
+
1753
+ // 写入文件清单用于未来的修改检测
1754
+ writeManifest(targetDir, runtime);
1755
+ console.log(` ${green}✓${reset} 已写入文件清单 (${MANIFEST_NAME})`);
1756
+
1757
+ // 报告任何已备份的本地补丁
1758
+ reportLocalPatches(targetDir, runtime);
1759
+
1760
+ if (isCodex) {
1761
+ return { settingsPath: null, settings: null, statuslineCommand: null, runtime };
1762
+ }
1763
+
1764
+ // 在 settings.json 中配置状态栏和钩子
1765
+ // Gemini 目前与 Claude Code 共享相同的钩子系统
1766
+ const settingsPath = path.join(targetDir, 'settings.json');
1767
+ const settings = cleanupOrphanedHooks(readSettings(settingsPath));
1768
+ const statuslineCommand = isGlobal
1769
+ ? buildHookCommand(targetDir, 'specops-statusline.js')
1770
+ : 'node ' + dirName + '/hooks/specops-statusline.js';
1771
+ const updateCheckCommand = isGlobal
1772
+ ? buildHookCommand(targetDir, 'specops-check-update.js')
1773
+ : 'node ' + dirName + '/hooks/specops-check-update.js';
1774
+ const contextMonitorCommand = isGlobal
1775
+ ? buildHookCommand(targetDir, 'specops-context-monitor.js')
1776
+ : 'node ' + dirName + '/hooks/specops-context-monitor.js';
1777
+
1778
+ // 为 Gemini CLI 启用实验性 agents (自定义子 Agent 所需)
1779
+ if (isGemini) {
1780
+ if (!settings.experimental) {
1781
+ settings.experimental = {};
1782
+ }
1783
+ if (!settings.experimental.enableAgents) {
1784
+ settings.experimental.enableAgents = true;
1785
+ console.log(` ${green}✓${reset} 已启用实验性 agents`);
1786
+ }
1787
+ }
1788
+
1789
+ // 配置 SessionStart 钩子用于更新检查 (opencode 跳过)
1790
+ if (!isOpencode) {
1791
+ if (!settings.hooks) {
1792
+ settings.hooks = {};
1793
+ }
1794
+ if (!settings.hooks.SessionStart) {
1795
+ settings.hooks.SessionStart = [];
1796
+ }
1797
+
1798
+ const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
1799
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('specops-check-update'))
1800
+ );
1801
+
1802
+ if (!hasGsdUpdateHook) {
1803
+ settings.hooks.SessionStart.push({
1804
+ hooks: [
1805
+ {
1806
+ type: 'command',
1807
+ command: updateCheckCommand
1808
+ }
1809
+ ]
1810
+ });
1811
+ console.log(` ${green}✓${reset} 已配置更新检查钩子`);
1812
+ }
1813
+
1814
+ // 配置 PostToolUse 钩子用于上下文窗口监控
1815
+ if (!settings.hooks.PostToolUse) {
1816
+ settings.hooks.PostToolUse = [];
1817
+ }
1818
+
1819
+ const hasContextMonitorHook = settings.hooks.PostToolUse.some(entry =>
1820
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('specops-context-monitor'))
1821
+ );
1822
+
1823
+ if (!hasContextMonitorHook) {
1824
+ settings.hooks.PostToolUse.push({
1825
+ hooks: [
1826
+ {
1827
+ type: 'command',
1828
+ command: contextMonitorCommand
1829
+ }
1830
+ ]
1831
+ });
1832
+ console.log(` ${green}✓${reset} 已配置上下文窗口监控钩子`);
1833
+ }
1834
+ }
1835
+
1836
+ return { settingsPath, settings, statuslineCommand, runtime };
1837
+ }
1838
+
1839
+ /**
1840
+ * 应用状态栏配置, 然后打印完成消息
1841
+ */
1842
+ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true) {
1843
+ const isOpencode = runtime === 'opencode';
1844
+ const isCodex = runtime === 'codex';
1845
+
1846
+ if (shouldInstallStatusline && !isOpencode && !isCodex) {
1847
+ settings.statusLine = {
1848
+ type: 'command',
1849
+ command: statuslineCommand
1850
+ };
1851
+ console.log(` ${green}✓${reset} 已配置状态栏`);
1852
+ }
1853
+
1854
+ // 运行时支持 settings.json 时写入设置
1855
+ if (!isCodex) {
1856
+ writeSettings(settingsPath, settings);
1857
+ }
1858
+
1859
+ // 配置 OpenCode 权限
1860
+ if (isOpencode) {
1861
+ configureOpencodePermissions(isGlobal);
1862
+ }
1863
+
1864
+ let program = 'Claude Code';
1865
+ if (runtime === 'opencode') program = 'OpenCode';
1866
+ if (runtime === 'gemini') program = 'Gemini';
1867
+ if (runtime === 'codex') program = 'Codex';
1868
+
1869
+ let command = '/specops:help';
1870
+ if (runtime === 'opencode') command = '/specops-help';
1871
+ if (runtime === 'codex') command = '$specops-help';
1872
+ console.log(`
1873
+ ${green}完成!${reset} 启动 ${program} 并运行 ${cyan}${command}${reset}。
1874
+
1875
+ ${cyan}加入社区:${reset} https://discord.gg/5JJgD5svVS
1876
+ `);
1877
+ }
1878
+
1879
+ /**
1880
+ * 处理状态栏配置, 可选提示
1881
+ */
1882
+ function handleStatusline(settings, isInteractive, callback) {
1883
+ const hasExisting = settings.statusLine != null;
1884
+
1885
+ if (!hasExisting) {
1886
+ callback(true);
1887
+ return;
1888
+ }
1889
+
1890
+ if (forceStatusline) {
1891
+ callback(true);
1892
+ return;
1893
+ }
1894
+
1895
+ if (!isInteractive) {
1896
+ console.log(` ${yellow}⚠${reset} 跳过状态栏 (已有配置)`);
1897
+ console.log(` 使用 ${cyan}--force-statusline${reset} 来替换\n`);
1898
+ callback(false);
1899
+ return;
1900
+ }
1901
+
1902
+ const existingCmd = settings.statusLine.command || settings.statusLine.url || '(自定义)';
1903
+
1904
+ const rl = readline.createInterface({
1905
+ input: process.stdin,
1906
+ output: process.stdout
1907
+ });
1908
+
1909
+ console.log(`
1910
+ ${yellow}⚠${reset} 检测到现有状态栏\n
1911
+ 你当前的状态栏:
1912
+ ${dim}command: ${existingCmd}${reset}
1913
+
1914
+ SpecOps 包含一个状态栏, 显示:
1915
+ • 模型名称
1916
+ • 当前任务 (来自 todo 列表)
1917
+ • 上下文窗口使用率 (颜色编码)
1918
+
1919
+ ${cyan}1${reset}) 保留现有
1920
+ ${cyan}2${reset}) 替换为 SpecOps 状态栏
1921
+ `);
1922
+
1923
+ rl.question(` 选择 ${dim}[1]${reset}: `, (answer) => {
1924
+ rl.close();
1925
+ const choice = answer.trim() || '1';
1926
+ callback(choice === '2');
1927
+ });
1928
+ }
1929
+
1930
+ /**
1931
+ * 提示选择运行时
1932
+ */
1933
+ function promptRuntime(callback) {
1934
+ const rl = readline.createInterface({
1935
+ input: process.stdin,
1936
+ output: process.stdout
1937
+ });
1938
+
1939
+ let answered = false;
1940
+
1941
+ rl.on('close', () => {
1942
+ if (!answered) {
1943
+ answered = true;
1944
+ console.log(`\n ${yellow}安装已取消${reset}\n`);
1945
+ process.exit(0);
1946
+ }
1947
+ });
1948
+
1949
+ console.log(` ${yellow}你想为哪个运行时安装?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset}
1950
+ ${cyan}2${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - 开源, 免费模型
1951
+ ${cyan}3${reset}) Gemini ${dim}(~/.gemini)${reset}
1952
+ ${cyan}4${reset}) Codex ${dim}(~/.codex)${reset}
1953
+ ${cyan}5${reset}) 全部
1954
+ `);
1955
+
1956
+ rl.question(` 选择 ${dim}[1]${reset}: `, (answer) => {
1957
+ answered = true;
1958
+ rl.close();
1959
+ const choice = answer.trim() || '1';
1960
+ if (choice === '5') {
1961
+ callback(['claude', 'opencode', 'gemini', 'codex']);
1962
+ } else if (choice === '4') {
1963
+ callback(['codex']);
1964
+ } else if (choice === '3') {
1965
+ callback(['gemini']);
1966
+ } else if (choice === '2') {
1967
+ callback(['opencode']);
1968
+ } else {
1969
+ callback(['claude']);
1970
+ }
1971
+ });
1972
+ }
1973
+
1974
+ /**
1975
+ * 提示选择安装位置
1976
+ */
1977
+ function promptLocation(runtimes) {
1978
+ if (!process.stdin.isTTY) {
1979
+ console.log(` ${yellow}检测到非交互式终端, 默认全局安装${reset}\n`);
1980
+ installAllRuntimes(runtimes, true, false);
1981
+ return;
1982
+ }
1983
+
1984
+ const rl = readline.createInterface({
1985
+ input: process.stdin,
1986
+ output: process.stdout
1987
+ });
1988
+
1989
+ let answered = false;
1990
+
1991
+ rl.on('close', () => {
1992
+ if (!answered) {
1993
+ answered = true;
1994
+ console.log(`\n ${yellow}安装已取消${reset}\n`);
1995
+ process.exit(0);
1996
+ }
1997
+ });
1998
+
1999
+ const pathExamples = runtimes.map(r => {
2000
+ const globalPath = getGlobalDir(r, explicitConfigDir);
2001
+ return globalPath.replace(os.homedir(), '~');
2002
+ }).join(', ');
2003
+
2004
+ const localExamples = runtimes.map(r => `./${getDirName(r)}`).join(', ');
2005
+
2006
+ console.log(` ${yellow}你想安装到哪里?${reset}\n\n ${cyan}1${reset}) 全局 ${dim}(${pathExamples})${reset} - 所有项目可用
2007
+ ${cyan}2${reset}) 本地 ${dim}(${localExamples})${reset} - 仅当前项目
2008
+ `);
2009
+
2010
+ rl.question(` 选择 ${dim}[1]${reset}: `, (answer) => {
2011
+ answered = true;
2012
+ rl.close();
2013
+ const choice = answer.trim() || '1';
2014
+ const isGlobal = choice !== '2';
2015
+ installAllRuntimes(runtimes, isGlobal, true);
2016
+ });
2017
+ }
2018
+
2019
+ /**
2020
+ * 为所有选定的运行时安装 SpecOps
2021
+ */
2022
+ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
2023
+ const results = [];
2024
+
2025
+ for (const runtime of runtimes) {
2026
+ const result = install(isGlobal, runtime);
2027
+ results.push(result);
2028
+ }
2029
+
2030
+ const statuslineRuntimes = ['claude', 'gemini'];
2031
+ const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
2032
+
2033
+ const finalize = (shouldInstallStatusline) => {
2034
+ for (const result of results) {
2035
+ const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
2036
+ finishInstall(
2037
+ result.settingsPath,
2038
+ result.settings,
2039
+ result.statuslineCommand,
2040
+ useStatusline,
2041
+ result.runtime,
2042
+ isGlobal
2043
+ );
2044
+ }
2045
+ };
2046
+
2047
+ if (primaryStatuslineResult) {
2048
+ handleStatusline(primaryStatuslineResult.settings, isInteractive, finalize);
2049
+ } else {
2050
+ finalize(false);
2051
+ }
2052
+ }
2053
+
2054
+ // 主逻辑
2055
+ if (hasGlobal && hasLocal) {
2056
+ console.error(` ${yellow}不能同时指定 --global 和 --local${reset}`);
2057
+ process.exit(1);
2058
+ } else if (explicitConfigDir && hasLocal) {
2059
+ console.error(` ${yellow}不能将 --config-dir 与 --local 一起使用${reset}`);
2060
+ process.exit(1);
2061
+ } else if (hasUninstall) {
2062
+ if (!hasGlobal && !hasLocal) {
2063
+ console.error(` ${yellow}--uninstall 需要 --global 或 --local${reset}`);
2064
+ process.exit(1);
2065
+ }
2066
+ const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
2067
+ for (const runtime of runtimes) {
2068
+ uninstall(hasGlobal, runtime);
2069
+ }
2070
+ } else if (selectedRuntimes.length > 0) {
2071
+ if (!hasGlobal && !hasLocal) {
2072
+ promptLocation(selectedRuntimes);
2073
+ } else {
2074
+ installAllRuntimes(selectedRuntimes, hasGlobal, false);
2075
+ }
2076
+ } else if (hasGlobal || hasLocal) {
2077
+ // 如果没有指定运行时但指定了位置, 默认 Claude
2078
+ installAllRuntimes(['claude'], hasGlobal, false);
2079
+ } else {
2080
+ // 交互式
2081
+ if (!process.stdin.isTTY) {
2082
+ console.log(` ${yellow}检测到非交互式终端, 默认全局安装 Claude Code${reset}\n`);
2083
+ installAllRuntimes(['claude'], true, false);
2084
+ } else {
2085
+ promptRuntime((runtimes) => {
2086
+ promptLocation(runtimes);
2087
+ });
2088
+ }
2089
+ }