@alenfitz/spec-copilot 1.0.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/bin/cli.js ADDED
@@ -0,0 +1,788 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @alenfitz/spec-copilot CLI — 渐进式 Spec 编码框架(多工具统一版)
5
+ *
6
+ * 支持工具:opencode / claude-code / cursor / windsurf / copilot / cline
7
+ *
8
+ * 命令:
9
+ * npx @alenfitz/spec-copilot install [--tool <name>] 初始化项目
10
+ * npx @alenfitz/spec-copilot update [--force] 升级框架文件
11
+ * npx @alenfitz/spec-copilot gate <name> <phase> 阶段门禁检查
12
+ * npx @alenfitz/spec-copilot lint [name] Spec 完整性检查
13
+ * npx @alenfitz/spec-copilot doctor 检查安装状态
14
+ * npx @alenfitz/spec-copilot uninstall [--confirm] 移除框架文件
15
+ *
16
+ * 零外部依赖,仅使用 Node.js 内置模块。
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { execSync } = require('child_process');
22
+ const { adapters, detectTools, supportedTools } = require('../adapters');
23
+
24
+ // ─── 常量 ───────────────────────────────────────────────────
25
+
26
+ const BUILTIN_ADAPTERS = ['_template.md', 'README.md', 'spring-boot-vue3.md'];
27
+ const TOOL_STATE_FILE = '.spec-copilot-tool'; // 记录使用的工具
28
+
29
+ // ─── 工具函数 ───────────────────────────────────────────────
30
+
31
+ const log = {
32
+ ok(msg) { console.log(`\x1b[32m✓\x1b[0m ${msg}`); },
33
+ warn(msg) { console.log(`\x1b[33m⚠\x1b[0m ${msg}`); },
34
+ err(msg) { console.log(`\x1b[31m✗\x1b[0m ${msg}`); },
35
+ info(msg) { console.log(` ${msg}`); },
36
+ title(msg){ console.log(`\n\x1b[1m${msg}\x1b[0m`); },
37
+ };
38
+
39
+ function copyDir(src, dest, options = {}) {
40
+ const { overwrite = true, exclude = [] } = options;
41
+ if (!fs.existsSync(src)) return;
42
+ fs.mkdirSync(dest, { recursive: true });
43
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
44
+ const srcPath = path.join(src, entry.name);
45
+ const destPath = path.join(dest, entry.name);
46
+ if (exclude.includes(entry.name)) continue;
47
+ if (entry.isDirectory()) {
48
+ copyDir(srcPath, destPath, options);
49
+ } else if (overwrite || !fs.existsSync(destPath)) {
50
+ fs.copyFileSync(srcPath, destPath);
51
+ }
52
+ }
53
+ }
54
+
55
+ function rmDirRecursive(dirPath) {
56
+ if (!fs.existsSync(dirPath)) return;
57
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
58
+ const p = path.join(dirPath, entry.name);
59
+ if (entry.isDirectory()) {
60
+ rmDirRecursive(p);
61
+ } else {
62
+ fs.unlinkSync(p);
63
+ }
64
+ }
65
+ fs.rmdirSync(dirPath);
66
+ }
67
+
68
+ function findProjectRoot(startDir = process.cwd()) {
69
+ let dir = path.resolve(startDir);
70
+ while (true) {
71
+ if (fs.existsSync(path.join(dir, '.git')) ||
72
+ fs.existsSync(path.join(dir, 'package.json'))) {
73
+ return dir;
74
+ }
75
+ const parent = path.dirname(dir);
76
+ if (parent === dir) break;
77
+ dir = parent;
78
+ }
79
+ return process.cwd();
80
+ }
81
+
82
+ function pkgRoot() {
83
+ return path.resolve(__dirname, '..');
84
+ }
85
+
86
+ function readVersion() {
87
+ try {
88
+ return fs.readFileSync(path.join(pkgRoot(), 'framework', 'VERSION'), 'utf-8').trim();
89
+ } catch {
90
+ return 'unknown';
91
+ }
92
+ }
93
+
94
+ function renderPromptTemplate() {
95
+ const templatePath = path.join(pkgRoot(), 'framework', 'AGENTS.md.template');
96
+ const template = fs.readFileSync(templatePath, 'utf-8');
97
+ return template.replace(/\{\{VERSION\}\}/g, readVersion());
98
+ }
99
+
100
+ /** 从 framework/ 读取文件 */
101
+ function readFrameworkFile(filename) {
102
+ const filepath = path.join(pkgRoot(), 'framework', filename);
103
+ if (!fs.existsSync(filepath)) {
104
+ throw new Error(`框架文件不存在: ${filename}`);
105
+ }
106
+ return fs.readFileSync(filepath, 'utf-8');
107
+ }
108
+
109
+ /** 解析 --tool 参数或自动检测 */
110
+ function resolveAdapter(args, projectRoot) {
111
+ const toolIdx = args.indexOf('--tool');
112
+ if (toolIdx !== -1 && args[toolIdx + 1]) {
113
+ const toolName = args[toolIdx + 1];
114
+ if (!adapters[toolName]) {
115
+ log.err(`未知工具: ${toolName}`);
116
+ log.info(`支持的工具: ${supportedTools().join(', ')}`);
117
+ process.exit(1);
118
+ }
119
+ return adapters[toolName];
120
+ }
121
+
122
+ // 从状态文件读取(已安装时)
123
+ const stateFile = path.join(projectRoot, 'spec_copilot', TOOL_STATE_FILE);
124
+ if (fs.existsSync(stateFile)) {
125
+ const savedTool = fs.readFileSync(stateFile, 'utf-8').trim();
126
+ if (adapters[savedTool]) return adapters[savedTool];
127
+ }
128
+
129
+ // 自动检测
130
+ const detected = detectTools(projectRoot);
131
+ if (detected.length === 1) {
132
+ return detected[0];
133
+ } else if (detected.length > 1) {
134
+ log.warn(`检测到多个工具: ${detected.map(a => a.displayName).join(', ')}`);
135
+ log.info(`请用 --tool <name> 指定: ${detected.map(a => a.name).join(', ')}`);
136
+ process.exit(1);
137
+ }
138
+
139
+ return null; // install 时 null 会触发提示
140
+ }
141
+
142
+ // ─── 安装 ───────────────────────────────────────────────────
143
+
144
+ function cmdInstall(args) {
145
+ const projectRoot = findProjectRoot();
146
+ const srcRoot = pkgRoot();
147
+ let adapter = resolveAdapter(args, projectRoot);
148
+
149
+ if (!adapter) {
150
+ log.err('未检测到 AI 编码工具,请用 --tool 指定:');
151
+ log.info('');
152
+ for (const a of Object.values(adapters)) {
153
+ log.info(` --tool ${a.name.padEnd(12)} ${a.description}`);
154
+ }
155
+ process.exit(1);
156
+ }
157
+
158
+ log.title(`@alenfitz/spec-copilot install → ${adapter.displayName}`);
159
+ log.info(`项目根目录: ${projectRoot}`);
160
+ log.info(`框架版本: ${readVersion()}`);
161
+ log.info(`目标工具: ${adapter.displayName}`);
162
+
163
+ // 1. 创建 spec_copilot/ 目录结构
164
+ const scDir = path.join(projectRoot, 'spec_copilot');
165
+ const dirs = [
166
+ scDir,
167
+ path.join(scDir, 'rules'),
168
+ path.join(scDir, 'stack-adapters'),
169
+ path.join(scDir, 'changes', 'templates'),
170
+ path.join(scDir, 'scripts'),
171
+ path.join(scDir, 'knowledge'),
172
+ path.join(scDir, 'archives'),
173
+ path.join(scDir, 'commands'),
174
+ ];
175
+ for (const d of dirs) {
176
+ fs.mkdirSync(d, { recursive: true });
177
+ }
178
+
179
+ // 2. 拷贝通用框架文件
180
+ const frameworkSrc = path.join(srcRoot, 'framework');
181
+ log.info('拷贝通用框架文件...');
182
+ copyDir(path.join(frameworkSrc, 'rules'), path.join(scDir, 'rules'), {
183
+ exclude: ['project-context.md', 'domain-rules.md'],
184
+ });
185
+ copyDir(path.join(frameworkSrc, 'stack-adapters'), path.join(scDir, 'stack-adapters'));
186
+ copyDir(path.join(frameworkSrc, 'changes', 'templates'), path.join(scDir, 'changes', 'templates'));
187
+ copyDir(path.join(frameworkSrc, 'scripts'), path.join(scDir, 'scripts'));
188
+
189
+ ['VERSION', 'CHANGELOG.md'].forEach(f => {
190
+ const src = path.join(frameworkSrc, f);
191
+ const dest = path.join(scDir, f);
192
+ if (fs.existsSync(src)) fs.copyFileSync(src, dest);
193
+ });
194
+
195
+ // 3. 设置脚本可执行
196
+ for (const s of ['spec-lint.sh', 'spec-gate.sh', 'install-hooks.sh']) {
197
+ const scriptPath = path.join(scDir, 'scripts', s);
198
+ if (fs.existsSync(scriptPath)) {
199
+ try { fs.chmodSync(scriptPath, 0o755); } catch {}
200
+ }
201
+ }
202
+
203
+ // 4. 创建项目专属文件(仅当不存在时)
204
+ for (const rel of ['rules/project-context.md', 'rules/domain-rules.md', 'knowledge/index.md']) {
205
+ const dest = path.join(scDir, rel);
206
+ if (!fs.existsSync(dest)) {
207
+ const content = readFrameworkFile(rel);
208
+ fs.writeFileSync(dest, content, 'utf-8');
209
+ log.ok(`创建 spec_copilot/${rel}`);
210
+ } else {
211
+ log.warn(`跳过 spec_copilot/${rel}(已存在,保护项目内容)`);
212
+ }
213
+ }
214
+
215
+ // 5. 安装命令文件
216
+ const commandsSrc = path.join(srcRoot, 'commands');
217
+ if (adapter.hasNativeCommands && adapter.commandsDir) {
218
+ // 有原生命令支持 → 拷贝到工具命令目录
219
+ const cmdDest = path.join(projectRoot, adapter.commandsDir);
220
+ fs.mkdirSync(cmdDest, { recursive: true });
221
+ copyDir(commandsSrc, cmdDest);
222
+ const cmdCount = fs.readdirSync(commandsSrc).filter(f => f.endsWith('.md')).length;
223
+ log.ok(`${adapter.commandsDir}/ 已安装(${cmdCount} 个斜杠命令)`);
224
+ }
225
+
226
+ // 同时拷贝到 spec_copilot/commands/(所有工具都有,供引用)
227
+ copyDir(commandsSrc, path.join(scDir, 'commands'));
228
+ if (!adapter.hasNativeCommands) {
229
+ const cmdCount = fs.readdirSync(commandsSrc).filter(f => f.endsWith('.md')).length;
230
+ log.ok(`spec_copilot/commands/ 已安装(${cmdCount} 个命令,通过 prompt 路由)`);
231
+ }
232
+
233
+ // 6. 生成提示词文件
234
+ const promptPath = path.join(projectRoot, adapter.promptPath);
235
+ const promptDir = path.dirname(promptPath);
236
+ if (!fs.existsSync(promptPath)) {
237
+ fs.mkdirSync(promptDir, { recursive: true });
238
+ const rawPrompt = renderPromptTemplate();
239
+ const formattedPrompt = adapter.formatPrompt(rawPrompt);
240
+ fs.writeFileSync(promptPath, formattedPrompt, 'utf-8');
241
+ log.ok(`${adapter.promptPath} 已创建`);
242
+ } else {
243
+ log.warn(`${adapter.promptPath} 已存在,跳过(update --force 覆盖)`);
244
+ }
245
+
246
+ // 7. 记录使用的工具
247
+ fs.writeFileSync(path.join(scDir, TOOL_STATE_FILE), adapter.name, 'utf-8');
248
+
249
+ // 8. Git hook
250
+ installGitHook(projectRoot);
251
+
252
+ log.title('安装完成');
253
+ log.info('');
254
+ log.info('接下来:');
255
+ if (adapter.hasNativeCommands) {
256
+ log.info(' 1. 执行 /spec:init(自动加载规范 + 扫描项目 + 报告状态)');
257
+ } else {
258
+ log.info(' 1. 对 AI 说:"读取 spec_copilot/ 目录下的规范,执行 /spec:init"');
259
+ }
260
+ log.info(' 2. 选择或创建 stack-adapters/<你的栈>.md');
261
+ log.info(' 3. 填写 rules/domain-rules.md 业务约束(可选)');
262
+ log.info(' 4. 开始使用:/spec:propose <你的第一个需求>');
263
+ log.info('');
264
+ log.info('验证安装:npx @alenfitz/spec-copilot doctor');
265
+ }
266
+
267
+ // ─── 升级 ───────────────────────────────────────────────────
268
+
269
+ function cmdUpdate(args) {
270
+ const force = args.includes('--force');
271
+ const projectRoot = findProjectRoot();
272
+ const srcRoot = pkgRoot();
273
+ const scDir = path.join(projectRoot, 'spec_copilot');
274
+
275
+ if (!fs.existsSync(scDir)) {
276
+ log.err('未找到 spec_copilot/ 目录,请先运行 install');
277
+ process.exit(1);
278
+ }
279
+
280
+ const adapter = resolveAdapter(args, projectRoot);
281
+ if (!adapter) {
282
+ log.err('无法确定工具类型,请用 --tool 指定');
283
+ process.exit(1);
284
+ }
285
+
286
+ log.title(`@alenfitz/spec-copilot update → ${adapter.displayName}`);
287
+
288
+ const localVersionPath = path.join(scDir, 'VERSION');
289
+ const localVersion = fs.existsSync(localVersionPath)
290
+ ? fs.readFileSync(localVersionPath, 'utf-8').trim()
291
+ : 'unknown';
292
+ log.info(`本地版本: ${localVersion}`);
293
+ log.info(`包版本: ${readVersion()}`);
294
+
295
+ if (localVersion === readVersion() && !force) {
296
+ log.ok('已是最新版本(使用 --force 强制更新)');
297
+ return;
298
+ }
299
+
300
+ const frameworkSrc = path.join(srcRoot, 'framework');
301
+ log.info('更新通用框架文件...');
302
+
303
+ copyDir(path.join(frameworkSrc, 'rules'), path.join(scDir, 'rules'), {
304
+ exclude: ['project-context.md', 'domain-rules.md'],
305
+ });
306
+
307
+ const adapterDir = path.join(scDir, 'stack-adapters');
308
+ const userAdapters = fs.existsSync(adapterDir)
309
+ ? fs.readdirSync(adapterDir).filter(f => !BUILTIN_ADAPTERS.includes(f))
310
+ : [];
311
+ copyDir(path.join(frameworkSrc, 'stack-adapters'), adapterDir, {
312
+ exclude: userAdapters,
313
+ });
314
+
315
+ copyDir(path.join(frameworkSrc, 'changes', 'templates'), path.join(scDir, 'changes', 'templates'));
316
+ copyDir(path.join(frameworkSrc, 'scripts'), path.join(scDir, 'scripts'));
317
+
318
+ ['VERSION', 'CHANGELOG.md'].forEach(f => {
319
+ const src = path.join(frameworkSrc, f);
320
+ const dest = path.join(scDir, f);
321
+ if (fs.existsSync(src)) fs.copyFileSync(src, dest);
322
+ });
323
+
324
+ // 更新命令
325
+ const commandsSrc = path.join(srcRoot, 'commands');
326
+ copyDir(commandsSrc, path.join(scDir, 'commands'));
327
+ if (adapter.hasNativeCommands && adapter.commandsDir) {
328
+ const cmdDest = path.join(projectRoot, adapter.commandsDir);
329
+ fs.mkdirSync(cmdDest, { recursive: true });
330
+ copyDir(commandsSrc, cmdDest);
331
+ log.ok(`${adapter.commandsDir}/ 已更新`);
332
+ }
333
+
334
+ // 更新 prompt
335
+ const promptPath = path.join(projectRoot, adapter.promptPath);
336
+ if (force || !fs.existsSync(promptPath)) {
337
+ fs.mkdirSync(path.dirname(promptPath), { recursive: true });
338
+ const rawPrompt = renderPromptTemplate();
339
+ const formattedPrompt = adapter.formatPrompt(rawPrompt);
340
+ fs.writeFileSync(promptPath, formattedPrompt, 'utf-8');
341
+ log.ok(`${adapter.promptPath} 已更新`);
342
+ } else {
343
+ log.warn(`${adapter.promptPath} 已跳过(使用 --force 覆盖)`);
344
+ }
345
+
346
+ // 更新工具记录
347
+ fs.writeFileSync(path.join(scDir, TOOL_STATE_FILE), adapter.name, 'utf-8');
348
+
349
+ log.title('升级完成');
350
+ log.info('保留的项目专属内容:');
351
+ log.info(' - rules/project-context.md');
352
+ log.info(' - rules/domain-rules.md');
353
+ log.info(' - knowledge/');
354
+ log.info(' - changes/(进行中的需求)');
355
+ log.info(' - archives/');
356
+ if (userAdapters.length > 0) {
357
+ log.info(` - 自定义 stack-adapters:${userAdapters.join(', ')}`);
358
+ }
359
+ }
360
+
361
+ // ─── Gate ───────────────────────────────────────────────────
362
+
363
+ function cmdGate(args) {
364
+ const changeName = args[0];
365
+ const phase = args[1];
366
+
367
+ if (!changeName || !phase) {
368
+ log.err('用法: npx @alenfitz/spec-copilot gate <变更名> <phase>');
369
+ log.info('phase: apply | review | test | archive');
370
+ process.exit(2);
371
+ }
372
+
373
+ const validPhases = ['apply', 'review', 'test', 'archive'];
374
+ if (!validPhases.includes(phase)) {
375
+ log.err(`未知 phase: ${phase}(可选: ${validPhases.join(', ')})`);
376
+ process.exit(2);
377
+ }
378
+
379
+ const projectRoot = findProjectRoot();
380
+ const changeDir = path.join(projectRoot, 'spec_copilot', 'changes', changeName);
381
+
382
+ if (!fs.existsSync(changeDir)) {
383
+ log.err(`变更目录不存在: spec_copilot/changes/${changeName}/`);
384
+ process.exit(1);
385
+ }
386
+
387
+ log.title(`Gate 检查: ${changeName} → ${phase}`);
388
+
389
+ let pass = true;
390
+ const fail = (msg) => { log.err(msg); pass = false; };
391
+
392
+ const specPath = path.join(changeDir, 'spec.md');
393
+ const tasksPath = path.join(changeDir, 'tasks.md');
394
+ const logPath = path.join(changeDir, 'log.md');
395
+
396
+ if (!fs.existsSync(specPath)) {
397
+ fail('spec.md 不存在');
398
+ log.err(`Gate 未通过 — 无法进入 ${phase} 阶段`);
399
+ process.exit(1);
400
+ }
401
+
402
+ const specContent = fs.readFileSync(specPath, 'utf-8');
403
+ const isComplex = specContent.includes('complexity:') && specContent.includes('🔴');
404
+
405
+ switch (phase) {
406
+ case 'apply': {
407
+ const section9Match = specContent.match(/## 9\. 待澄清[\s\S]*?(?=## 10\.)/);
408
+ if (section9Match && /- \[ \]/.test(section9Match[0])) {
409
+ fail('§9 待澄清仍有未解决项 — 必须全部解决后才能 apply');
410
+ } else {
411
+ log.ok('§9 待澄清已清空');
412
+ }
413
+ if (isComplex && !fs.existsSync(tasksPath)) {
414
+ fail('🔴 复杂需求缺少 tasks.md');
415
+ } else if (isComplex) {
416
+ log.ok('tasks.md 存在(🔴 复杂需求)');
417
+ }
418
+ if (!fs.existsSync(logPath)) {
419
+ fail('log.md 不存在');
420
+ } else {
421
+ log.ok('log.md 存在');
422
+ }
423
+ break;
424
+ }
425
+ case 'review': {
426
+ if (!fs.existsSync(logPath)) {
427
+ fail('log.md 不存在');
428
+ } else {
429
+ const logContent = fs.readFileSync(logPath, 'utf-8');
430
+ if (/smoke.*通过|冒烟.*通过|smoke.*✓/i.test(logContent)) {
431
+ log.ok('log.md 含冒烟通过记录');
432
+ } else {
433
+ fail('log.md 无冒烟通过记录 — 请先运行 /spec:smoke');
434
+ }
435
+ }
436
+ if (fs.existsSync(tasksPath)) {
437
+ const tasksContent = fs.readFileSync(tasksPath, 'utf-8');
438
+ const pendingTasks = tasksContent.match(/状态:待完成/g) || [];
439
+ if (pendingTasks.length > 0) {
440
+ fail(`tasks.md 中有 ${pendingTasks.length} 个未完成 task`);
441
+ }
442
+ }
443
+ break;
444
+ }
445
+ case 'test': {
446
+ if (!isComplex) {
447
+ log.warn('非 🔴 复杂需求,test 为可选');
448
+ }
449
+ if (/结论:通过|Spec 合规:✅/.test(specContent)) {
450
+ log.ok('spec.md §12 审查已通过');
451
+ } else {
452
+ fail('spec.md §12 审查结论未通过或未填写');
453
+ }
454
+ break;
455
+ }
456
+ case 'archive': {
457
+ if (/结论:通过/.test(specContent)) {
458
+ log.ok('spec.md §12 审查结论为通过');
459
+ } else {
460
+ fail('spec.md §12 审查结论未通过');
461
+ }
462
+ break;
463
+ }
464
+ }
465
+
466
+ console.log('');
467
+ if (pass) {
468
+ log.ok(`Gate 通过 ✓ — 可以进入 ${phase} 阶段`);
469
+ process.exit(0);
470
+ } else {
471
+ log.err(`Gate 未通过 ✗ — 无法进入 ${phase} 阶段`);
472
+ process.exit(1);
473
+ }
474
+ }
475
+
476
+ // ─── Lint ───────────────────────────────────────────────────
477
+
478
+ function cmdLint(args) {
479
+ const projectRoot = findProjectRoot();
480
+ const lintScript = path.join(projectRoot, 'spec_copilot', 'scripts', 'spec-lint.sh');
481
+
482
+ if (!fs.existsSync(lintScript)) {
483
+ log.err('未找到 spec-lint.sh,请先运行 install');
484
+ process.exit(1);
485
+ }
486
+
487
+ const target = args[0] || '';
488
+ try {
489
+ execSync(`bash "${lintScript}" ${target}`, { cwd: projectRoot, stdio: 'inherit' });
490
+ } catch (e) {
491
+ process.exit(e.status || 1);
492
+ }
493
+ }
494
+
495
+ // ─── Doctor ─────────────────────────────────────────────────
496
+
497
+ function cmdDoctor() {
498
+ const projectRoot = findProjectRoot();
499
+ log.title('@alenfitz/spec-copilot doctor');
500
+ log.info(`项目根目录: ${projectRoot}`);
501
+ log.info(`框架版本: ${readVersion()}`);
502
+
503
+ let issues = 0;
504
+ const scDir = path.join(projectRoot, 'spec_copilot');
505
+
506
+ // 检查 spec_copilot/ 目录
507
+ if (fs.existsSync(scDir)) {
508
+ log.ok('spec_copilot/ 目录存在');
509
+ } else {
510
+ log.err('spec_copilot/ 目录不存在');
511
+ issues++;
512
+ log.err(`发现 ${issues} 个问题,运行 install 修复`);
513
+ return;
514
+ }
515
+
516
+ // 检测工具
517
+ const stateFile = path.join(scDir, TOOL_STATE_FILE);
518
+ let adapter = null;
519
+ if (fs.existsSync(stateFile)) {
520
+ const toolName = fs.readFileSync(stateFile, 'utf-8').trim();
521
+ adapter = adapters[toolName];
522
+ if (adapter) {
523
+ log.ok(`工具: ${adapter.displayName}`);
524
+ }
525
+ }
526
+ if (!adapter) {
527
+ const detected = detectTools(projectRoot);
528
+ if (detected.length > 0) {
529
+ adapter = detected[0];
530
+ log.warn(`工具未记录,自动检测: ${adapter.displayName}`);
531
+ } else {
532
+ log.warn('未检测到 AI 编码工具');
533
+ }
534
+ }
535
+
536
+ // 检查 prompt 文件
537
+ if (adapter) {
538
+ const promptPath = path.join(projectRoot, adapter.promptPath);
539
+ if (fs.existsSync(promptPath)) {
540
+ log.ok(`提示词文件: ${adapter.promptPath}`);
541
+ } else {
542
+ log.err(`提示词文件缺失: ${adapter.promptPath}`);
543
+ issues++;
544
+ }
545
+
546
+ if (adapter.hasNativeCommands && adapter.commandsDir) {
547
+ const cmdDir = path.join(projectRoot, adapter.commandsDir);
548
+ if (fs.existsSync(cmdDir)) {
549
+ const cmdFiles = fs.readdirSync(cmdDir).filter(f => f.endsWith('.md'));
550
+ log.ok(`${adapter.commandsDir}/ 已安装(${cmdFiles.length} 个命令)`);
551
+ } else {
552
+ log.err(`${adapter.commandsDir}/ 不存在`);
553
+ issues++;
554
+ }
555
+ }
556
+ }
557
+
558
+ // 检查命令文件
559
+ const cmdDir = path.join(scDir, 'commands');
560
+ if (fs.existsSync(cmdDir)) {
561
+ const cmdFiles = fs.readdirSync(cmdDir).filter(f => f.endsWith('.md'));
562
+ log.ok(`spec_copilot/commands/ 已安装(${cmdFiles.length} 个命令)`);
563
+ } else {
564
+ log.err('spec_copilot/commands/ 不存在');
565
+ issues++;
566
+ }
567
+
568
+ // 检查关键文件
569
+ const checkFiles = [
570
+ ['spec_copilot/rules/coding-style.md', '编码规范'],
571
+ ['spec_copilot/rules/security.md', '安全红线'],
572
+ ['spec_copilot/rules/project-context.md', '项目上下文'],
573
+ ['spec_copilot/knowledge/index.md', '知识索引'],
574
+ ['spec_copilot/scripts/spec-lint.sh', 'Lint 脚本'],
575
+ ];
576
+ for (const [rel, label] of checkFiles) {
577
+ if (fs.existsSync(path.join(projectRoot, rel))) {
578
+ log.ok(`${label}(${rel})`);
579
+ } else {
580
+ log.err(`缺少 ${label}(${rel})`);
581
+ issues++;
582
+ }
583
+ }
584
+
585
+ // 检查 project-context.md 是否已填充
586
+ const pcPath = path.join(scDir, 'rules', 'project-context.md');
587
+ if (fs.existsSync(pcPath)) {
588
+ const content = fs.readFileSync(pcPath, 'utf-8');
589
+ if (content.match(/- 应用名:$/m)) {
590
+ log.warn('project-context.md 未填充 → 请执行 /spec:init');
591
+ }
592
+ }
593
+
594
+ // 检查 Git hook
595
+ const hookPath = path.join(projectRoot, '.git', 'hooks', 'pre-commit');
596
+ if (fs.existsSync(hookPath)) {
597
+ const hookContent = fs.readFileSync(hookPath, 'utf-8');
598
+ if (hookContent.includes('spec_copilot')) {
599
+ log.ok('Git pre-commit hook 已安装');
600
+ } else {
601
+ log.warn('pre-commit hook 存在但不是 spec_copilot 的');
602
+ }
603
+ } else if (fs.existsSync(path.join(projectRoot, '.git'))) {
604
+ log.warn('Git pre-commit hook 未安装(可选)');
605
+ }
606
+
607
+ // 检查 stack adapter
608
+ const saDir = path.join(scDir, 'stack-adapters');
609
+ if (fs.existsSync(saDir)) {
610
+ const sas = fs.readdirSync(saDir)
611
+ .filter(f => f.endsWith('.md') && f !== 'README.md' && f !== '_template.md');
612
+ if (sas.length > 0) {
613
+ log.ok(`技术栈适配:${sas.map(f => f.replace('.md', '')).join(', ')}`);
614
+ } else {
615
+ log.warn('无自定义栈适配(可基于 _template.md 创建)');
616
+ }
617
+ }
618
+
619
+ console.log('');
620
+ if (issues === 0) {
621
+ log.ok('全部检查通过');
622
+ } else {
623
+ log.err(`发现 ${issues} 个问题,运行 install 修复`);
624
+ }
625
+ }
626
+
627
+ // ─── 卸载 ───────────────────────────────────────────────────
628
+
629
+ function cmdUninstall(args) {
630
+ const confirm = args.includes('--confirm');
631
+ const projectRoot = findProjectRoot();
632
+ log.title('@alenfitz/spec-copilot uninstall');
633
+ log.info(`项目根目录: ${projectRoot}`);
634
+
635
+ const scDir = path.join(projectRoot, 'spec_copilot');
636
+
637
+ // 读取工具信息
638
+ const adapter = resolveAdapter(args, projectRoot);
639
+
640
+ // 检查有无重要数据
641
+ const changesDir = path.join(scDir, 'changes');
642
+ if (fs.existsSync(changesDir)) {
643
+ const activeChanges = fs.readdirSync(changesDir)
644
+ .filter(d => d !== 'templates' && fs.existsSync(path.join(changesDir, d)) &&
645
+ fs.statSync(path.join(changesDir, d)).isDirectory());
646
+ if (activeChanges.length > 0) {
647
+ log.warn(`发现 ${activeChanges.length} 个进行中的变更: ${activeChanges.join(', ')}`);
648
+ }
649
+ }
650
+ const archivesDir = path.join(scDir, 'archives');
651
+ if (fs.existsSync(archivesDir)) {
652
+ try {
653
+ const archives = fs.readdirSync(archivesDir).filter(d =>
654
+ fs.statSync(path.join(archivesDir, d)).isDirectory());
655
+ if (archives.length > 0) log.warn(`发现 ${archives.length} 个归档目录`);
656
+ } catch {}
657
+ }
658
+
659
+ console.log('');
660
+ log.info('将删除以下内容:');
661
+ if (fs.existsSync(scDir)) log.info(' - spec_copilot/');
662
+ if (adapter) {
663
+ const promptPath = path.join(projectRoot, adapter.promptPath);
664
+ if (fs.existsSync(promptPath)) log.info(` - ${adapter.promptPath}`);
665
+ if (adapter.hasNativeCommands && adapter.commandsDir) {
666
+ const cmdDir = path.join(projectRoot, adapter.commandsDir);
667
+ if (fs.existsSync(cmdDir)) log.info(` - ${adapter.commandsDir}/`);
668
+ }
669
+ }
670
+
671
+ if (!confirm) {
672
+ console.log('');
673
+ log.warn('确认卸载请运行: npx @alenfitz/spec-copilot uninstall --confirm');
674
+ return;
675
+ }
676
+
677
+ // 执行删除
678
+ if (adapter) {
679
+ const promptPath = path.join(projectRoot, adapter.promptPath);
680
+ if (fs.existsSync(promptPath)) {
681
+ fs.unlinkSync(promptPath);
682
+ log.ok(`已删除 ${adapter.promptPath}`);
683
+ }
684
+ if (adapter.hasNativeCommands && adapter.commandsDir) {
685
+ const cmdDir = path.join(projectRoot, adapter.commandsDir);
686
+ if (fs.existsSync(cmdDir)) {
687
+ rmDirRecursive(cmdDir);
688
+ log.ok(`已删除 ${adapter.commandsDir}/`);
689
+ }
690
+ }
691
+ }
692
+ if (fs.existsSync(scDir)) {
693
+ rmDirRecursive(scDir);
694
+ log.ok('已删除 spec_copilot/');
695
+ }
696
+
697
+ // 移除 Git hook
698
+ const hookPath = path.join(projectRoot, '.git', 'hooks', 'pre-commit');
699
+ if (fs.existsSync(hookPath)) {
700
+ const hookContent = fs.readFileSync(hookPath, 'utf-8');
701
+ if (hookContent.includes('spec_copilot')) {
702
+ fs.unlinkSync(hookPath);
703
+ log.ok('已删除 Git pre-commit hook');
704
+ }
705
+ }
706
+
707
+ log.title('卸载完成');
708
+ }
709
+
710
+ // ─── 辅助 ───────────────────────────────────────────────────
711
+
712
+ function installGitHook(projectRoot) {
713
+ const hookScript = path.join(projectRoot, 'spec_copilot', 'scripts', 'install-hooks.sh');
714
+ if (!fs.existsSync(hookScript)) return;
715
+
716
+ const gitDir = path.join(projectRoot, '.git');
717
+ if (!fs.existsSync(gitDir)) {
718
+ log.warn('不在 Git 仓库中,跳过 pre-commit hook 安装');
719
+ return;
720
+ }
721
+
722
+ try {
723
+ execSync(`bash "${hookScript}"`, { cwd: projectRoot, stdio: 'pipe' });
724
+ log.ok('Git pre-commit hook 已安装');
725
+ } catch {
726
+ log.warn('pre-commit hook 安装失败(不影响其他功能)');
727
+ }
728
+ }
729
+
730
+ // ─── 入口 ────────────────────────────────────────────────────
731
+
732
+ function showHelp() {
733
+ console.log(`
734
+ @alenfitz/spec-copilot — 渐进式 Spec 编码框架(多工具统一版)
735
+
736
+ 支持工具: ${supportedTools().join(', ')}
737
+
738
+ 用法:
739
+ npx @alenfitz/spec-copilot install [--tool <name>] 初始化项目
740
+ npx @alenfitz/spec-copilot update [--force] 升级框架
741
+ npx @alenfitz/spec-copilot gate <name> <phase> 阶段门禁检查
742
+ npx @alenfitz/spec-copilot lint [name] Spec 完整性检查
743
+ npx @alenfitz/spec-copilot doctor 检查安装状态
744
+ npx @alenfitz/spec-copilot uninstall [--confirm] 移除框架文件
745
+
746
+ 示例:
747
+ npx @alenfitz/spec-copilot install --tool cursor
748
+ npx @alenfitz/spec-copilot install --tool claude-code
749
+ npx @alenfitz/spec-copilot gate user-login apply
750
+ npx @alenfitz/spec-copilot doctor
751
+ `);
752
+ }
753
+
754
+ const args = process.argv.slice(2);
755
+ const cmd = args[0];
756
+
757
+ switch (cmd) {
758
+ case 'install':
759
+ cmdInstall(args.slice(1));
760
+ break;
761
+ case 'update':
762
+ case 'upgrade':
763
+ cmdUpdate(args.slice(1));
764
+ break;
765
+ case 'gate':
766
+ cmdGate(args.slice(1));
767
+ break;
768
+ case 'lint':
769
+ cmdLint(args.slice(1));
770
+ break;
771
+ case 'doctor':
772
+ case 'check':
773
+ cmdDoctor();
774
+ break;
775
+ case 'uninstall':
776
+ case 'remove':
777
+ cmdUninstall(args.slice(1));
778
+ break;
779
+ case '--help':
780
+ case '-h':
781
+ case undefined:
782
+ showHelp();
783
+ break;
784
+ default:
785
+ log.err(`未知命令: ${cmd}`);
786
+ showHelp();
787
+ process.exit(1);
788
+ }