kld-sdd 2.7.8 → 2.8.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 (73) hide show
  1. package/README.md +26 -0
  2. package/USABILITY.md +72 -0
  3. package/bin/kld-sdd-init.js +718 -34
  4. package/lib/command-bridge.js +26 -17
  5. package/lib/deploy-codebuddy-hooks.js +19 -26
  6. package/lib/deploy-strategy.js +293 -0
  7. package/lib/hook-gate-core.js +7 -8
  8. package/lib/hook-install.js +75 -0
  9. package/lib/init.js +507 -726
  10. package/lib/maintenance.js +221 -0
  11. package/lib/managed-hook-hashes.json +24 -0
  12. package/lib/settings-merge.js +102 -25
  13. package/lib/skills-bundle.js +5 -5
  14. package/lib/spec-choice.js +85 -0
  15. package/lib/tool-profiles.js +2 -2
  16. package/lib/uninstall.js +453 -0
  17. package/package.json +7 -4
  18. package/skywalk-sdd/context-client.cjs +38 -7
  19. package/skywalk-sdd/index.cjs +153 -27
  20. package/skywalk-sdd/kb-upload.cjs +224 -82
  21. package/skywalk-sdd/lib/check-review.cjs +41 -0
  22. package/skywalk-sdd/lib/shared.cjs +62 -41
  23. package/skywalk-sdd/lib/test-execution.cjs +130 -0
  24. package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
  25. package/skywalk-sdd/ontology/archive-package.cjs +141 -0
  26. package/skywalk-sdd/ontology/artifact-parser.cjs +24 -3
  27. package/skywalk-sdd/ontology/cli.cjs +53 -0
  28. package/skywalk-sdd/ontology/id.cjs +1 -1
  29. package/skywalk-sdd/ontology/identity-index.cjs +5 -1
  30. package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
  31. package/skywalk-sdd/ontology/schema.cjs +6 -0
  32. package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
  33. package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
  34. package/skywalk-sdd/ontology/spec-ontology.cjs +507 -0
  35. package/skywalk-sdd/ontology/workspace-layout.cjs +65 -17
  36. package/templates/git-hooks/commit-msg +17 -85
  37. package/templates/git-hooks/commit-msg-runner.cjs +44 -0
  38. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
  39. package/templates/git-hooks/consistency-check-core.cjs +92 -261
  40. package/templates/git-hooks/delivery-check.cjs +231 -0
  41. package/templates/git-hooks/delivery-snapshot.cjs +181 -0
  42. package/templates/git-hooks/hooks.config +8 -6
  43. package/templates/git-hooks/pre-commit +19 -5
  44. package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
  45. package/templates/git-hooks/pre-push +19 -5
  46. package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
  47. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
  48. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
  49. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
  50. package/templates/openspec/proposal.md +1 -1
  51. package/templates/openspec/spec.md +8 -0
  52. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +28 -24
  53. package/templates/skills/kld-sdd/opsx-apply/checklist.md +12 -3
  54. package/templates/skills/kld-sdd/opsx-apply/reference.md +21 -1
  55. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +20 -18
  56. package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -13
  57. package/templates/skills/kld-sdd/opsx-check/reference.md +3 -1
  58. package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
  59. package/templates/skills/kld-sdd/opsx-check/reviewer.md +27 -6
  60. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
  61. package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
  62. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +82 -61
  63. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +8 -11
  64. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +3 -2
  65. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +2 -2
  66. package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +77 -0
  67. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +5 -4
  68. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +14 -1
  69. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
  70. package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +9 -0
  71. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +5 -3
  72. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
  73. package/index.js +0 -13
@@ -1,45 +1,681 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * KLD SDD 项目初始化工具
5
- * 一键配置团队标准化开发环境,内置 openSpec 模版
4
+ * KLD SDD CLI 入口
5
+ *
6
+ * 职责边界:
7
+ * - bin/(本文件):命令行参数解析、帮助/版本输出、交互式输入(编辑器选择)、子命令路由
8
+ * - lib/:纯业务逻辑,只接收解析后的选项对象,不读取 process.argv
6
9
  *
7
10
  * 子命令:
8
11
  * (无参数) 运行项目初始化
9
12
  * log SDD Telemetry CLI(记录阶段事件、查询指标)
10
13
  * link-spec 将代码仓关联到本地 spec clone(git config sdd.specPath)
11
14
  * sync-repos 工作目录下发现新增代码仓并轻量接入(不装 skills)
15
+ * uninstall 卸载 SDD 产物(渐进式:先预览工具范围,最后确认执行)
12
16
  */
13
17
 
18
+ 'use strict';
19
+
14
20
  const path = require('path');
15
- const args = process.argv.slice(2);
21
+ const { listToolProfileIds, buildToolConfigs } = require('../lib/tool-profiles');
22
+ const { MODE, OP, modeLabel } = require('../lib/uninstall');
23
+ const specDetect = require('../skywalk-sdd/ontology/spec-detect.cjs');
24
+ const { specSourceLabel } = specDetect;
25
+ // spec 候选单选交互(唯一实现,与 lib/init.js 共用)
26
+ const { selectSpecRoot: selectSpecChoice } = require('../lib/spec-choice');
16
27
 
17
- if (args[0] === 'log') {
18
- // Telemetry CLI 模式
19
- require('../skywalk-sdd/index.js').main();
20
- } else if (args[0] === 'link-spec') {
21
- const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
22
- const pathArg = args.find((item) => item.startsWith('--path='));
23
- const projectArg = args.find((item) => item.startsWith('--project='));
24
- const positional = args.slice(1).find((item) => !item.startsWith('--'));
28
+ const args = cliArgs();
29
+ const command = args[0];
30
+
31
+ /** 统一的失败出口:stderr + 非零退出码 */
32
+ function fail(message) {
33
+ console.error(`❌ ${message}`);
34
+ process.exit(1);
35
+ }
36
+
37
+ // ═══════════════════════════════════════════════════════════════
38
+ // CLI 输入层:参数解析 / 交互输入 / 帮助输出
39
+ // 只做「字符串 → 选项对象」的转换,不落盘、不执行业务。
40
+ // ═══════════════════════════════════════════════════════════════
41
+
42
+ /** verbose 环境变量名(与 --verbose 等价) */
43
+ const VERBOSE_ENV = 'KLD_SDD_VERBOSE';
44
+
45
+ /** 取子命令及其后的参数(去掉 node 与脚本路径) */
46
+ function cliArgs(argv = process.argv) {
47
+ return argv.slice(2);
48
+ }
49
+
50
+ function hasFlag(argv, ...names) {
51
+ return names.some((name) => argv.includes(name));
52
+ }
53
+
54
+ /**
55
+ * 输出详细度判定
56
+ * 触发条件:argv 含 --verbose,或 KLD_SDD_VERBOSE 为 1/true(大小写不敏感)
57
+ * 注意:-v 已被 --version 占用,不复用
58
+ */
59
+ function isVerbose(argv, env) {
60
+ const list = Array.isArray(argv) ? argv : cliArgs();
61
+ const envVars = env && typeof env === 'object' ? env : process.env;
62
+ if (list.includes('--verbose')) return true;
63
+ const value = envVars[VERBOSE_ENV];
64
+ return typeof value === 'string' && /^(1|true)$/i.test(value);
65
+ }
66
+
67
+ /** 读取 --tool / --tool= 的原始值;未出现返回 null */
68
+ function readToolArg(argv) {
69
+ const index = argv.findIndex((arg) => arg === '--tool' || arg.startsWith('--tool='));
70
+ if (index === -1) return null;
71
+ return argv[index] === '--tool' ? argv[index + 1] : argv[index].slice('--tool='.length);
72
+ }
73
+
74
+ /**
75
+ * 解析 --tool,返回编辑器 id 数组;未指定返回 null
76
+ * 支持:--tool codex、--tool=codex、--tool cursor,codex、--tool all
77
+ */
78
+ function parseToolOption(argv) {
79
+ const rawValue = readToolArg(argv);
80
+ if (rawValue === null) return null;
81
+ if (!rawValue) {
82
+ throw new Error('缺少 --tool 参数值,请指定编辑器名称');
83
+ }
84
+
85
+ const allTools = listToolProfileIds();
86
+ const normalized = String(rawValue).trim().toLowerCase();
87
+ if (normalized === 'all' || normalized === '全部') {
88
+ return allTools;
89
+ }
90
+
91
+ const selected = normalized.split(',').map((item) => item.trim()).filter(Boolean);
92
+ const invalid = selected.filter((tool) => !allTools.includes(tool));
93
+ if (invalid.length > 0) {
94
+ throw new Error(`不支持的编辑器: ${invalid.join(', ')}。可用值: ${allTools.join(', ')}, all`);
95
+ }
96
+
97
+ return selected;
98
+ }
99
+
100
+ /**
101
+ * 解析 --spec-path(相对/绝对路径字符串);未指定返回 null。
102
+ * 路径语义校验(必须落在当前目录内)属业务逻辑,由 lib 完成。
103
+ */
104
+ function parseSpecPathOption(argv) {
105
+ const index = argv.findIndex(
106
+ (arg) => arg === '--spec-path' || arg.startsWith('--spec-path='),
107
+ );
108
+ if (index === -1) return null;
109
+
110
+ const rawValue = argv[index] === '--spec-path'
111
+ ? argv[index + 1]
112
+ : argv[index].slice('--spec-path='.length);
113
+
114
+ if (!rawValue || rawValue.startsWith('--')) {
115
+ throw new Error('缺少 --spec-path 参数值,请指定 spec 仓库目录(相对或绝对路径)');
116
+ }
117
+ return rawValue.trim();
118
+ }
119
+
120
+ /** kld-sdd [选项] */
121
+ function parseInitArgs(argv = cliArgs(), env = process.env) {
122
+ const tools = parseToolOption(argv);
123
+ return {
124
+ help: hasFlag(argv, '-h', '--help'),
125
+ version: hasFlag(argv, '-v', '--version'),
126
+ verbose: isVerbose(argv, env),
127
+ skipOpenspec: argv.includes('--skip-openspec'),
128
+ skipTemplate: argv.includes('--skip-template'),
129
+ repairGit: argv[0] === 'repair-git' || argv.includes('--repair-git'),
130
+ applyRepair: argv.includes('--apply'),
131
+ json: argv.includes('--json'),
132
+ tools,
133
+ // 显式给了 --tool 即自动化场景:lib 据此抑制 spec 路径的交互询问
134
+ hasToolArg: tools !== null,
135
+ specPath: parseSpecPathOption(argv),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * kld-sdd uninstall [选项]
141
+ *
142
+ * 卸载产物与编辑器无关(8 个编辑器的产物全量扫描),工作目录固定为当前目录,
143
+ * 故不提供 --tool / --project:仅卸载当前范围的工具,目录由 cwd 决定。
144
+ *
145
+ * --spec-path 用于无人值守场景:多候选(如用户复制的备份)时无法自动判定,
146
+ * 需显式指定要卸载的 spec 仓库。
147
+ */
148
+ function parseUninstallArgs(argv = cliArgs()) {
149
+ if (argv.some(arg => arg === '--purge' || arg.startsWith('--purge=') || arg === '--full' || arg.startsWith('--mode'))) {
150
+ throw new Error('已取消全量删除,仅支持卸载工具;Spec 文档、配置与历史数据始终保留');
151
+ }
152
+ return {
153
+ cwd: process.cwd(),
154
+ specPath: parseSpecPathOption(argv),
155
+ mode: MODE.TOOLS_ONLY,
156
+ dryRun: argv.includes('--dry-run'),
157
+ yes: hasFlag(argv, '--yes', '-y'),
158
+ help: hasFlag(argv, '-h', '--help'),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * kld-sdd sync-repos [选项]
164
+ *
165
+ * --spec-path 用于无人值守场景:多候选(如用户复制的备份)时无法自动判定,
166
+ * 需显式指定要接入的 spec 仓库(与 uninstall 同名参数语义一致)。
167
+ */
168
+ function parseSyncReposArgs(argv = cliArgs()) {
169
+ const projectArg = argv.find((item) => item.startsWith('--project='));
170
+ return {
171
+ workspaceRoot: path.resolve(projectArg ? projectArg.slice('--project='.length) : '.'),
172
+ onlyNew: !argv.includes('--all'),
173
+ specPath: parseSpecPathOption(argv),
174
+ yes: hasFlag(argv, '--yes', '-y'),
175
+ };
176
+ }
177
+
178
+ /** kld-sdd link-spec [选项] */
179
+ function parseLinkSpecArgs(argv = cliArgs()) {
180
+ const pathArg = argv.find((item) => item.startsWith('--path='));
181
+ const projectArg = argv.find((item) => item.startsWith('--project='));
182
+ const positional = argv.slice(1).find((item) => !item.startsWith('--'));
25
183
  const specPath = pathArg ? pathArg.slice('--path='.length) : positional;
26
184
  const projectRoot = path.resolve(projectArg ? projectArg.slice('--project='.length) : '.');
27
185
  if (!specPath) {
28
- console.error('用法: kld-sdd link-spec --path=<spec-clone> [--project=.]');
29
- process.exit(1);
186
+ throw new Error('用法: kld-sdd link-spec --path=<spec-clone> [--project=.]');
30
187
  }
31
- const result = sddConfig.setSpecPath(projectRoot, specPath);
32
- if (!result.ok) {
33
- console.error(`❌ ${result.message}`);
188
+ return { specPath, projectRoot };
189
+ }
190
+
191
+ /** 编辑器交互选择菜单映射(1-9) */
192
+ const EDITOR_CHOICES = Object.freeze({
193
+ '1': ['cursor'],
194
+ '2': ['claude'],
195
+ '3': ['codebuddy'],
196
+ '4': ['qoder'],
197
+ '5': ['opencode'],
198
+ '6': ['kunlunzhima'],
199
+ '7': ['workbuddy'],
200
+ '8': ['codex'],
201
+ '9': listToolProfileIds(),
202
+ });
203
+
204
+ /**
205
+ * 让用户选择编辑器(交互式 CLI 输入,非参数解析)
206
+ * 无效输入时重新询问,不允许默认
207
+ */
208
+ async function selectEditor(options = {}) {
209
+ const toolConfigs = buildToolConfigs();
210
+ const readline = require('readline');
211
+ const rl = readline.createInterface({
212
+ input: options.input || process.stdin,
213
+ output: options.output || process.stdout,
214
+ });
215
+ const ask = (question) => new Promise(
216
+ (resolve) => rl.question(question, (answer) => resolve(String(answer).trim())),
217
+ );
218
+
219
+ try {
220
+ while (true) {
221
+ console.log('\n请选择您使用的 AI 编辑器:');
222
+ console.log(' 1. Cursor');
223
+ console.log(' 2. Claude Code');
224
+ console.log(' 3. CodeBuddy');
225
+ console.log(' 4. Qoder');
226
+ console.log(' 5. OpenCode');
227
+ console.log(' 6. KunlunZhima');
228
+ console.log(' 7. WorkBuddy');
229
+ console.log(' 8. Codex');
230
+ console.log(' 9. 全部(为所有编辑器生成 skills)');
231
+
232
+ const answer = await ask('请输入选项 (1-9): ');
233
+ const selected = EDITOR_CHOICES[answer];
234
+
235
+ if (!selected) {
236
+ console.log(`❌ 无效选项 "${answer}",请输入 1-9 之间的数字\n`);
237
+ continue; // 重新循环询问
238
+ }
239
+
240
+ const names = selected.map((k) => toolConfigs[k].name).join(', ');
241
+ console.log(`✓ 已选择: ${names}\n`);
242
+ return selected.slice();
243
+ }
244
+ } finally {
245
+ rl.close();
246
+ }
247
+ }
248
+
249
+ /**
250
+ * 打开 readline 交互会话,返回 { ask, close }
251
+ * 卸载的「方式选择 / 计划预览 / 确认」全部在本文件完成(CLI 输入层职责)。
252
+ */
253
+ function openPrompt(options = {}) {
254
+ const readline = require('readline');
255
+ const rl = readline.createInterface({
256
+ input: options.input || process.stdin,
257
+ output: options.output || process.stdout,
258
+ });
259
+ return {
260
+ ask: (question) => new Promise(
261
+ (resolve) => rl.question(question, (answer) => resolve(String(answer).trim())),
262
+ ),
263
+ close: () => rl.close(),
264
+ };
265
+ }
266
+
267
+ /** 打印卸载头部信息 */
268
+ function printUninstallHeader(cwd, specRoot, mode, dryRun) {
269
+ console.log('');
270
+ console.log(`🗑 KLD SDD 卸载 · ${modeLabel(mode)}`);
271
+ console.log(` 工作目录: ${cwd}`);
272
+ if (specRoot) console.log(` Spec 包裹包: ${specRoot}`);
273
+ if (dryRun) console.log(' 模式: 预演(不落盘)');
274
+ console.log('');
275
+ }
276
+
277
+ /**
278
+ * 打印计划预览:按操作类型分组,避免逐条刷屏。
279
+ * 每类最多列 5 条,其余折叠为计数;KEEP 项只列文件名与原因。
280
+ */
281
+ function printPlanPreview(plan) {
282
+ const dels = plan.ops.filter((o) => o.op !== OP.KEEP);
283
+ const keeps = plan.ops.filter((o) => o.op === OP.KEEP);
284
+
285
+ const OP_LABELS = {
286
+ [OP.DELETE_DIR]: '删除目录',
287
+ [OP.DELETE_FILE]: '删除文件',
288
+ [OP.STRIP_HOOKS]: '摘除受管 hook(文件其余配置保留)',
289
+ [OP.STRIP_GITIGNORE]: '摘除 .gitignore 的 SDD 块(其余行保留)',
290
+ [OP.UNLINK_GIT_CONFIG]: '清除 git config sdd.specPath',
291
+ };
292
+
293
+ const byOp = new Map();
294
+ for (const item of dels) {
295
+ if (!byOp.has(item.op)) byOp.set(item.op, []);
296
+ byOp.get(item.op).push(item);
297
+ }
298
+
299
+ console.log(`📋 计划预览:待处理 ${dels.length} 项,保留 ${keeps.length} 项`);
300
+ console.log('');
301
+ for (const [op, items] of byOp) {
302
+ console.log(` ${OP_LABELS[op] || op}(${items.length} 项):`);
303
+ for (const item of items.slice(0, 5)) console.log(` - ${item.target}`);
304
+ if (items.length > 5) console.log(` … 其余 ${items.length - 5} 项`);
305
+ console.log('');
306
+ }
307
+ if (keeps.length) {
308
+ console.log(` ✅ 保留(${keeps.length} 项):`);
309
+ for (const item of keeps) console.log(` - ${path.basename(item.target)}(${item.reason})`);
310
+ console.log('');
311
+ }
312
+ for (const w of plan.warnings) console.log(` ⚠️ ${w}`);
313
+ if (plan.warnings.length) console.log('');
314
+ }
315
+
316
+ /**
317
+ * 选择 spec 包裹包(单选)。
318
+ *
319
+ * 实现已收敛到 `lib/spec-choice.js#selectSpecRoot`(init / uninstall /
320
+ * sync-repos 三个场景共用的唯一交互实现)。此处仅作转发,保持 bin 层的
321
+ * 导出面不变(既有测试与 `runUninstall` 依赖)。
322
+ *
323
+ * @param {{ask: Function}} prompt readline 会话
324
+ * @param {Array<{abs: string, name: string, source: string, tier?: number}>} candidates
325
+ * @param {{actionLabel?: string}} [options] actionLabel 用于提问文案(默认「卸载」)
326
+ * @returns {Promise<string|null|undefined>} 选中的绝对路径;null=用户取消;undefined=无候选
327
+ */
328
+ async function selectSpecRoot(prompt, candidates, options = {}) {
329
+ return selectSpecChoice(prompt, candidates, options);
330
+ }
331
+
332
+ /** 显示卸载帮助信息 */
333
+ function showUninstallHelp() {
334
+ console.log(`
335
+ KLD SDD 工具卸载
336
+
337
+ 用法:
338
+ kld-sdd uninstall # 选择 Spec → 预览范围 → 确认
339
+ kld-sdd uninstall --dry-run # 只看计划,不修改文件
340
+ kld-sdd uninstall --yes --spec-path <dir> # 明确范围后用于脚本
341
+
342
+ 仅删除可识别的 SDD Skills、运行脚本、只读模板与受管 Hook。
343
+ 始终保留 openspec/ 全部文档、KB 配置、项目身份、历史数据、多仓关联和 Git 历史。
344
+ 自定义 Hook、规则、未知文件与隐私忽略规则保留。不提供全量删除模式。
345
+
346
+ -h, --help 显示本帮助
347
+ --spec-path <dir> 指定目标 Spec;多个候选时必须选择,备份不受影响
348
+ --dry-run 只预览
349
+ -y, --yes 确认工具卸载;非交互执行必须提供
350
+
351
+ 升级不需要先卸载:重新运行 init 覆盖工具文件,保留已有文档及配置。
352
+ `);
353
+ }
354
+
355
+ /** 显示初始化帮助信息 */
356
+ function showInitHelp() {
357
+ console.log(`
358
+ KLD SDD 项目初始化工具
359
+
360
+ 用法:
361
+ kld-sdd-init [选项]
362
+ npx kld-sdd [选项]
363
+ kld-sdd sync-repos # 工作目录下发现新增代码仓并轻量接入(多 spec 候选时单选)
364
+ kld-sdd repair-git # 检查并清理旧 Spec 仓的工具跟踪噪声(默认只预览)
365
+ kld-sdd uninstall [选项] # 卸载 SDD 产物(渐进式:预览工具范围 → 确认)
366
+ kld-sdd uninstall --help # 查看工具卸载与保留范围
367
+
368
+ 选项:
369
+ -h, --help 显示帮助信息
370
+ -v, --version 显示版本号
371
+ --verbose 输出每个安装文件/目录的明细日志(默认仅摘要,亦可用 KLD_SDD_VERBOSE=1)
372
+ --skip-openspec 跳过 openspec init 步骤
373
+ --skip-template 跳过复制内置模版
374
+ --repair-git 检查 Spec 仓中被 Git 跟踪的安装器工具(默认只预览)
375
+ --apply 与 --repair-git 同用:保留本地文件,仅从 Git 索引移除工具
376
+ --json --repair-git 输出机器可读 JSON,供托盘健康检查使用
377
+ --tool <name> 指定编辑器,可用值: cursor, claude, codebuddy, qoder, opencode, kunlunzhima, workbuddy, codex, all
378
+ --spec-path <dir> 指定 spec 包裹包目录(必须是当前目录的子目录)。
379
+ 不指定时(交互终端):自动检测已有 spec 目录(目录名 *-sdd-specs,
380
+ 或 modules.yaml + sdd.config.yaml + openspec/changes + openspec/specs/overview.md 齐全),
381
+ 检测到则询问是否使用;未检测到则要求输入相对路径(回车用默认 <当前目录名>-sdd-specs)。
382
+ 例: 在 erp-klny-all 下 --spec-path erp-sdd-specs
383
+
384
+ 工作区模式(在个人工作目录执行):
385
+ - skills / 编辑器配置只部署一次到当前工作目录
386
+ - 自动发现子目录 Git 仓库
387
+ - spec 仓:modules.yaml / openspec 模板 / 文档门禁
388
+ - 代码仓:仅 commit-msg Hook + .sdd.yaml + link-spec(不装 skills)
389
+ - Trailer 数据仍在代码 commit 时由 Hook 写入
390
+
391
+ 示例:
392
+ kld-sdd-init # 完整初始化流程
393
+ kld-sdd-init --skip-openspec # 跳过 openspec,直接部署 skills
394
+ kld-sdd-init --tool codex # 仅部署 Codex 配置
395
+ kld-sdd-init --spec-path erp-sdd-specs # 指定 spec 目录(当前目录的子目录)
396
+ kld-sdd repair-git --spec-path=. # 预览旧仓工具跟踪问题
397
+ kld-sdd repair-git --apply --spec-path=. # 一次迁出工具,文件仍留在磁盘
398
+ kld-sdd sync-repos # 新代码仓加入后同步接入
399
+ `);
400
+ }
401
+
402
+ // ═══════════════════════════════════════════════════════════════
403
+ // 子命令路由:解析参数后调用 lib/ 业务实现
404
+ // ═══════════════════════════════════════════════════════════════
405
+
406
+ function resolveRepairSpecRoot(parsed, cwd = process.cwd()) {
407
+ const root = path.resolve(cwd);
408
+ if (parsed.specPath) {
409
+ const explicit = path.resolve(root, parsed.specPath);
410
+ if (!specDetect.isSpecPackageRoot(explicit)) {
411
+ throw new Error(`指定目录不是有效的 Spec 包裹包: ${explicit}`);
412
+ }
413
+ return explicit;
414
+ }
415
+ if (specDetect.isSpecPackageRoot(root)) return root;
416
+ const candidates = specDetect.detectSpecCandidatesWithGit(root);
417
+ if (candidates.length === 1) return candidates[0].abs;
418
+ if (candidates.length === 0) throw new Error('未发现 Spec 包裹包,请用 --spec-path=<dir> 指定');
419
+ throw new Error(`发现 ${candidates.length} 个 Spec 包裹包,请用 --spec-path=<dir> 指定,不自动猜测`);
420
+ }
421
+
422
+ function runGitRepair(parsed) {
423
+ const init = require('../lib/init');
424
+ const maintenance = require('../lib/maintenance');
425
+ const specRoot = resolveRepairSpecRoot(parsed);
426
+ const plan = maintenance.trackedManagedSpecTools(specRoot);
427
+ if (!plan.ok) throw new Error(plan.error);
428
+
429
+ if (!parsed.applyRepair) {
430
+ const payload = {
431
+ mode: 'preview',
432
+ specRoot,
433
+ trackedToolCount: plan.files.length,
434
+ trackedTools: plan.files.map((file) => file.relative),
435
+ businessFilesAffected: 0,
436
+ };
437
+ if (parsed.json) {
438
+ console.log(JSON.stringify(payload, null, 2));
439
+ return;
440
+ }
441
+ console.log(`🧹 Spec Git 清洁度检查: ${path.basename(specRoot)}`);
442
+ console.log(` 被 Git 跟踪的可再生成工具: ${plan.files.length} 个`);
443
+ for (const file of plan.files.slice(0, 8)) console.log(` - ${file.relative}`);
444
+ if (plan.files.length > 8) console.log(` … 其余 ${plan.files.length - 8} 个`);
445
+ console.log(' 业务 Spec、配置、项目身份与 Hook 参数: 不处理');
446
+ if (plan.files.length) console.log(' 执行 --repair-git --apply 可一次迁出工具;不会删除磁盘文件。');
447
+ return;
448
+ }
449
+
450
+ const existingBefore = plan.files.filter((file) => require('fs').existsSync(path.join(specRoot, file.relative)));
451
+ init.updateGitignore(specRoot, { specPackage: true });
452
+ const result = maintenance.untrackManagedSpecTools(specRoot);
453
+ if (!result.ok) throw new Error(result.error);
454
+ const payload = {
455
+ mode: 'applied',
456
+ specRoot,
457
+ untrackedToolCount: result.files.length,
458
+ filesRemainOnDisk: existingBefore.every((file) => require('fs').existsSync(path.join(specRoot, file.relative))),
459
+ retainedLocalToolCount: existingBefore.length,
460
+ alreadyMissingToolCount: result.files.length - existingBefore.length,
461
+ businessFilesAffected: 0,
462
+ };
463
+ if (parsed.json) {
464
+ console.log(JSON.stringify(payload, null, 2));
465
+ return;
466
+ }
467
+ console.log(`✅ 已将 ${result.files.length} 个安装器工具移出 Git 跟踪,现存的 ${existingBefore.length} 个本地文件全部保留`);
468
+ if (payload.alreadyMissingToolCount) console.log(` ℹ️ 其中 ${payload.alreadyMissingToolCount} 个文件在修复前已从磁盘删除`);
469
+ console.log(' 请审阅一次 git status 并提交迁移;以后升级不再污染业务改动列表。');
470
+ }
471
+
472
+ async function runInit() {
473
+ let parsed;
474
+ try {
475
+ parsed = parseInitArgs(args);
476
+ } catch (error) {
477
+ fail(error.message);
478
+ return;
479
+ }
480
+
481
+ if (parsed.help) {
482
+ showInitHelp();
483
+ return;
484
+ }
485
+
486
+ if (parsed.version) {
487
+ console.log(require('../package.json').version);
488
+ return;
489
+ }
490
+
491
+ if (parsed.repairGit) {
492
+ try {
493
+ runGitRepair(parsed);
494
+ } catch (error) {
495
+ fail(`Git 清洁度修复失败: ${error.message}`);
496
+ }
497
+ return;
498
+ }
499
+
500
+ const init = require('../lib/init');
501
+ init.setVerbose(parsed.verbose);
502
+
503
+ try {
504
+ await init.main({
505
+ tools: parsed.tools,
506
+ selectTools: selectEditor,
507
+ hasToolArg: parsed.hasToolArg,
508
+ specPath: parsed.specPath,
509
+ skipOpenspec: parsed.skipOpenspec,
510
+ skipTemplate: parsed.skipTemplate,
511
+ });
512
+ } catch (error) {
513
+ fail(`初始化失败: ${error.message}`);
514
+ }
515
+ }
516
+
517
+ /**
518
+ * 卸载(渐进式):方式选择 → 计划预览 → 确认执行。
519
+ *
520
+ * 交互编排全部在本函数(CLI 输入层);仅选择 Spec、预览工具范围并确认:
521
+ * - buildUninstallPlan 纯计算计划,不落盘
522
+ * - main 按给定计划执行,无交互
523
+ *
524
+ * 非交互场景(管道 / CI / --dry-run / --yes)自动退化为直接执行。
525
+ */
526
+ async function runUninstall(argv = args, deps = {}) {
527
+ let opts;
528
+ try {
529
+ opts = parseUninstallArgs(argv);
530
+ } catch (error) {
531
+ fail(error.message);
532
+ return;
533
+ }
534
+
535
+ if (opts.help) {
536
+ showUninstallHelp();
537
+ return;
538
+ }
539
+
540
+ const uninstall = require('../lib/uninstall');
541
+ const { buildUninstallPlan, main: runUninstallPlan, listSpecCandidatesForUninstall } = uninstall;
542
+ // 工作目录固定为 cwd;deps.cwd 仅供测试注入(避免测试改全局 cwd)
543
+ const cwd = deps.cwd ? path.resolve(deps.cwd) : opts.cwd;
544
+ const { dryRun } = opts;
545
+ let mode = opts.mode;
546
+
547
+ // 交互依赖可注入(测试用):默认走真实 readline 与 process.stdin.isTTY
548
+ const promptFactory = deps.openPrompt || openPrompt;
549
+ const isTTY = deps.isTTY !== undefined ? Boolean(deps.isTTY) : Boolean(process.stdin.isTTY);
550
+
551
+ // 交互能力判定:
552
+ // - --dry-run 语义是「只看不改」,不弹菜单
553
+ // - --yes 表示无人值守,跳过全部询问
554
+ // - 无 TTY(管道 / CI)同样跳过询问
555
+ const canPrompt = isTTY && !dryRun && !opts.yes;
556
+
557
+ // 候选枚举:结构判据无法区分「当前项目的 spec 仓」与「用户复制的备份」
558
+ //(如 `erp-sdd-specs copy` 结构标记齐全),故由用户单选裁决。
559
+ // 显式 --spec-path 时跳过枚举(调用方已指定)。
560
+ let specRoot = opts.specPath ? path.resolve(cwd, opts.specPath) : undefined;
561
+ const candidates = specRoot ? [] : listSpecCandidatesForUninstall(cwd);
562
+
563
+ if (!specRoot && candidates.length > 1 && !canPrompt) {
564
+ // 无人值守 + 多候选:不猜,报错要求显式指定(猜错 = 不可逆数据丢失)。
565
+ // 抛错而非 process.exit:由 route() 统一转成非零退出码,测试也可断言。
566
+ const lines = [
567
+ `❌ 检测到 ${candidates.length} 个 spec 仓库,无法自动判定要卸载哪一个:`,
568
+ ...candidates.map((c) => ` - ${c.abs}`),
569
+ ' 请用 --spec-path=<dir> 显式指定,或在交互终端中运行以手动选择。',
570
+ ];
571
+ for (const line of lines) console.error(line);
572
+ throw new Error('多个 spec 仓库候选,需 --spec-path 显式指定');
573
+ }
574
+
575
+ if (!specRoot && !canPrompt && candidates.length === 1) specRoot = candidates[0].abs;
576
+ const prompt = canPrompt ? promptFactory() : null;
577
+
578
+ // 步骤 1:spec 仓库选择(多候选 → 用户单选;单候选 → 确认)
579
+ // 必须在生成计划之前完成,保证预览列出的是用户真正要卸载的那一个。
580
+ try {
581
+ if (prompt && !specRoot) {
582
+ const chosenSpec = await selectSpecRoot(prompt, candidates);
583
+ if (chosenSpec === null) {
584
+ console.log('已取消');
585
+ return;
586
+ }
587
+ if (chosenSpec) specRoot = chosenSpec;
588
+ }
589
+
590
+ // 步骤 2:生成计划(纯计算,用于空产物判定与预览)
591
+ let planned = buildUninstallPlan({ cwd, mode, specRoot });
592
+
593
+ if (planned.plan.ops.length === 0) {
594
+ printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
595
+ console.log('ℹ️ 未发现需要处理的 SDD 产物(可能未初始化或已卸载)');
596
+ return;
597
+ }
598
+
599
+ if (!prompt) {
600
+ if (!dryRun && !opts.yes) throw new Error('非交互卸载需要 --yes;可先用 --dry-run 预览');
601
+ printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
602
+ if (dryRun) printPlanPreview(planned.plan);
603
+ // 已打印过预览(含警告)时不重复;直接执行时由 lib 打印
604
+ const result = runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: !dryRun });
605
+ if (!result.ok) throw new Error(result.errors.join('; '));
606
+ return;
607
+ }
608
+
609
+ // 步骤 4:计划预览
610
+ printUninstallHeader(cwd, planned.specRoot, mode, dryRun);
611
+ printPlanPreview(planned.plan);
612
+
613
+ // 步骤 5:确认执行
614
+ const dels = planned.plan.ops.filter((o) => o.op !== OP.KEEP).length;
615
+ const answer = await prompt.ask(`将处理 ${dels} 项,此操作不可撤销。确认执行? (y/N): `);
616
+ if (!/^y(es)?$/i.test(answer)) {
617
+ console.log('已取消');
618
+ return;
619
+ }
620
+ console.log('');
621
+
622
+ // 交互路径:警告已由 printPlanPreview 打印过,不再重复
623
+ const result = runUninstallPlan({ cwd, mode, dryRun, plan: planned.plan, showWarnings: false });
624
+ if (!result.ok) throw new Error(result.errors.join('; '));
625
+ } finally {
626
+ if (prompt) prompt.close();
627
+ }
628
+ }
629
+
630
+ /**
631
+ * sync-repos:工作区新增代码仓后轻量接入。
632
+ *
633
+ * spec 定位与 init / uninstall 同契约:多候选时让用户单选,**不静默挑一个**。
634
+ * 这里尤其重要 —— 本命令会把 spec 根路径写进每个代码仓的
635
+ * `git config sdd.specPath`,挑错等于污染全部代码仓的关联关系。
636
+ *
637
+ * 交互能力判定与 uninstall 一致:无 TTY / 非交互时不询问;
638
+ * 此时多候选直接报错,要求用 --spec-path 显式指定。
639
+ */
640
+ async function runSyncRepos() {
641
+ const { syncWorkspaceRepos, listSpecCandidatesForInit } = require('../lib/init');
642
+ const opts = parseSyncReposArgs(args);
643
+
644
+ let specRoot = opts.specPath ? path.resolve(opts.specPath) : undefined;
645
+ const candidates = specRoot ? [] : listSpecCandidatesForInit(opts.workspaceRoot);
646
+ const canPrompt = Boolean(process.stdin.isTTY) && !opts.yes;
647
+
648
+ // 多候选 + 无法询问:不猜(猜错不可逆 —— 写错的是每个代码仓的 git config)
649
+ if (!specRoot && candidates.length > 1 && !canPrompt) {
650
+ const lines = [
651
+ `❌ 检测到 ${candidates.length} 个 spec 仓库,无法自动判定要接入哪一个:`,
652
+ ...candidates.map((c) => ` - ${c.abs} [${specSourceLabel(c.source)}]`),
653
+ ' 请用 --spec-path=<dir> 显式指定,或在交互终端中运行以手动选择。',
654
+ ];
655
+ for (const line of lines) console.error(line);
34
656
  process.exit(1);
35
657
  }
36
- console.log(`✅ 已设置 git config --local sdd.specPath = ${result.path}`);
37
- } else if (args[0] === 'sync-repos') {
38
- const { syncWorkspaceRepos } = require('../lib/init');
39
- const projectArg = args.find((item) => item.startsWith('--project='));
40
- const workspaceRoot = path.resolve(projectArg ? projectArg.slice('--project='.length) : '.');
41
- const all = args.includes('--all');
42
- const result = syncWorkspaceRepos(workspaceRoot, { onlyNew: !all });
658
+
659
+ // 单选(0 个候选 → undefined;1 个 → 确认;N 个 → 列出选择)
660
+ if (!specRoot && candidates.length > 0 && canPrompt) {
661
+ const prompt = openPrompt();
662
+ try {
663
+ const chosen = await selectSpecRoot(prompt, candidates, { actionLabel: '接入' });
664
+ if (chosen === null) {
665
+ console.log('已取消');
666
+ return;
667
+ }
668
+ if (chosen) specRoot = chosen;
669
+ } finally {
670
+ prompt.close();
671
+ }
672
+ }
673
+
674
+ const result = syncWorkspaceRepos(opts.workspaceRoot, {
675
+ onlyNew: opts.onlyNew,
676
+ specRoot,
677
+ });
678
+
43
679
  if (!result.ok && result.attached.length === 0 && (!result.layout || !result.layout.specRepo)) {
44
680
  process.exit(1);
45
681
  }
@@ -50,15 +686,63 @@ if (args[0] === 'log') {
50
686
  process.exit(1);
51
687
  }
52
688
  console.log(`✅ sync-repos 完成,接入 ${result.attached.length} 个代码仓`);
53
- } else if (args[0] === 'auth') {
54
- // 登录体系已随 2.7.0 移除:仅保留 deprecation 提示,不进入 init 向导
55
- console.log('ℹ️ auth 子命令已于 2.7.0 移除:使用统计按本机 git 身份(user.name/user.email)上报,无需绑定。');
56
- process.exit(0);
57
- } else {
58
- // 正常初始化模式
59
- const { main } = require('../lib/init');
60
- main().catch(error => {
61
- console.error('❌ 初始化失败:', error.message);
62
- process.exit(1);
63
- });
64
689
  }
690
+
691
+ function runLinkSpec() {
692
+ let opts;
693
+ try {
694
+ opts = parseLinkSpecArgs(args);
695
+ } catch (error) {
696
+ fail(error.message);
697
+ return;
698
+ }
699
+
700
+ const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
701
+ const result = sddConfig.setSpecPath(opts.projectRoot, opts.specPath);
702
+ if (!result.ok) {
703
+ fail(result.message);
704
+ return;
705
+ }
706
+ console.log(`✅ 已设置 git config --local sdd.specPath = ${result.path}`);
707
+ }
708
+
709
+ /**
710
+ * 子命令路由(仅在被当作入口脚本执行时触发)。
711
+ *
712
+ * require.main 守卫:使本文件可被测试 require 以复用卸载编排函数,
713
+ * 而不产生「加载即执行」的副作用。
714
+ */
715
+ function route() {
716
+ if (command === 'uninstall') {
717
+ // 卸载模式:默认只删工具内容,保留用户配置与运行时数据
718
+ runUninstall().catch((error) => fail(error.message));
719
+ } else if (command === 'log') {
720
+ // Telemetry CLI 模式(运行时入口为 index.cjs,部署到用户仓后改名为 log.cjs)
721
+ require('../skywalk-sdd/index.cjs').main();
722
+ } else if (command === 'link-spec') {
723
+ runLinkSpec();
724
+ } else if (command === 'sync-repos') {
725
+ // 与 uninstall 一致:异步失败统一转成 stderr + 非零退出码
726
+ runSyncRepos().catch((error) => fail(error.message));
727
+ } else if (command === 'auth') {
728
+ // 登录体系已随 2.7.0 移除:仅保留 deprecation 提示,不进入 init 向导
729
+ console.log('ℹ️ auth 子命令已于 2.7.0 移除:使用统计按本机 git 身份(user.name/user.email)上报,无需绑定。');
730
+ process.exit(0);
731
+ } else {
732
+ // 正常初始化模式
733
+ runInit();
734
+ }
735
+ }
736
+
737
+ if (require.main === module) {
738
+ route();
739
+ }
740
+
741
+ module.exports = {
742
+ route,
743
+ runUninstall,
744
+ parseUninstallArgs,
745
+ printPlanPreview,
746
+ selectSpecRoot,
747
+ showUninstallHelp,
748
+ };