specline 1.4.0 → 2.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.
Files changed (75) hide show
  1. package/README.md +132 -125
  2. package/adapters/claude/deploy.json +12 -0
  3. package/adapters/claude/hooks/hooks.json +12 -0
  4. package/adapters/claude/hooks.json +12 -0
  5. package/adapters/claude/orchestration.md +17 -0
  6. package/adapters/codex/agent.toml.hbs +7 -0
  7. package/adapters/codex/deploy.json +12 -0
  8. package/adapters/codex/hooks.json +12 -0
  9. package/adapters/codex/orchestration.md +18 -0
  10. package/adapters/cursor/deploy.json +12 -0
  11. package/adapters/cursor/hooks.json +9 -0
  12. package/adapters/cursor/orchestration.md +17 -0
  13. package/adapters/opencode/deploy.json +12 -0
  14. package/adapters/opencode/orchestration.md +18 -0
  15. package/adapters/opencode/plugin.js +10 -0
  16. package/cli.mjs +161 -558
  17. package/core/agents/specline-backend-dev.yaml +45 -0
  18. package/core/agents/specline-code-reviewer.yaml +67 -0
  19. package/core/agents/specline-config-dev.yaml +50 -0
  20. package/core/agents/specline-config-reviewer.yaml +70 -0
  21. package/core/agents/specline-explore-assistant.yaml +79 -0
  22. package/core/agents/specline-frontend-dev.yaml +45 -0
  23. package/core/agents/specline-spec-creator.yaml +58 -0
  24. package/core/agents/specline-spec-reviewer.yaml +58 -0
  25. package/core/agents/specline-test-runner.yaml +62 -0
  26. package/core/agents/specline-test-writer.yaml +67 -0
  27. package/core/bootstrap/using-specline.md +14 -0
  28. package/core/gates/pipeline-gate-checks/a1-covers-ref.sh +125 -0
  29. package/core/gates/pipeline-gate-checks/a2-a3-reverse.sh +171 -0
  30. package/core/gates/pipeline-gate-checks/c1-exception.sh +71 -0
  31. package/core/gates/pipeline-gate-checks/c2-vague.sh +60 -0
  32. package/core/gates/pipeline-gate-checks/common.sh +68 -0
  33. package/core/gates/pipeline-gate-checks/d1-cycle.sh +149 -0
  34. package/core/gates/pipeline-gate-checks/d3-type-file.sh +260 -0
  35. package/core/gates/pipeline-gate.sh +1456 -0
  36. package/core/hooks/session-start.sh +259 -0
  37. package/core/skills/specline-apply-change/SKILL.md +197 -0
  38. package/core/skills/specline-archive-change/SKILL.md +173 -0
  39. package/core/skills/specline-explore/SKILL.md +504 -0
  40. package/core/skills/specline-knowledge/SKILL.md +539 -0
  41. package/core/skills/specline-pipeline/SKILL.md +604 -0
  42. package/core/skills/specline-pipeline/references/error-recovery-details.md +49 -0
  43. package/core/skills/specline-pipeline/references/event-log-spec.md +59 -0
  44. package/core/skills/specline-pipeline/references/pipeline-state-schema.md +87 -0
  45. package/core/skills/specline-pipeline/templates/subagent-prompts.md +397 -0
  46. package/core/skills/specline-propose/SKILL.md +186 -0
  47. package/core/skills/specline-quickfix/SKILL.md +289 -0
  48. package/core/templates/AGENTS.md.hbs +5 -0
  49. package/core/templates/specline/config.yaml +15 -0
  50. package/lib/deploy-claude.mjs +80 -0
  51. package/lib/deploy-codex.mjs +77 -0
  52. package/lib/deploy-opencode.mjs +93 -0
  53. package/lib/deploy.mjs +668 -0
  54. package/lib/gate.mjs +103 -0
  55. package/lib/hash.mjs +13 -0
  56. package/lib/hook.mjs +105 -0
  57. package/lib/init.mjs +122 -0
  58. package/lib/lock.mjs +99 -0
  59. package/lib/merge.mjs +184 -0
  60. package/lib/paths.mjs +40 -0
  61. package/lib/platforms.mjs +74 -0
  62. package/lib/render-agents.mjs +88 -0
  63. package/lib/render.mjs +126 -0
  64. package/lib/sync.mjs +253 -0
  65. package/lib/tty-select.mjs +89 -0
  66. package/package.json +4 -1
  67. package/templates/.cursor/README.md +18 -0
  68. package/templates/.cursor/agents/specline-code-reviewer.md +18 -2
  69. package/templates/.cursor/agents/specline-spec-creator.md +51 -2
  70. package/templates/.cursor/agents/specline-test-runner.md +10 -1
  71. package/templates/.cursor/agents/specline-test-writer.md +58 -7
  72. package/templates/.cursor/hooks/specline-pipeline-gate-checks/a2-a3-reverse.sh +1 -1
  73. package/templates/.cursor/hooks/specline-pipeline-gate.sh +118 -0
  74. package/templates/.cursor/skills/specline-pipeline/SKILL.md +10 -4
  75. package/templates/.cursor/skills/specline-propose/SKILL.md +3 -3
package/cli.mjs CHANGED
@@ -1,99 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { existsSync, mkdirSync, readdirSync, copyFileSync, writeFileSync, readFileSync } from 'fs';
4
- import { join, dirname, resolve, relative, basename } from 'path';
5
- import { fileURLToPath } from 'url';
6
- import { createHash } from 'crypto';
4
+ import { join, dirname, resolve } from 'path';
7
5
  import { get } from 'https';
8
6
  import { execSync, spawnSync } from 'child_process';
9
7
  import { createInterface } from 'readline/promises';
10
8
 
11
- const __dirname = dirname(fileURLToPath(import.meta.url));
12
- const TEMPLATES_DIR = join(__dirname, 'templates');
9
+ import { PACKAGE_ROOT, TEMPLATES_DIR } from './lib/paths.mjs';
10
+ import { computeFileHash } from './lib/hash.mjs';
11
+ import { readLockFile, writeLockFile } from './lib/lock.mjs';
12
+ import { cliGate } from './lib/gate.mjs';
13
+ import { cliHook } from './lib/hook.mjs';
14
+ import { cliPlatforms } from './lib/platforms.mjs';
15
+ import { runInit, resolvePlatforms, parsePlatformList } from './lib/init.mjs';
16
+ import { runSync } from './lib/sync.mjs';
13
17
 
14
- // package.json 读取版本号(由 npm version 命令自动维护)
15
- const PKG = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));
18
+ const PKG = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8'));
16
19
  const VERSION = PKG.version;
17
20
 
18
21
  // ============================================================
19
- // 共享工具函数 — 锁文件读写、哈希计算
22
+ // 共享工具函数
20
23
  // ============================================================
21
24
 
22
- /**
23
- * 计算内容的 SHA-256 哈希,返回 sha256:<hex> 格式字符串
24
- */
25
- function sha256(content) {
26
- const hash = createHash('sha256').update(content).digest('hex');
27
- return `sha256:${hash}`;
28
- }
29
-
30
- /**
31
- * 读取文件内容并计算 SHA-256 哈希
32
- */
33
- function computeFileHash(filePath) {
34
- const content = readFileSync(filePath);
35
- return sha256(content);
36
- }
37
-
38
- /**
39
- * 读取 specline/.specline-lock.yaml,手工行解析器
40
- * 返回 { version, synced_at, files: Map<string, string> } | null
41
- */
42
- function readLockFile(projectDir) {
43
- const lockPath = join(projectDir, 'specline', '.specline-lock.yaml');
44
- if (!existsSync(lockPath)) return null;
45
-
46
- const lines = readFileSync(lockPath, 'utf-8').split('\n');
47
- const result = { version: '', synced_at: '', files: new Map() };
48
- let inFiles = false;
49
-
50
- for (const line of lines) {
51
- const trimmed = line.trim();
52
- if (trimmed === '' || trimmed.startsWith('#')) continue;
53
-
54
- if (trimmed.startsWith('version:')) {
55
- result.version = trimmed.slice('version:'.length).trim().replace(/^"(.*)"$/, '$1');
56
- } else if (trimmed.startsWith('synced_at:')) {
57
- result.synced_at = trimmed.slice('synced_at:'.length).trim().replace(/^"(.*)"$/, '$1');
58
- } else if (trimmed === 'files:') {
59
- inFiles = true;
60
- } else if (inFiles && trimmed.includes(':')) {
61
- const colonIdx = trimmed.indexOf(':');
62
- const key = trimmed.slice(0, colonIdx).trim();
63
- const value = trimmed.slice(colonIdx + 1).trim();
64
- result.files.set(key, value);
65
- }
66
- }
67
-
68
- return result;
69
- }
70
-
71
- /**
72
- * 将锁数据序列化为 YAML 格式写入 specline/.specline-lock.yaml
73
- */
74
- function writeLockFile(projectDir, lockData) {
75
- const lockDir = join(projectDir, 'specline');
76
- if (!existsSync(lockDir)) {
77
- mkdirSync(lockDir, { recursive: true });
78
- }
79
- const lockPath = join(lockDir, '.specline-lock.yaml');
80
- const lines = [
81
- '# Specline Lock File — 自动生成,请勿手动编辑',
82
- `version: "${lockData.version}"`,
83
- `synced_at: "${lockData.synced_at}"`,
84
- 'files:',
85
- ];
86
- for (const [key, value] of lockData.files) {
87
- lines.push(` ${key}: ${value}`);
88
- }
89
- writeFileSync(lockPath, lines.join('\n') + '\n', 'utf-8');
90
- }
91
-
92
- /**
93
- * 遍历指定目录所有文件,构建锁数据结构
94
- * rootDir: 要遍历的根目录(必须是目标项目目录,这样 init 后锁哈希与实际文件一致)
95
- * 返回 { version, synced_at, files: Map<string, string> }
96
- */
97
25
  function buildLockData(projectDir, rootDir) {
98
26
  const files = new Map();
99
27
  const walkRoot = rootDir || TEMPLATES_DIR;
@@ -120,9 +48,6 @@ function buildLockData(projectDir, rootDir) {
120
48
  };
121
49
  }
122
50
 
123
- /**
124
- * 版本号语义比较:返回 -1 (a<b)、0 (a==b)、1 (a>b)
125
- */
126
51
  function compareVersions(a, b) {
127
52
  const aParts = a.split('.').map(Number);
128
53
  const bParts = b.split('.').map(Number);
@@ -135,32 +60,6 @@ function compareVersions(a, b) {
135
60
  return 0;
136
61
  }
137
62
 
138
- /**
139
- * 九态决策树:根据模板哈希、锁记录、项目文件状态,分类文件同步策略
140
- */
141
- function classifyFile(templatePath, templateHash, lockEntry, projectPath) {
142
- const projectExists = existsSync(projectPath);
143
- if (!projectExists) return { type: 'NEW', path: templatePath };
144
-
145
- const projectHash = computeFileHash(projectPath);
146
-
147
- if (lockEntry) {
148
- if (projectHash === lockEntry) {
149
- // PRISTINE
150
- if (templateHash === lockEntry) return { type: 'UNCHANGED', path: templatePath };
151
- return { type: 'WILL_UPDATE', path: templatePath };
152
- } else {
153
- // MODIFIED
154
- if (templateHash === lockEntry) return { type: 'MODIFIED_ONLY', path: templatePath };
155
- return { type: 'CONFLICT', path: templatePath };
156
- }
157
- } else {
158
- // 旧版项目,无 lock 记录
159
- if (projectHash === templateHash) return { type: 'UNCHANGED', path: templatePath };
160
- return { type: 'NO_LOCK_CONFLICT', path: templatePath };
161
- }
162
- }
163
-
164
63
  // ============================================================
165
64
  // 日志输出函数
166
65
  // ============================================================
@@ -197,150 +96,25 @@ function copyDirRecursive(src, dest) {
197
96
  }
198
97
  }
199
98
 
200
- // ============================================================
201
- // 智能合并函数 — hooks.json / config.yaml / CONFLICT 备份
202
- // ============================================================
203
-
204
- /**
205
- * hooks.json 语义合并:清理所有 specline-* 条目,注入模板最新官方 hook
206
- */
207
- function mergeHooksJson(existingContent, templateContent) {
208
- let existingObj, templateObj;
209
- try {
210
- existingObj = JSON.parse(existingContent);
211
- } catch {
212
- warn('hooks.json 解析失败,将使用模板完整替换');
213
- return templateContent;
214
- }
99
+ async function askConfirm(question) {
100
+ if (!process.stdin.isTTY) return true;
101
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
215
102
  try {
216
- templateObj = JSON.parse(templateContent);
103
+ const answer = await rl.question(question + ' ');
104
+ const trimmed = answer.trim().toLowerCase();
105
+ return trimmed === '' || trimmed === 'y' || trimmed === 'yes';
217
106
  } catch {
218
- warn('模板 hooks.json 解析失败,保留现有文件');
219
- return existingContent;
220
- }
221
-
222
- for (const eventName of Object.keys(templateObj.hooks || {})) {
223
- if (!existingObj.hooks) {
224
- existingObj.hooks = {};
225
- }
226
- if (!existingObj.hooks[eventName]) {
227
- existingObj.hooks[eventName] = [];
228
- }
229
- existingObj.hooks[eventName] = existingObj.hooks[eventName].filter(
230
- (entry) => !(entry.command || '').includes('specline-')
231
- );
232
- existingObj.hooks[eventName] = [
233
- ...templateObj.hooks[eventName],
234
- ...existingObj.hooks[eventName],
235
- ];
236
- }
237
- return JSON.stringify(existingObj, null, 2) + '\n';
238
- }
239
-
240
- function countCustomHooks(hooksObj) {
241
- let count = 0;
242
- for (const eventName of Object.keys(hooksObj.hooks || {})) {
243
- for (const entry of (hooksObj.hooks[eventName] || [])) {
244
- if (!(entry.command || '').includes('specline-')) count++;
245
- }
246
- }
247
- return count;
248
- }
249
-
250
- /**
251
- * YAML 段落结构
252
- */
253
- function parseYamlSections(content) {
254
- const lines = content.split('\n');
255
- const sections = [];
256
- let currentComments = [];
257
- let currentKey = null;
258
- let currentBodyLines = [];
259
- let inBody = false;
260
-
261
- function flushSection() {
262
- if (currentComments.length > 0 || currentBodyLines.length > 0 || currentKey) {
263
- const bodyStr = currentBodyLines.join('\n');
264
- // 判定 isEmpty:body 为空、纯注释、或仅声明 key 但无实际值
265
- const bodyTrimmed = bodyStr.trim();
266
- const onlyKeyDeclaration = currentKey !== null &&
267
- currentBodyLines.length === 1 &&
268
- bodyTrimmed.match(/^\w[\w_-]*\s*:\s*$/) !== null;
269
- const isEmpty = bodyTrimmed === '' ||
270
- bodyTrimmed.startsWith('#') ||
271
- onlyKeyDeclaration;
272
- sections.push({ key: currentKey, headerComments: [...currentComments], body: bodyStr, isEmpty });
273
- }
274
- currentComments = [];
275
- currentKey = null;
276
- currentBodyLines = [];
277
- inBody = false;
278
- }
279
-
280
- for (const line of lines) {
281
- const trimmed = line.trim();
282
- if (trimmed === '') { if (inBody) currentBodyLines.push(line); continue; }
283
- if (trimmed.startsWith('#')) { if (inBody) currentBodyLines.push(line); else currentComments.push(line); continue; }
284
- const topKeyMatch = line.match(/^(\w[\w_-]*)\s*:(.*)/);
285
- if (topKeyMatch && !line.startsWith(' ') && !line.startsWith('\t')) {
286
- flushSection();
287
- currentKey = topKeyMatch[1];
288
- currentBodyLines = [line];
289
- inBody = true;
290
- continue;
291
- }
292
- if (inBody) currentBodyLines.push(line);
293
- }
294
- flushSection();
295
- return sections;
296
- }
297
-
298
- function findSection(sections, key) {
299
- return sections.find((s) => s.key === key) || null;
300
- }
301
-
302
- function mergeConfigYaml(existingContent, templateContent) {
303
- const existingSections = parseYamlSections(existingContent);
304
- const templateSections = parseYamlSections(templateContent);
305
- const resultLines = [];
306
-
307
- for (const tmplSec of templateSections) {
308
- const existSec = findSection(existingSections, tmplSec.key);
309
- if (existSec) {
310
- if (!existSec.isEmpty && existSec.body.trim() !== tmplSec.body.trim()) {
311
- resultLines.push(...tmplSec.headerComments);
312
- resultLines.push(existSec.body);
313
- } else {
314
- resultLines.push(...tmplSec.headerComments);
315
- resultLines.push(tmplSec.body);
316
- }
317
- resultLines.push('');
318
- } else if (tmplSec.key !== null) {
319
- resultLines.push('# 🆕 新增配置段 (specline sync)');
320
- resultLines.push(...tmplSec.headerComments);
321
- resultLines.push(tmplSec.body);
322
- resultLines.push('');
323
- }
324
- }
325
-
326
- for (const existSec of existingSections) {
327
- if (existSec.key === null) continue;
328
- if (!findSection(templateSections, existSec.key)) {
329
- resultLines.push(...existSec.headerComments);
330
- resultLines.push(existSec.body);
331
- resultLines.push('');
332
- }
107
+ return false;
108
+ } finally {
109
+ rl.close();
333
110
  }
334
- return resultLines.join('\n');
335
111
  }
336
112
 
337
- function backupBeforeOverwrite(destPath) {
338
- const backupPath = destPath + '.orig';
339
- copyFileSync(destPath, backupPath);
340
- return backupPath;
341
- }
113
+ // ============================================================
114
+ // 命令实现 init / sync / update(保持原有逻辑,Task 16/18 再拆)
115
+ // ============================================================
342
116
 
343
- function cmd_init(targetPath) {
117
+ async function cmd_init(targetPath, rawArgs) {
344
118
  const cwd = process.cwd();
345
119
  const target = resolve(cwd, targetPath || '.');
346
120
 
@@ -350,79 +124,48 @@ function cmd_init(targetPath) {
350
124
  }
351
125
 
352
126
  const lockFile = join(target, 'specline', '.specline-lock.yaml');
353
- const forceMode = process.argv.includes('--force') || process.argv.includes('-f');
354
-
355
- if (existsSync(lockFile) && !forceMode) {
356
- warn('Specline 已在此项目中初始化。使用 --force 强制覆盖。');
357
- process.exit(0);
358
- }
359
-
360
- // 检测 hooks.json 冲突
361
- const hooksJsonDest = join(target, '.cursor', 'hooks.json');
362
- if (existsSync(hooksJsonDest)) {
363
- const backupPath = hooksJsonDest + '.bak';
364
- copyFileSync(hooksJsonDest, backupPath);
365
- warn('已备份原有 hooks.json → .cursor/hooks.json.bak');
366
- }
127
+ const forceMode = rawArgs.includes('--force') || rawArgs.includes('-f');
128
+ const withShellGuard = rawArgs.includes('--with-shell-guard');
367
129
 
368
- // 创建目录结构
369
- const dirs = [
370
- '.cursor/agents',
371
- '.cursor/skills',
372
- '.cursor/hooks',
373
- 'specline/changes/archive',
374
- 'specline/specs',
375
- ];
376
-
377
- for (const dir of dirs) {
378
- const fullDir = join(target, dir);
379
- if (!existsSync(fullDir)) {
380
- mkdirSync(fullDir, { recursive: true });
130
+ let platformArg;
131
+ for (let i = 0; i < rawArgs.length; i++) {
132
+ if (rawArgs[i] === '--platform' && rawArgs[i + 1]) {
133
+ platformArg = rawArgs[++i];
381
134
  }
382
135
  }
383
136
 
384
- // templates/ 复制文件
385
- if (!existsSync(TEMPLATES_DIR)) {
386
- error(`templates/ 目录不存在: ${TEMPLATES_DIR}`);
387
- process.exit(1);
137
+ if (existsSync(lockFile) && !forceMode && !platformArg) {
138
+ warn('Specline 已在此项目中初始化。使用 --force 强制覆盖,或使用 --platform 追加平台。');
139
+ process.exit(0);
388
140
  }
389
141
 
390
- copyDirRecursive(TEMPLATES_DIR, target);
142
+ const platforms = await resolvePlatforms(process.stdin.isTTY, platformArg);
391
143
 
392
- // 统计各类文件数量
393
- function countFiles(dir) {
394
- let count = 0;
395
- if (!existsSync(dir)) return 0;
396
- try {
397
- const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
398
- for (const entry of entries) {
399
- if (entry.isFile()) count++;
400
- }
401
- } catch (_) {}
402
- return count;
403
- }
144
+ const result = runInit({
145
+ target,
146
+ platforms,
147
+ withShellGuard,
148
+ version: VERSION,
149
+ force: forceMode,
150
+ });
404
151
 
405
- const agentsCount = countFiles(join(target, '.cursor', 'agents'));
406
- const skillsCount = countFiles(join(target, '.cursor', 'skills'));
407
- const hooksCount = countFiles(join(target, '.cursor', 'hooks'));
152
+ if (result.appended && result.appended.length > 0) {
153
+ success(`追加平台:${result.appended.join(', ')}。已有平台不受影响。`);
154
+ } else {
155
+ success('Specline 初始化完成');
156
+ }
157
+ log(`📁 文件: ${result.skills} skills, ${result.agents} agents, ${result.hooks} hooks`);
158
+ if (result.platforms.length > 0) {
159
+ log(`🌐 平台: ${result.platforms.join(', ')}`);
160
+ } else {
161
+ log('🌐 平台: 无(仅创建 specline/ 核心目录)');
162
+ }
408
163
 
409
- success('Specline 初始化完成');
410
- log(`📁 文件: ${skillsCount} skills, ${agentsCount} agents, ${hooksCount} hooks`);
411
164
  log('');
412
- log('🚀 试试在 Cursor 中输入:');
165
+ log('🚀 试试输入:');
413
166
  log(' /specline-pipeline "你的第一个需求"');
414
167
  log(' /specline-explore');
415
168
 
416
- // 生成锁文件
417
- const lockPath = join(target, 'specline', '.specline-lock.yaml');
418
- if (existsSync(lockPath) && !forceMode) {
419
- warn('锁文件已存在,跳过');
420
- } else {
421
- const lockData = buildLockData(target, target);
422
- writeLockFile(target, lockData);
423
- success('已生成锁文件');
424
- }
425
-
426
169
  process.exit(0);
427
170
  }
428
171
 
@@ -455,26 +198,7 @@ function fetchLatestVersion() {
455
198
  });
456
199
  }
457
200
 
458
- /**
459
- * 交互式确认提问:回车/Y/y/Yes/yes → true, N/n/No/no → false
460
- * 非 TTY 环境直接返回 true(无人值守模式)
461
- */
462
- async function askConfirm(question) {
463
- if (!process.stdin.isTTY) return true;
464
- const rl = createInterface({ input: process.stdin, output: process.stdout });
465
- try {
466
- const answer = await rl.question(question + ' ');
467
- const trimmed = answer.trim().toLowerCase();
468
- return trimmed === '' || trimmed === 'y' || trimmed === 'yes';
469
- } catch {
470
- return false;
471
- } finally {
472
- rl.close();
473
- }
474
- }
475
-
476
201
  async function cmd_update() {
477
- // 1. 从 npm registry 获取最新版本
478
202
  let latest;
479
203
  try {
480
204
  latest = await fetchLatestVersion();
@@ -492,13 +216,11 @@ async function cmd_update() {
492
216
  process.exit(0);
493
217
  }
494
218
 
495
- // 2. 版本比较
496
219
  if (compareVersions(VERSION, latest) >= 0) {
497
220
  success('已是最新版本 (v' + VERSION + ')');
498
221
  process.exit(0);
499
222
  }
500
223
 
501
- // 3. 交互确认
502
224
  log('✨ 新版本可用: v' + latest + '(当前: v' + VERSION + ')');
503
225
 
504
226
  if (!process.stdin.isTTY) {
@@ -512,7 +234,6 @@ async function cmd_update() {
512
234
  process.exit(0);
513
235
  }
514
236
 
515
- // 4. 执行 npm install -g specline@latest
516
237
  log('正在升级 specline...');
517
238
  try {
518
239
  execSync('npm install -g specline@latest', { stdio: 'inherit' });
@@ -530,7 +251,6 @@ async function cmd_update() {
530
251
 
531
252
  success('已升级至 v' + latest);
532
253
 
533
- // 5. 检测是否为 specline 项目,询问是否同步模板
534
254
  const cwd = process.cwd();
535
255
  const lockFile = join(cwd, 'specline', '.specline-lock.yaml');
536
256
  if (existsSync(lockFile)) {
@@ -551,14 +271,12 @@ async function cmd_update() {
551
271
  process.exit(0);
552
272
  }
553
273
 
554
- function cmd_sync({ dryRun, targetPath }) {
274
+ function cmd_sync({ dryRun, targetPath, platformArg }) {
555
275
  const cwd = process.cwd();
556
276
  const target = resolve(cwd, targetPath || '.');
557
277
 
558
- // 1. 检查项目是否已初始化
559
278
  const lockFile = join(target, 'specline', '.specline-lock.yaml');
560
279
  if (!existsSync(lockFile)) {
561
- // 向后兼容:检查旧版 .specline-config.yaml
562
280
  const oldMarker = join(target, '.specline-config.yaml');
563
281
  if (existsSync(oldMarker)) {
564
282
  warn('检测到旧版项目,正在自动迁移...');
@@ -571,259 +289,116 @@ function cmd_sync({ dryRun, targetPath }) {
571
289
  }
572
290
  }
573
291
 
574
- // 2. 构建上游模板哈希映射
575
- const upstreamData = buildLockData(target);
576
- const upstreamFiles = upstreamData.files;
577
-
578
- // 3. 读取锁文件
579
292
  const lockData = readLockFile(target);
580
-
581
- // 4. 版本校验
582
293
  if (lockData && compareVersions(lockData.version, VERSION) > 0) {
583
- warn('锁文件版本 (v' + lockData.version + ') 高于 CLI 版本 (v' + VERSION + '),继续同步可能导致问题');
584
- if (!process.stdin.isTTY) {
585
- error('非交互式环境,已跳过同步');
586
- process.exit(1);
587
- }
588
- error('锁文件版本高于 CLI,请先更新 CLI');
294
+ error('锁文件版本 (v' + lockData.version + ') 高于 CLI 版本 (v' + VERSION + '),请先更新 CLI');
589
295
  process.exit(1);
590
296
  }
591
297
 
592
- // 5. 收集所有需要分类的路径
593
- const allPaths = new Set();
594
- for (const p of upstreamFiles.keys()) allPaths.add(p);
595
- if (lockData) {
596
- for (const p of lockData.files.keys()) {
597
- if (!upstreamFiles.has(p)) allPaths.add(p);
598
- }
599
- }
600
-
601
- // 6. 分类
602
- const results = [];
603
- for (const path of allPaths) {
604
- const templateHash = upstreamFiles.get(path) || null;
605
- const lockEntry = lockData ? (lockData.files.get(path) || null) : null;
606
- const projectPath = join(target, path);
607
-
608
- if (templateHash === null) {
609
- results.push({ type: 'UPSTREAM_REMOVED', path });
610
- } else {
611
- results.push(classifyFile(path, templateHash, lockEntry, projectPath));
612
- }
613
- }
614
-
615
- // 7. 统计
616
- const stats = { newCount: 0, updated: 0, conflicted: 0, skippedModified: 0, unchanged: 0, upstreamRemoved: 0 };
617
- for (const r of results) {
618
- if (r.type === 'NEW') stats.newCount++;
619
- else if (r.type === 'WILL_UPDATE') stats.updated++;
620
- else if (r.type === 'CONFLICT' || r.type === 'NO_LOCK_CONFLICT') stats.conflicted++;
621
- else if (r.type === 'MODIFIED_ONLY') stats.skippedModified++;
622
- else if (r.type === 'UPSTREAM_REMOVED') stats.upstreamRemoved++;
623
- else stats.unchanged++;
624
- }
298
+ const platforms = platformArg ? parsePlatformList(platformArg) : undefined;
625
299
 
626
- // 8. dryRun 模式只预览
627
- if (dryRun) {
628
- const HOOKS_JSON = '.cursor/hooks.json';
629
- const CONFIG_YAML = 'specline/config.yaml';
630
- let hooksPlan = null;
631
-
632
- for (const r of results) {
633
- if (r.type === 'UNCHANGED' || r.type === 'MODIFIED_ONLY') {
634
- if (r.path === HOOKS_JSON) {
635
- const projPath = join(target, r.path);
636
- if (existsSync(projPath)) {
637
- try {
638
- const existingObj = JSON.parse(readFileSync(projPath, 'utf-8'));
639
- hooksPlan = { customCount: countCustomHooks(existingObj) };
640
- } catch {}
641
- }
642
- }
643
- if (r.path === CONFIG_YAML) {
644
- log('💡 config.yaml: 用户已修改,保留现有配置不变');
645
- }
646
- continue;
647
- }
648
-
649
- if (r.path === HOOKS_JSON) {
650
- hooksPlan = hooksPlan || { customCount: 0 };
651
- // 读取用户现有 hooks.json,计算自定义 hook 数量
652
- const projPath = join(target, r.path);
653
- if (existsSync(projPath) && !hooksPlan.readFromUser) {
654
- try {
655
- const existingObj = JSON.parse(readFileSync(projPath, 'utf-8'));
656
- hooksPlan = { customCount: countCustomHooks(existingObj), readFromUser: true };
657
- } catch {}
658
- }
659
- let tplCount = 0;
660
- try {
661
- const tpl = JSON.parse(readFileSync(join(TEMPLATES_DIR, r.path), 'utf-8'));
662
- for (const ev of Object.keys(tpl.hooks || {})) tplCount += (tpl.hooks[ev] || []).length;
663
- } catch {}
664
- log(`🔄 hooks.json 语义合并: 保留 ${hooksPlan.customCount >= 0 ? hooksPlan.customCount : '?'} 个自定义 hook, 更新 ${tplCount} 个官方 hook`);
665
- continue;
300
+ try {
301
+ const result = runSync(target, { dryRun, platforms });
302
+
303
+ if (dryRun) {
304
+ for (const item of result.plan) {
305
+ if (item.type === 'UNCHANGED' || item.type === 'MODIFIED_ONLY') continue;
306
+ const labels = {
307
+ NEW: ' 新增',
308
+ WILL_UPDATE: '🔄 更新',
309
+ CONFLICT: '⚠️ 冲突(将备份后覆盖)',
310
+ UPSTREAM_REMOVED: '🗑️ 上游移除',
311
+ };
312
+ log((labels[item.type] || item.type) + ' ' + item.path);
666
313
  }
667
-
668
- if (r.path === CONFIG_YAML) {
669
- log('💡 config.yaml: 保留用户配置不变,更新文档注释');
670
- continue;
314
+ const s = result.stats;
315
+ if (s.newCount === 0 && s.updated === 0 && s.conflicted === 0 && s.upstreamRemoved === 0) {
316
+ log('所有模板文件已是最新,无需同步');
317
+ } else {
318
+ log('\n以上为预览,未实际执行。去掉 --dry-run 以执行同步。');
671
319
  }
672
-
673
- const labels = { NEW: ' 新增', WILL_UPDATE: '🔄 更新', CONFLICT: '⚠️ 冲突(将备份后覆盖)', NO_LOCK_CONFLICT: '⚠️ 无锁记录', UPSTREAM_REMOVED: '🗑️ 上游移除' };
674
- log(labels[r.type] + ' ' + r.path);
675
- }
676
- if (stats.newCount === 0 && stats.updated === 0 && stats.conflicted === 0
677
- && stats.skippedModified === 0 && stats.upstreamRemoved === 0) {
678
- log('所有模板文件已是最新,无需同步');
679
- } else {
680
- log('\n以上为预览,未实际执行。去掉 --dry-run 以执行同步。');
681
- }
682
- process.exit(0);
683
- }
684
-
685
- // 9. 执行写入
686
- const newFiles = new Map();
687
- const HOOKS_JSON = '.cursor/hooks.json';
688
- const CONFIG_YAML = 'specline/config.yaml';
689
- const mergeStats = { hooksMerged: false, configUpdated: false, backupsCreated: 0 };
690
-
691
- for (const r of results) {
692
- if (r.type === 'UNCHANGED' || r.type === 'MODIFIED_ONLY') {
693
- const projectPath = join(target, r.path);
694
- if (existsSync(projectPath)) {
695
- newFiles.set(r.path, computeFileHash(projectPath));
320
+ if (result.migrated) {
321
+ log('📦 Lock file 将从 v1 迁移至 v2');
696
322
  }
697
- continue;
698
- }
699
-
700
- if (r.type === 'UPSTREAM_REMOVED') {
701
- warn('上游已移除:' + r.path);
702
- continue;
323
+ process.exit(0);
703
324
  }
704
325
 
705
- const srcPath = join(TEMPLATES_DIR, r.path);
706
- const destPath = join(target, r.path);
707
- const destDir = dirname(destPath);
708
- if (!existsSync(destDir)) {
709
- mkdirSync(destDir, { recursive: true });
326
+ const s = result.stats;
327
+ if (s.newCount === 0 && s.updated === 0 && s.conflicted === 0
328
+ && s.skippedModified === 0 && s.upstreamRemoved === 0) {
329
+ success('项目模板已是最新,无需同步 (v' + VERSION + ')');
330
+ } else {
331
+ log('📊 同步摘要:');
332
+ log(' ✅ 已新增: ' + s.newCount);
333
+ log(' 🔄 已更新: ' + s.updated);
334
+ log(' ⚠️ 已覆盖(冲突): ' + s.conflicted);
335
+ log(' ⏭️ 已跳过(本地修改): ' + s.skippedModified);
336
+ log(' 🗑️ 上游已移除: ' + s.upstreamRemoved);
337
+ log(' ✨ 锁文件已更新至 v' + VERSION);
710
338
  }
711
-
712
- try {
713
- // 特殊文件:hooks.json 语义合并
714
- if (r.path === HOOKS_JSON) {
715
- const existingContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '{}';
716
- const templateContent = readFileSync(srcPath, 'utf-8');
717
- try {
718
- const merged = mergeHooksJson(existingContent, templateContent);
719
- writeFileSync(destPath, merged, 'utf-8');
720
- newFiles.set(r.path, sha256(merged));
721
- mergeStats.hooksMerged = true;
722
- } catch {
723
- warn('hooks.json 合并失败,将保留现有文件');
724
- newFiles.set(r.path, computeFileHash(destPath));
725
- }
726
- continue;
727
- }
728
-
729
- // 特殊文件:config.yaml 注释合并
730
- if (r.path === CONFIG_YAML) {
731
- const existingContent = existsSync(destPath) ? readFileSync(destPath, 'utf-8') : '';
732
- const templateContent = readFileSync(srcPath, 'utf-8');
733
- try {
734
- const merged = mergeConfigYaml(existingContent, templateContent);
735
- writeFileSync(destPath, merged, 'utf-8');
736
- newFiles.set(r.path, sha256(merged));
737
- mergeStats.configUpdated = true;
738
- } catch {
739
- warn('config.yaml 合并失败,将保留现有文件');
740
- newFiles.set(r.path, computeFileHash(destPath));
741
- }
742
- continue;
743
- }
744
-
745
- // CONFLICT:备份后覆盖
746
- if (r.type === 'CONFLICT' || r.type === 'NO_LOCK_CONFLICT') {
747
- if (existsSync(destPath)) {
748
- const backupPath = backupBeforeOverwrite(destPath);
749
- mergeStats.backupsCreated++;
750
- warn('已覆盖(冲突,备份: ' + basename(backupPath) + '): ' + r.path);
751
- }
752
- copyFileSync(srcPath, destPath);
753
- newFiles.set(r.path, computeFileHash(destPath));
754
- } else {
755
- copyFileSync(srcPath, destPath);
756
- newFiles.set(r.path, computeFileHash(destPath));
757
- }
758
- } catch (err) {
759
- warn(r.path + ' 写入失败:' + err.message);
760
- if (lockData && lockData.files.has(r.path)) {
761
- newFiles.set(r.path, lockData.files.get(r.path));
762
- }
339
+ if (result.migrated) {
340
+ success('Lock file 已从 v1 迁移至 v2 格式');
763
341
  }
764
- }
765
-
766
- // 10. 更新锁文件
767
- writeLockFile(target, {
768
- version: VERSION,
769
- synced_at: new Date().toISOString(),
770
- files: newFiles,
771
- });
772
-
773
- // 11. 输出摘要
774
- if (stats.newCount === 0 && stats.updated === 0 && stats.conflicted === 0
775
- && stats.skippedModified === 0 && stats.upstreamRemoved === 0
776
- && !mergeStats.hooksMerged && !mergeStats.configUpdated) {
777
- success('项目模板已是最新,无需同步 (v' + VERSION + ')');
778
- } else {
779
- log('📊 同步摘要:');
780
- log(' 总模板文件: ' + allPaths.size);
781
- log(' ✅ 已新增: ' + stats.newCount);
782
- log(' 🔄 已更新: ' + stats.updated);
783
- log(' ⚠️ 已覆盖(冲突): ' + stats.conflicted);
784
- log(' ⏭️ 已跳过(本地修改): ' + stats.skippedModified);
785
- log(' 🗑️ 上游已移除: ' + stats.upstreamRemoved);
786
- if (mergeStats.hooksMerged) log(' 🔧 hooks.json: 语义合并完成');
787
- if (mergeStats.configUpdated) log(' 📝 config.yaml: 注释已更新');
788
- if (mergeStats.backupsCreated > 0) log(' 💾 创建备份: ' + mergeStats.backupsCreated + ' 个 .orig 文件');
789
- log(' ✨ 锁文件已更新至 v' + VERSION);
342
+ } catch (err) {
343
+ error(err.message);
344
+ process.exit(1);
790
345
  }
791
346
 
792
347
  process.exit(0);
793
348
  }
794
349
 
350
+ // ============================================================
351
+ // 命令路由
352
+ // ============================================================
353
+
795
354
  function cmd_version() {
796
355
  log(`specline v${VERSION}`);
797
356
  process.exit(0);
798
357
  }
799
358
 
800
- function cmd_help() {
801
- log(`specline v${VERSION} — Spec-driven AI coding pipeline for Cursor IDE
359
+ function cmd_help(exitCode = 0) {
360
+ log(`specline v${VERSION} — Spec-driven AI coding pipeline
802
361
 
803
362
  用法:
804
- specline init [path] 在指定路径初始化流水线基础设施
805
- specline init --force 强制覆盖已有配置
806
- specline update 检查 CLI 自身更新(npm registry)
807
- specline sync [--dry-run] [path] 同步项目模板文件到最新版本
808
- specline --version, -v 显示版本号
809
- specline --help, -h 显示此帮助信息
363
+ specline init [path] 在指定路径初始化流水线基础设施
364
+ specline init --platform <list> 指定平台 (cursor,claude,codex,opencode,all,none)
365
+ specline init --force 强制覆盖已有配置
366
+ specline init --with-shell-guard 启用 shell 命令安全防护 hook
367
+ specline sync [--dry-run] [path] 同步项目模板文件到最新版本
368
+ specline sync --platform <list> 只同步指定平台
369
+ specline update 检查 CLI 自身更新(npm registry)
370
+ specline gate <sub> [--change <n>] 运行 Gate 检查(spec/build/lint/list …)
371
+ specline hook <sub> [--platform p] 运行 Hook 脚本(session-start …)
372
+ specline platforms 显示已部署平台列表
373
+ specline --version, -v 显示版本号
374
+ specline --help, -h 显示此帮助信息
810
375
 
811
376
  示例:
812
- specline init 在当前目录初始化
813
- specline init ./my-project 在指定目录初始化
814
- specline sync --dry-run 预览模板文件更新
815
- npx specline init 无需全局安装即可使用
377
+ specline init 在当前目录初始化(TTY 下交互选择平台)
378
+ specline init --platform cursor 只部署 Cursor 平台
379
+ specline init --platform all 部署全部 4 个平台
380
+ specline init ./my-project 在指定目录初始化
381
+ specline sync --dry-run 预览模板文件更新
382
+ specline gate spec --change feat 运行 spec gate 检查
383
+ specline hook session-start 运行 session-start hook
384
+ specline platforms 列出已部署平台
385
+ npx specline init 无需全局安装即可使用
816
386
  `);
817
- process.exit(0);
387
+ process.exit(exitCode);
818
388
  }
819
389
 
820
- // 入口
821
390
  const [,, command, ...args] = process.argv;
822
391
 
823
392
  switch (command) {
824
393
  case 'init': {
825
- const pathArg = args.filter(a => a !== '--force' && a !== '-f')[0];
826
- cmd_init(pathArg);
394
+ const initFlags = ['--force', '-f', '--with-shell-guard', '--platform'];
395
+ const pathArg = args.filter(a => {
396
+ if (initFlags.includes(a)) return false;
397
+ const prevIdx = args.indexOf(a) - 1;
398
+ if (prevIdx >= 0 && args[prevIdx] === '--platform') return false;
399
+ return true;
400
+ })[0];
401
+ await cmd_init(pathArg, args);
827
402
  break;
828
403
  }
829
404
  case 'update': {
@@ -832,8 +407,31 @@ switch (command) {
832
407
  }
833
408
  case 'sync': {
834
409
  const dryRun = args.includes('--dry-run');
835
- const pathArg = args.filter(a => a !== '--dry-run')[0];
836
- cmd_sync({ dryRun, targetPath: pathArg });
410
+ let syncPlatformArg;
411
+ const filteredArgs = [];
412
+ for (let i = 0; i < args.length; i++) {
413
+ if (args[i] === '--platform' && args[i + 1]) {
414
+ syncPlatformArg = args[++i];
415
+ } else if (args[i] !== '--dry-run') {
416
+ filteredArgs.push(args[i]);
417
+ }
418
+ }
419
+ cmd_sync({ dryRun, targetPath: filteredArgs[0], platformArg: syncPlatformArg });
420
+ break;
421
+ }
422
+ case 'gate': {
423
+ const exitCode = cliGate(args);
424
+ process.exit(exitCode);
425
+ break;
426
+ }
427
+ case 'hook': {
428
+ const exitCode = cliHook(args);
429
+ process.exit(exitCode);
430
+ break;
431
+ }
432
+ case 'platforms': {
433
+ const exitCode = cliPlatforms();
434
+ process.exit(exitCode);
837
435
  break;
838
436
  }
839
437
  case '--version':
@@ -842,7 +440,12 @@ switch (command) {
842
440
  break;
843
441
  case '--help':
844
442
  case '-h':
845
- default:
846
443
  cmd_help();
847
444
  break;
445
+ default:
446
+ if (command) {
447
+ console.error(`\x1b[31m未知命令: ${command}\x1b[0m\n`);
448
+ }
449
+ cmd_help(command ? 1 : 0);
450
+ break;
848
451
  }