specdrive-cli 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +955 -697
  2. package/agents/00-onboarding.md +261 -0
  3. package/agents/01-constitution.md +214 -201
  4. package/agents/02-specification.md +249 -226
  5. package/agents/03-uiux.md +156 -144
  6. package/agents/04-cascade.md +151 -122
  7. package/agents/05-discover-skills.md +136 -136
  8. package/agents/06-documentation.md +158 -145
  9. package/agents/07-implementation.md +201 -169
  10. package/agents/08-performance.md +179 -165
  11. package/agents/09-review-complete.md +239 -168
  12. package/agents/10-security.md +180 -167
  13. package/agents/11-test.md +195 -0
  14. package/commands/gates.js +73 -73
  15. package/commands/manifest.json +113 -95
  16. package/commands/permissions.json +39 -0
  17. package/commands/router.js +151 -127
  18. package/commands/tools.json +19 -19
  19. package/dashboard/app.js +394 -0
  20. package/dashboard/index.html +74 -0
  21. package/dashboard/server.js +166 -0
  22. package/dashboard/style.css +157 -0
  23. package/mcp/mcp.json +31 -0
  24. package/mcp/server.js +108 -0
  25. package/package.json +35 -32
  26. package/schemas/config.schema.json +20 -0
  27. package/schemas/workflow-state.schema.json +149 -38
  28. package/scripts/anti-redundancy.js +176 -176
  29. package/scripts/audit-log.js +46 -46
  30. package/scripts/check-permission.js +87 -0
  31. package/scripts/diff-spec.js +50 -50
  32. package/scripts/diff-version.js +96 -0
  33. package/scripts/generate-adapters.js +80 -80
  34. package/scripts/generate-from-template.js +97 -97
  35. package/scripts/generate-openapi.js +75 -75
  36. package/scripts/github-team-sync.js +80 -80
  37. package/scripts/install-hooks.js +20 -20
  38. package/scripts/load-plugins.js +65 -65
  39. package/scripts/migrate-openspec.js +318 -0
  40. package/scripts/migrate-speckit.js +322 -0
  41. package/scripts/migrate.js +12 -62
  42. package/scripts/onboard.js +312 -0
  43. package/scripts/pre-commit.js +56 -20
  44. package/scripts/team.js +113 -113
  45. package/scripts/test-adapters.js +118 -118
  46. package/scripts/test-create.js +13 -13
  47. package/scripts/test-end-to-end.js +137 -137
  48. package/scripts/test-router.js +110 -110
  49. package/scripts/test-state-transitions.js +146 -146
  50. package/scripts/test-validator.js +152 -152
  51. package/scripts/validate-config.js +36 -0
  52. package/scripts/validate-governance.js +150 -130
  53. package/scripts/verify.js +525 -0
  54. package/scripts/version-new.js +202 -0
  55. package/src/index.js +1010 -807
  56. package/templates/expo/plan.json +12 -0
  57. package/templates/expo/spec.json +12 -0
  58. package/templates/expo/tasks.json +5 -0
  59. package/templates/fastapi/plan.json +12 -0
  60. package/templates/fastapi/spec.json +12 -0
  61. package/templates/fastapi/tasks.json +5 -0
  62. package/templates/generic/plan.json +12 -0
  63. package/templates/generic/spec.json +11 -0
  64. package/templates/generic/tasks.json +5 -0
  65. package/templates/nextjs/plan.json +23 -0
  66. package/templates/nextjs/spec.json +12 -0
  67. package/templates/nextjs/tasks.json +5 -0
  68. package/templates/react-node/plan.json +15 -0
  69. package/templates/react-node/spec.json +12 -0
  70. package/templates/react-node/tasks.json +5 -0
  71. package/templates/registry.json +30 -0
  72. package/templates/turborepo/plan.json +12 -0
  73. package/templates/turborepo/spec.json +12 -0
  74. package/templates/turborepo/tasks.json +5 -0
package/src/index.js CHANGED
@@ -1,808 +1,1011 @@
1
- #!/usr/bin/env node
2
-
3
- const { Command } = require("commander");
4
- const { execSync } = require("child_process");
5
- const fs = require("fs");
6
- const path = require("path");
7
- const os = require("os");
8
- const readline = require("readline");
9
-
10
- const program = new Command();
11
-
12
- program
13
- .name("sdrive")
14
- .description("SpecDrive Enterprise Spec-Driven Development CLI")
15
- .version("0.1.0");
16
-
17
- function adapterContent(toolName) {
18
- return `# SpecDrive Commands
19
-
20
- This project uses SpecDrive for spec-driven development.
21
-
22
- ## How to use
23
-
24
- When the user types a /sdrive:* command, follow these steps:
25
-
26
- 1. Read .sdrive/commands/manifest.json
27
- 2. Find the matching command
28
- 3. Read the agent file listed in agentFile
29
- 4. Follow that agent's instructions exactly
30
- 5. Run validators after writing files
31
- 6. Stop at human approval gates
32
-
33
- Always read the manifest first. Never hardcode the command list.
34
- `;
35
- }
36
-
37
- function mcpServerContent() {
38
- return `#!/usr/bin/env node
39
-
40
- const fs = require("fs");
41
- const path = require("path");
42
-
43
- const projectRoot = process.cwd();
44
-
45
- function loadWorkflowState() {
46
- const statePath = path.join(projectRoot, ".sdrive", "workflow-state.json");
47
- if (!fs.existsSync(statePath)) return { features: [] };
48
- return JSON.parse(fs.readFileSync(statePath, "utf8"));
49
- }
50
-
51
- function listFeatures() {
52
- return loadWorkflowState().features || [];
53
- }
54
-
55
- function getFeature(name) {
56
- return listFeatures().find((f) => f.name === name);
57
- }
58
-
59
- function getSpec(name) {
60
- const specPath = path.join(projectRoot, ".sdrive", "governance", name, "spec.json");
61
- if (!fs.existsSync(specPath)) return null;
62
- return JSON.parse(fs.readFileSync(specPath, "utf8"));
63
- }
64
-
65
- function getTraceability(name) {
66
- const tracePath = path.join(projectRoot, ".sdrive", "governance", name, "traceability.json");
67
- if (!fs.existsSync(tracePath)) return null;
68
- return JSON.parse(fs.readFileSync(tracePath, "utf8"));
69
- }
70
-
71
- function runValidator() {
72
- const { execSync } = require("child_process");
73
- const validatorPath = path.join(projectRoot, ".sdrive", "scripts", "validate-governance.js");
74
- try {
75
- execSync(\`node "\${validatorPath}"\`, { stdio: "pipe" });
76
- return { success: true };
77
- } catch (error) {
78
- return { success: false, message: error.message };
79
- }
80
- }
81
-
82
- const handlers = {
83
- "specdrive.list_features": () => ({ features: listFeatures() }),
84
- "specdrive.get_feature": ({ name }) => ({ feature: getFeature(name) }),
85
- "specdrive.get_spec": ({ name }) => ({ spec: getSpec(name) }),
86
- "specdrive.get_traceability": ({ name }) => ({ traceability: getTraceability(name) }),
87
- "specdrive.validate": () => runValidator(),
88
- "specdrive.status": () => {
89
- const features = listFeatures();
90
- return {
91
- count: features.length,
92
- features: features.map((f) => ({
93
- name: f.name,
94
- phase: f.phase,
95
- currentStep: f.currentStep
96
- }))
97
- };
98
- }
99
- };
100
-
101
- const input = process.argv[2] ? JSON.parse(process.argv[2]) : null;
102
-
103
- if (!input) {
104
- console.error("❌ Usage: node server.js '{\\"method\\": \\"specdrive.list_features\\"}'");
105
- process.exit(1);
106
- }
107
-
108
- const handler = handlers[input.method];
109
-
110
- if (!handler) {
111
- console.error(\`❌ Unknown MCP method: \${input.method}\`);
112
- process.exit(1);
113
- }
114
-
115
- const result = handler(input.params || {});
116
- console.log(JSON.stringify(result, null, 2));
117
- `;
118
- }
119
-
120
- function mcpConfigContent() {
121
- return JSON.stringify(
122
- {
123
- mcpServers: {
124
- specdrive: {
125
- command: "node",
126
- args: [".sdrive/mcp/server.js"]
127
- }
128
- }
129
- },
130
- null,
131
- 2
132
- );
133
- }
134
-
135
- function safeMergeMcpConfig(configPath, mcpContent) {
136
- if (!fs.existsSync(configPath)) {
137
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
138
- fs.writeFileSync(configPath, mcpContent);
139
- console.log(`✅ Created: ${configPath}`);
140
- return;
141
- }
142
-
143
- try {
144
- const existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
145
- existing.mcpServers = existing.mcpServers || {};
146
-
147
- if (existing.mcpServers.specdrive) {
148
- console.log(`⚠️ SpecDrive already configured in ${configPath}`);
149
- return;
150
- }
151
-
152
- existing.mcpServers.specdrive = {
153
- command: "node",
154
- args: [".sdrive/mcp/server.js"]
155
- };
156
-
157
- fs.writeFileSync(configPath, JSON.stringify(existing, null, 2));
158
- console.log(`✅ Merged SpecDrive into: ${configPath}`);
159
- } catch (error) {
160
- console.error(`⚠️ Could not merge into ${configPath}`);
161
- }
162
- }
163
-
164
- function getMcpConfigPaths() {
165
- const paths = [];
166
-
167
- const platform = os.platform();
168
- const home = os.homedir();
169
-
170
- if (platform === "darwin") {
171
- paths.push({
172
- name: "Claude Desktop",
173
- path: path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
174
- });
175
- }
176
-
177
- if (platform === "win32") {
178
- paths.push({
179
- name: "Claude Desktop",
180
- path: path.join(process.env.APPDATA || "", "Claude", "claude_desktop_config.json")
181
- });
182
- }
183
-
184
- paths.push({
185
- name: "Cursor",
186
- path: path.join(process.cwd(), ".cursor", "mcp.json")
187
- });
188
-
189
- paths.push({
190
- name: "Cline",
191
- path: path.join(process.cwd(), ".cline", "mcp.json")
192
- });
193
-
194
- paths.push({
195
- name: "VS Code",
196
- path: path.join(process.cwd(), ".vscode", "mcp.json")
197
- });
198
-
199
- return paths;
200
- }
201
-
202
- // ---------- INIT COMMAND ----------
203
- program
204
- .command("init")
205
- .description("Initialize SpecDrive structure, tool adapters, and default template")
206
- .action(() => {
207
- const projectRoot = process.cwd();
208
- const sdriveRoot = path.join(projectRoot, ".sdrive");
209
-
210
- const dirs = [
211
- path.join(sdriveRoot, "agents"),
212
- path.join(sdriveRoot, "commands"),
213
- path.join(sdriveRoot, "schemas"),
214
- path.join(sdriveRoot, "specs", "backlog"),
215
- path.join(sdriveRoot, "specs", "ongoing"),
216
- path.join(sdriveRoot, "specs", "completed"),
217
- path.join(sdriveRoot, "governance"),
218
- path.join(sdriveRoot, "prototype", "css"),
219
- path.join(sdriveRoot, "prototype", "js"),
220
- path.join(sdriveRoot, "prototype", "screens"),
221
- path.join(sdriveRoot, "reports", "security"),
222
- path.join(sdriveRoot, "reports", "performance"),
223
- path.join(sdriveRoot, "reports", "docs"),
224
- path.join(sdriveRoot, "context"),
225
- path.join(sdriveRoot, "skills"),
226
- path.join(sdriveRoot, "templates"),
227
- path.join(sdriveRoot, "mcp")
228
- ];
229
-
230
- dirs.forEach((dir) => {
231
- fs.mkdirSync(dir, { recursive: true });
232
- console.log(` Created: ${path.relative(projectRoot, dir)}`);
233
- });
234
-
235
- const workflowStatePath = path.join(sdriveRoot, "workflow-state.json");
236
- if (!fs.existsSync(workflowStatePath)) {
237
- fs.writeFileSync(workflowStatePath, JSON.stringify({ features: [] }, null, 2));
238
- console.log(" Created: .sdrive/workflow-state.json");
239
- }
240
-
241
- const constitutionPath = path.join(sdriveRoot, "constitution.md");
242
- if (!fs.existsSync(constitutionPath)) {
243
- fs.writeFileSync(constitutionPath, "# SpecDrive Constitution\n\nInitialized by `sdrive init`.\n");
244
- console.log(" Created: .sdrive/constitution.md");
245
- }
246
-
247
- const configPath = path.join(sdriveRoot, "config.json");
248
- if (!fs.existsSync(configPath)) {
249
- fs.writeFileSync(configPath, JSON.stringify({ defaultTemplate: "generic" }, null, 2));
250
- console.log(" Created: .sdrive/config.json");
251
- }
252
-
253
- const teamPath = path.join(sdriveRoot, "team.json");
254
- if (!fs.existsSync(teamPath)) {
255
- fs.writeFileSync(
256
- teamPath,
257
- JSON.stringify(
258
- {
259
- members: [],
260
- rules: {
261
- approval_required: ["admin", "reviewer"],
262
- implementation_allowed: ["developer", "admin"]
263
- }
264
- },
265
- null,
266
- 2
267
- )
268
- );
269
- console.log(" Created: .sdrive/team.json");
270
- }
271
-
272
- // MCP server file
273
- const mcpServerPath = path.join(sdriveRoot, "mcp", "server.js");
274
- if (!fs.existsSync(mcpServerPath)) {
275
- fs.writeFileSync(mcpServerPath, mcpServerContent());
276
- fs.chmodSync(mcpServerPath, 0o755);
277
- console.log(" Created: .sdrive/mcp/server.js");
278
- }
279
-
280
- // MCP config template
281
- const mcpConfigPath = path.join(sdriveRoot, "mcp", "mcp.json");
282
- if (!fs.existsSync(mcpConfigPath)) {
283
- fs.writeFileSync(mcpConfigPath, mcpConfigContent());
284
- console.log(" Created: .sdrive/mcp/mcp.json");
285
- }
286
-
287
- // Tool selection
288
- const toolsPath = path.join(sdriveRoot, "commands", "tools.json");
289
-
290
- if (!fs.existsSync(toolsPath)) {
291
- console.log("\n⚠️ tools.json not found. Skipping tool selection.");
292
- console.log("\n✅ SpecDrive initialized successfully.");
293
- return;
294
- }
295
-
296
- const tools = JSON.parse(fs.readFileSync(toolsPath, "utf8")).tools;
297
-
298
- console.log("\n🔧 Select tools to set up (comma-separated numbers):\n");
299
- tools.forEach((tool, index) => {
300
- console.log(` ${index + 1}. ${tool.name}`);
301
- });
302
- console.log(" Or press Enter to generate all.\n");
303
-
304
- const rl = readline.createInterface({
305
- input: process.stdin,
306
- output: process.stdout
307
- });
308
-
309
- rl.question("Selection: ", (answer) => {
310
- rl.close();
311
-
312
- let selectedTools = tools;
313
-
314
- if (answer.trim() !== "") {
315
- const selectedIndexes = answer.split(",").map((n) => parseInt(n.trim()) - 1);
316
- selectedTools = selectedIndexes
317
- .filter((i) => i >= 0 && i < tools.length)
318
- .map((i) => tools[i]);
319
- }
320
-
321
- selectedTools.forEach((tool) => {
322
- const adapterFullPath = path.join(projectRoot, tool.adapterPath);
323
- const adapterDir = path.dirname(adapterFullPath);
324
-
325
- if (!fs.existsSync(adapterDir)) {
326
- fs.mkdirSync(adapterDir, { recursive: true });
327
- }
328
-
329
- if (fs.existsSync(adapterFullPath)) {
330
- console.log(`⚠️ Skipped existing: ${tool.adapterPath}`);
331
- return;
332
- }
333
-
334
- fs.writeFileSync(adapterFullPath, adapterContent(tool.name));
335
- console.log(`✅ Generated: ${tool.adapterPath}`);
336
- });
337
-
338
- const templates = ["generic", "react-node", "nextjs", "expo", "fastapi", "turborepo"];
339
-
340
- console.log("\n📦 Select default template:\n");
341
- templates.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
342
-
343
- const rl2 = readline.createInterface({
344
- input: process.stdin,
345
- output: process.stdout
346
- });
347
-
348
- rl2.question("Template (default: 1): ", (templateAnswer) => {
349
- rl2.close();
350
-
351
- const idx = templateAnswer.trim() === "" ? 0 : parseInt(templateAnswer.trim()) - 1;
352
- const selectedTemplate = templates[idx] || "generic";
353
-
354
- const cfgPath = path.join(sdriveRoot, "config.json");
355
- fs.writeFileSync(cfgPath, JSON.stringify({ defaultTemplate: selectedTemplate }, null, 2));
356
-
357
- console.log(`\n✅ Default template set to: ${selectedTemplate}`);
358
-
359
- // MCP wiring
360
- const mcpPaths = getMcpConfigPaths();
361
-
362
- console.log("\n🔌 MCP Client Wiring\n");
363
- mcpPaths.forEach((p, i) => {
364
- console.log(` ${i + 1}. ${p.name} (${p.path})`);
365
- });
366
- console.log(" Or press Enter to skip MCP wiring.\n");
367
-
368
- const rl3 = readline.createInterface({
369
- input: process.stdin,
370
- output: process.stdout
371
- });
372
-
373
- rl3.question("Wire MCP clients: ", (mcpAnswer) => {
374
- rl3.close();
375
-
376
- if (mcpAnswer.trim() === "") {
377
- console.log("\n⚠️ MCP wiring skipped.");
378
- console.log("\n✅ SpecDrive initialized successfully.");
379
- return;
380
- }
381
-
382
- const selectedIndexes = mcpAnswer.split(",").map((n) => parseInt(n.trim()) - 1);
383
- const selectedPaths = selectedIndexes
384
- .filter((i) => i >= 0 && i < mcpPaths.length)
385
- .map((i) => mcpPaths[i]);
386
-
387
- selectedPaths.forEach((p) => {
388
- safeMergeMcpConfig(p.path, mcpConfigContent());
389
- });
390
-
391
- console.log("\n✅ SpecDrive initialized successfully.");
392
- });
393
- });
394
- });
395
- });
396
-
397
- // ---------- TEMPLATE COMMANDS ----------
398
- program
399
- .command("template:list")
400
- .description("List available templates")
401
- .action(() => {
402
- const templates = ["generic", "react-node", "nextjs", "expo", "fastapi", "turborepo"];
403
- console.log("\nAvailable Templates\n");
404
- templates.forEach((t) => console.log(` ${t}`));
405
- });
406
-
407
- program
408
- .command("template:set <name>")
409
- .description("Set default template")
410
- .action((name) => {
411
- const templates = ["generic", "react-node", "nextjs", "expo", "fastapi", "turborepo"];
412
-
413
- if (!templates.includes(name)) {
414
- console.error(`❌ Invalid template. Run \`sdrive template:list\` to see available templates.`);
415
- process.exit(1);
416
- }
417
-
418
- const configPath = path.join(process.cwd(), ".sdrive", "config.json");
419
-
420
- if (!fs.existsSync(configPath)) {
421
- console.error("❌ config.json not found. Run `sdrive init` first.");
422
- process.exit(1);
423
- }
424
-
425
- const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
426
- config.defaultTemplate = name;
427
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
428
-
429
- console.log(`✅ Default template set to: ${name}`);
430
- });
431
-
432
- // ---------- VALIDATE COMMAND ----------
433
- program
434
- .command("validate")
435
- .description("Validate SpecDrive governance files")
436
- .action(() => {
437
- const projectRoot = process.cwd();
438
- const validatorPath = path.join(projectRoot, ".sdrive", "scripts", "validate-governance.js");
439
-
440
- if (!fs.existsSync(validatorPath)) {
441
- console.error("❌ Validator not found. Run `sdrive init` first.");
442
- process.exit(1);
443
- }
444
-
445
- try {
446
- execSync(`node "${validatorPath}"`, { stdio: "inherit" });
447
- } catch (error) {
448
- console.error("\n❌ Validation failed.");
449
- process.exit(1);
450
- }
451
- });
452
-
453
- // ---------- CREATE COMMAND ----------
454
- program
455
- .command("create <feature>")
456
- .description("Create a new feature scaffold (internal)")
457
- .action((feature) => {
458
- if (!/^[a-z0-9-]+$/.test(feature)) {
459
- console.error("❌ Feature name must be lowercase letters, numbers, and hyphens only.");
460
- process.exit(1);
461
- }
462
-
463
- const projectRoot = process.cwd();
464
- const featureSpecDir = path.join(projectRoot, ".sdrive", "specs", "backlog", feature);
465
- const featureGovDir = path.join(projectRoot, ".sdrive", "governance", feature);
466
-
467
- if (fs.existsSync(featureSpecDir) || fs.existsSync(featureGovDir)) {
468
- console.error(`❌ Feature "${feature}" already exists.`);
469
- process.exit(1);
470
- }
471
-
472
- fs.mkdirSync(featureSpecDir, { recursive: true });
473
- fs.mkdirSync(featureGovDir, { recursive: true });
474
-
475
- fs.writeFileSync(
476
- path.join(featureSpecDir, "spec.md"),
477
- `---\ntitle: ${feature}\nstatus: backlog\ncreated: ${new Date().toISOString()}\nupdated: ${new Date().toISOString()}\n---\n\n# ${feature}\n`
478
- );
479
-
480
- fs.writeFileSync(path.join(featureSpecDir, "plan.md"), `# Technical Plan: ${feature}\n`);
481
- fs.writeFileSync(path.join(featureSpecDir, "tasks.md"), `# Execution Tasks: ${feature}\n`);
482
-
483
- fs.writeFileSync(
484
- path.join(featureGovDir, "spec.json"),
485
- JSON.stringify({ feature, title: feature, version: 1, overview: {}, userStories: [], functionalRequirements: [] }, null, 2)
486
- );
487
-
488
- fs.writeFileSync(
489
- path.join(featureGovDir, "plan.json"),
490
- JSON.stringify({ feature, version: 1, architecture: {}, dataModel: {} }, null, 2)
491
- );
492
-
493
- fs.writeFileSync(
494
- path.join(featureGovDir, "tasks.json"),
495
- JSON.stringify({ feature, version: 1, phases: [] }, null, 2)
496
- );
497
-
498
- fs.writeFileSync(
499
- path.join(featureGovDir, "traceability.json"),
500
- JSON.stringify({ feature, version: 1, rows: [] }, null, 2)
501
- );
502
-
503
- console.log(`✅ Feature "${feature}" created in backlog.`);
504
- });
505
-
506
- // ---------- STATUS COMMAND ----------
507
- program
508
- .command("status")
509
- .description("Show feature lifecycle state")
510
- .action(() => {
511
- const projectRoot = process.cwd();
512
- const workflowStatePath = path.join(projectRoot, ".sdrive", "workflow-state.json");
513
-
514
- if (!fs.existsSync(workflowStatePath)) {
515
- console.error("❌ workflow-state.json not found. Run `sdrive init` first.");
516
- process.exit(1);
517
- }
518
-
519
- const state = JSON.parse(fs.readFileSync(workflowStatePath, "utf8"));
520
-
521
- if (!state.features || state.features.length === 0) {
522
- console.log("No features found.");
523
- return;
524
- }
525
-
526
- console.log("\nSpecDrive Feature Status\n");
527
-
528
- state.features.forEach((f) => {
529
- console.log(`📁 ${f.name}`);
530
- console.log(` Phase: ${f.phase}`);
531
- console.log(` Current Step: ${f.currentStep}`);
532
- console.log(` Gates:`);
533
- console.log(` specification: ${f.gates?.specification ?? "pending"}`);
534
- console.log(` planning: ${f.gates?.planning ?? "pending"}`);
535
- console.log(` implementation: ${f.gates?.implementation ?? "pending"}`);
536
- console.log(` review: ${f.gates?.review ?? "pending"}`);
537
- console.log("");
538
- });
539
- });
540
-
541
- // ---------- APPROVE COMMAND ----------
542
- program
543
- .command("approve <gate>")
544
- .description("Approve a workflow gate (specification, planning, implementation, review)")
545
- .action((gate) => {
546
- const allowedGates = ["specification", "planning", "implementation", "review"];
547
-
548
- if (!allowedGates.includes(gate)) {
549
- console.error(`❌ Invalid gate. Allowed: ${allowedGates.join(", ")}`);
550
- process.exit(1);
551
- }
552
-
553
- const projectRoot = process.cwd();
554
- const workflowStatePath = path.join(projectRoot, ".sdrive", "workflow-state.json");
555
-
556
- if (!fs.existsSync(workflowStatePath)) {
557
- console.error("❌ workflow-state.json not found. Run `sdrive init` first.");
558
- process.exit(1);
559
- }
560
-
561
- const state = JSON.parse(fs.readFileSync(workflowStatePath, "utf8"));
562
-
563
- if (!state.features || state.features.length === 0) {
564
- console.error("❌ No features found.");
565
- process.exit(1);
566
- }
567
-
568
- const feature = state.features[0];
569
-
570
- if (!feature.gates) {
571
- feature.gates = {};
572
- }
573
-
574
- feature.gates[gate] = "approved";
575
- feature.history = feature.history || [];
576
- feature.history.push({
577
- event: `${gate}_approved`,
578
- timestamp: new Date().toISOString()
579
- });
580
-
581
- fs.writeFileSync(workflowStatePath, JSON.stringify(state, null, 2));
582
-
583
- console.log(`✅ Gate "${gate}" approved for feature "${feature.name}".`);
584
- });
585
-
586
- // ---------- TEAM COMMANDS ----------
587
- program
588
- .command("team:add <username> <role>")
589
- .description("Add a new team member")
590
- .action((username, role) => {
591
- const teamPath = path.join(process.cwd(), ".sdrive", "team.json");
592
-
593
- if (!fs.existsSync(teamPath)) {
594
- console.error("❌ team.json not found. Run `sdrive init` first.");
595
- process.exit(1);
596
- }
597
-
598
- const team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
599
-
600
- if (!team.members) team.members = [];
601
-
602
- const existing = team.members.find((m) => m.username === username);
603
- if (existing) {
604
- console.error(`❌ User "${username}" already exists. Use team:update to change role.`);
605
- process.exit(1);
606
- }
607
-
608
- team.members.push({ username, role });
609
- fs.writeFileSync(teamPath, JSON.stringify(team, null, 2));
610
-
611
- console.log(`✅ Added ${username} as ${role}.`);
612
- });
613
-
614
- program
615
- .command("team:remove <username>")
616
- .description("Remove a team member")
617
- .action((username) => {
618
- const teamPath = path.join(process.cwd(), ".sdrive", "team.json");
619
-
620
- if (!fs.existsSync(teamPath)) {
621
- console.error("❌ team.json not found. Run `sdrive init` first.");
622
- process.exit(1);
623
- }
624
-
625
- const team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
626
-
627
- const before = team.members?.length || 0;
628
- team.members = (team.members || []).filter((m) => m.username !== username);
629
- const after = team.members.length;
630
-
631
- if (before === after) {
632
- console.error(`❌ User "${username}" not found.`);
633
- process.exit(1);
634
- }
635
-
636
- fs.writeFileSync(teamPath, JSON.stringify(team, null, 2));
637
- console.log(`✅ Removed ${username}.`);
638
- });
639
-
640
- program
641
- .command("team:list")
642
- .description("List all team members")
643
- .action(() => {
644
- const teamPath = path.join(process.cwd(), ".sdrive", "team.json");
645
-
646
- if (!fs.existsSync(teamPath)) {
647
- console.error("❌ team.json not found. Run `sdrive init` first.");
648
- process.exit(1);
649
- }
650
-
651
- const team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
652
-
653
- if (!team.members || team.members.length === 0) {
654
- console.log("No team members found.");
655
- return;
656
- }
657
-
658
- console.log("\nSpecDrive Team Members\n");
659
- team.members.forEach((m) => {
660
- console.log(`👤 ${m.username} — ${m.role}`);
661
- });
662
- });
663
-
664
- program
665
- .command("team:update <username> <role>")
666
- .description("Update a team member's role")
667
- .action((username, role) => {
668
- const teamPath = path.join(process.cwd(), ".sdrive", "team.json");
669
-
670
- if (!fs.existsSync(teamPath)) {
671
- console.error("❌ team.json not found. Run `sdrive init` first.");
672
- process.exit(1);
673
- }
674
-
675
- const team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
676
-
677
- const member = team.members?.find((m) => m.username === username);
678
-
679
- if (!member) {
680
- console.error(`❌ User "${username}" not found.`);
681
- process.exit(1);
682
- }
683
-
684
- member.role = role;
685
- fs.writeFileSync(teamPath, JSON.stringify(team, null, 2));
686
-
687
- console.log(`✅ Updated ${username} to ${role}.`);
688
- });
689
-
690
- program
691
- .command("team:sync")
692
- .description("Sync team members from GitHub collaborators")
693
- .action(() => {
694
- const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "github-team-sync.js");
695
-
696
- if (!fs.existsSync(scriptPath)) {
697
- console.error("❌ github-team-sync.js not found. Run `sdrive init` first.");
698
- process.exit(1);
699
- }
700
-
701
- try {
702
- execSync(`node "${scriptPath}"`, { stdio: "inherit" });
703
- } catch (error) {
704
- console.error("❌ Team sync failed.");
705
- process.exit(1);
706
- }
707
- });
708
-
709
- // ---------- HOOKS ----------
710
- program
711
- .command("hooks:install")
712
- .description("Install SpecDrive pre-commit hook")
713
- .action(() => {
714
- const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "install-hooks.js");
715
-
716
- if (!fs.existsSync(scriptPath)) {
717
- console.error("❌ install-hooks.js not found. Run `sdrive init` first.");
718
- process.exit(1);
719
- }
720
-
721
- try {
722
- execSync(`node "${scriptPath}"`, { stdio: "inherit" });
723
- } catch (error) {
724
- console.error("❌ Hook installation failed.");
725
- process.exit(1);
726
- }
727
- });
728
-
729
- // ---------- MIGRATE ----------
730
- program
731
- .command("migrate")
732
- .description("Migrate specs from OpenSpec or Spec-Kit")
733
- .action(() => {
734
- const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "migrate.js");
735
-
736
- if (!fs.existsSync(scriptPath)) {
737
- console.error("❌ migrate.js not found. Run `sdrive init` first.");
738
- process.exit(1);
739
- }
740
-
741
- try {
742
- execSync(`node "${scriptPath}"`, { stdio: "inherit" });
743
- } catch (error) {
744
- console.error("❌ Migration failed.");
745
- process.exit(1);
746
- }
747
- });
748
-
749
- // ---------- DIFF ----------
750
- program
751
- .command("diff <oldFile> <newFile>")
752
- .description("Show differences between two spec files")
753
- .action((oldFile, newFile) => {
754
- const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "diff-spec.js");
755
-
756
- if (!fs.existsSync(scriptPath)) {
757
- console.error("❌ diff-spec.js not found. Run `sdrive init` first.");
758
- process.exit(1);
759
- }
760
-
761
- try {
762
- execSync(`node "${scriptPath}" "${oldFile}" "${newFile}"`, { stdio: "inherit" });
763
- } catch (error) {
764
- console.error("❌ Diff failed.");
765
- process.exit(1);
766
- }
767
- });
768
-
769
- // ---------- OPENAPI ----------
770
- program
771
- .command("openapi:generate <feature>")
772
- .description("Generate OpenAPI spec from plan.json API contracts")
773
- .action((feature) => {
774
- const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "generate-openapi.js");
775
-
776
- if (!fs.existsSync(scriptPath)) {
777
- console.error("❌ generate-openapi.js not found. Run `sdrive init` first.");
778
- process.exit(1);
779
- }
780
-
781
- try {
782
- execSync(`node "${scriptPath}" "${feature}"`, { stdio: "inherit" });
783
- } catch (error) {
784
- console.error("❌ OpenAPI generation failed.");
785
- process.exit(1);
786
- }
787
- });
788
-
789
- program
790
- .command("dashboard")
791
- .description("Start SpecDrive web dashboard")
792
- .action(() => {
793
- const scriptPath = path.join(process.cwd(), ".sdrive", "dashboard", "server.js");
794
-
795
- if (!fs.existsSync(scriptPath)) {
796
- console.error("❌ Dashboard not found. Run `sdrive init` first.");
797
- process.exit(1);
798
- }
799
-
800
- try {
801
- execSync(`node "${scriptPath}"`, { stdio: "inherit" });
802
- } catch (error) {
803
- console.error("❌ Dashboard failed to start.");
804
- process.exit(1);
805
- }
806
- });
807
-
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require("commander");
4
+ const { execSync } = require("child_process");
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const os = require("os");
8
+ const readline = require("readline");
9
+
10
+ const program = new Command();
11
+
12
+ program
13
+ .name("sdrive")
14
+ .description("SpecDrive Enterprise Spec-Driven Development CLI")
15
+ .version("0.1.0");
16
+
17
+ function copyRecursive(src, dest) {
18
+ if (!fs.existsSync(src)) return;
19
+
20
+ fs.mkdirSync(dest, { recursive: true });
21
+
22
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
23
+ const srcPath = path.join(src, entry.name);
24
+ const destPath = path.join(dest, entry.name);
25
+
26
+ if (entry.isDirectory()) {
27
+ copyRecursive(srcPath, destPath);
28
+ } else {
29
+ if (!fs.existsSync(destPath)) {
30
+ fs.copyFileSync(srcPath, destPath);
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ function requirePermission(cliCommand) {
37
+ const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "check-permission.js");
38
+
39
+ if (!fs.existsSync(scriptPath)) return;
40
+
41
+ try {
42
+ execSync(`node "${scriptPath}" cli "${cliCommand}"`, { stdio: "pipe" });
43
+ } catch (error) {
44
+ console.error(`❌ Permission denied for 'sdrive ${cliCommand}'.`);
45
+ if (error.stdout) console.error(error.stdout.toString());
46
+ if (error.stderr) console.error(error.stderr.toString());
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ function loadConfig() {
52
+ const configPath = path.join(process.cwd(), ".sdrive", "config.json");
53
+ const schemaPath = path.join(process.cwd(), ".sdrive", "schemas", "config.schema.json");
54
+
55
+ if (!fs.existsSync(configPath)) {
56
+ console.error("❌ config.json not found. Run `sdrive init` first.");
57
+ process.exit(1);
58
+ }
59
+
60
+ if (!fs.existsSync(schemaPath)) {
61
+ console.error("❌ config.schema.json not found. Run `sdrive init` first.");
62
+ process.exit(1);
63
+ }
64
+
65
+ const Ajv = require("ajv");
66
+ const ajv = new Ajv({ allErrors: true });
67
+
68
+ const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
69
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
70
+
71
+ const valid = ajv.validate(schema, config);
72
+
73
+ if (!valid) {
74
+ console.error("❌ config.json is invalid:");
75
+ console.error(ajv.errors);
76
+ process.exit(1);
77
+ }
78
+
79
+ return config;
80
+ }
81
+
82
+ function adapterContent(toolName) {
83
+ return `# SpecDrive Commands
84
+
85
+ This project uses SpecDrive for spec-driven development.
86
+
87
+ ## How to use
88
+
89
+ When the user types a /sdrive:* command, follow these steps:
90
+
91
+ 1. Read .sdrive/commands/manifest.json
92
+ 2. Find the matching command
93
+ 3. Read the agent file listed in agentFile
94
+ 4. Follow that agent's instructions exactly
95
+ 5. Run validators after writing files
96
+ 6. Stop at human approval gates
97
+
98
+ Always read the manifest first. Never hardcode the command list.
99
+ `;
100
+ }
101
+
102
+ function mcpConfigContent() {
103
+ return JSON.stringify(
104
+ {
105
+ mcpServers: {
106
+ specdrive: {
107
+ command: "node",
108
+ args: [".sdrive/mcp/server.js"]
109
+ }
110
+ }
111
+ },
112
+ null,
113
+ 2
114
+ );
115
+ }
116
+
117
+ function safeMergeMcpConfig(configPath, mcpContent) {
118
+ if (!fs.existsSync(configPath)) {
119
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
120
+ fs.writeFileSync(configPath, mcpContent);
121
+ console.log(`✅ Created: ${configPath}`);
122
+ return;
123
+ }
124
+
125
+ try {
126
+ const existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
127
+ existing.mcpServers = existing.mcpServers || {};
128
+
129
+ if (existing.mcpServers.specdrive) {
130
+ console.log(`⚠️ SpecDrive already configured in ${configPath}`);
131
+ return;
132
+ }
133
+
134
+ existing.mcpServers.specdrive = {
135
+ command: "node",
136
+ args: [".sdrive/mcp/server.js"]
137
+ };
138
+
139
+ fs.writeFileSync(configPath, JSON.stringify(existing, null, 2));
140
+ console.log(`✅ Merged SpecDrive into: ${configPath}`);
141
+ } catch (error) {
142
+ console.error(`⚠️ Could not merge into ${configPath}`);
143
+ }
144
+ }
145
+
146
+ function getMcpConfigPaths() {
147
+ const paths = [];
148
+
149
+ const platform = os.platform();
150
+ const home = os.homedir();
151
+
152
+ if (platform === "darwin") {
153
+ paths.push({
154
+ name: "Claude Desktop",
155
+ path: path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
156
+ });
157
+ }
158
+
159
+ if (platform === "win32") {
160
+ paths.push({
161
+ name: "Claude Desktop",
162
+ path: path.join(process.env.APPDATA || "", "Claude", "claude_desktop_config.json")
163
+ });
164
+ }
165
+
166
+ paths.push({
167
+ name: "Cursor",
168
+ path: path.join(process.cwd(), ".cursor", "mcp.json")
169
+ });
170
+
171
+ paths.push({
172
+ name: "Cline",
173
+ path: path.join(process.cwd(), ".cline", "mcp.json")
174
+ });
175
+
176
+ paths.push({
177
+ name: "VS Code",
178
+ path: path.join(process.cwd(), ".vscode", "mcp.json")
179
+ });
180
+
181
+ return paths;
182
+ }
183
+
184
+ // ---------- INIT COMMAND ----------
185
+ program
186
+ .command("init")
187
+ .description("Initialize SpecDrive structure, tool adapters, and default template")
188
+ .action(() => {
189
+ const projectRoot = process.cwd();
190
+ const sdriveRoot = path.join(projectRoot, ".sdrive");
191
+
192
+ const dirs = [
193
+ path.join(sdriveRoot, "agents"),
194
+ path.join(sdriveRoot, "commands"),
195
+ path.join(sdriveRoot, "schemas"),
196
+ path.join(sdriveRoot, "scripts"),
197
+ path.join(sdriveRoot, "dashboard"),
198
+ path.join(sdriveRoot, "mcp"),
199
+ path.join(sdriveRoot, "templates"),
200
+ path.join(sdriveRoot, "specs", "backlog"),
201
+ path.join(sdriveRoot, "specs", "ongoing"),
202
+ path.join(sdriveRoot, "specs", "completed"),
203
+ path.join(sdriveRoot, "governance"),
204
+ path.join(sdriveRoot, "prototype", "css"),
205
+ path.join(sdriveRoot, "prototype", "js"),
206
+ path.join(sdriveRoot, "prototype", "screens"),
207
+ path.join(sdriveRoot, "reports", "security"),
208
+ path.join(sdriveRoot, "reports", "performance"),
209
+ path.join(sdriveRoot, "reports", "docs"),
210
+ path.join(sdriveRoot, "reports", "tests"),
211
+ path.join(sdriveRoot, "context"),
212
+ path.join(sdriveRoot, "skills")
213
+ ];
214
+
215
+ dirs.forEach((dir) => {
216
+ fs.mkdirSync(dir, { recursive: true });
217
+ console.log(` Created: ${path.relative(projectRoot, dir)}`);
218
+ });
219
+
220
+ const packageRoot = path.join(__dirname, "..");
221
+
222
+ const dirsToCopy = [
223
+ "agents",
224
+ "schemas",
225
+ "scripts",
226
+ "commands",
227
+ "dashboard",
228
+ "mcp",
229
+ "templates"
230
+ ];
231
+
232
+ dirsToCopy.forEach((dir) => {
233
+ const srcDir = path.join(packageRoot, dir);
234
+ const destDir = path.join(sdriveRoot, dir);
235
+
236
+ if (fs.existsSync(srcDir)) {
237
+ copyRecursive(srcDir, destDir);
238
+ console.log(` Copied: .sdrive/${dir}/`);
239
+ } else {
240
+ console.log(` ⚠️ Missing in package: ${dir}/`);
241
+ }
242
+ });
243
+
244
+ const workflowStatePath = path.join(sdriveRoot, "workflow-state.json");
245
+ if (!fs.existsSync(workflowStatePath)) {
246
+ fs.writeFileSync(workflowStatePath, JSON.stringify({ features: [] }, null, 2));
247
+ console.log(" Created: .sdrive/workflow-state.json");
248
+ }
249
+
250
+ const constitutionPath = path.join(sdriveRoot, "constitution.md");
251
+ if (!fs.existsSync(constitutionPath)) {
252
+ fs.writeFileSync(constitutionPath, "# SpecDrive Constitution\n\nInitialized by `sdrive init`.\n");
253
+ console.log(" Created: .sdrive/constitution.md");
254
+ }
255
+
256
+ const configPath = path.join(sdriveRoot, "config.json");
257
+ if (!fs.existsSync(configPath)) {
258
+ fs.writeFileSync(configPath, JSON.stringify({ defaultTemplate: "generic" }, null, 2));
259
+ console.log(" Created: .sdrive/config.json");
260
+ }
261
+
262
+ const teamPath = path.join(sdriveRoot, "team.json");
263
+ if (!fs.existsSync(teamPath)) {
264
+ fs.writeFileSync(
265
+ teamPath,
266
+ JSON.stringify(
267
+ {
268
+ members: [],
269
+ rules: {
270
+ approval_required: ["admin", "reviewer"],
271
+ implementation_allowed: ["developer", "admin"]
272
+ }
273
+ },
274
+ null,
275
+ 2
276
+ )
277
+ );
278
+ console.log(" Created: .sdrive/team.json");
279
+ }
280
+
281
+ const mcpConfigPath = path.join(sdriveRoot, "mcp", "mcp.json");
282
+ if (!fs.existsSync(mcpConfigPath)) {
283
+ fs.writeFileSync(mcpConfigPath, mcpConfigContent());
284
+ console.log(" Created: .sdrive/mcp/mcp.json");
285
+ }
286
+
287
+ const toolsPath = path.join(sdriveRoot, "commands", "tools.json");
288
+
289
+ if (!fs.existsSync(toolsPath)) {
290
+ console.log("\n⚠️ tools.json not found. Skipping tool selection.");
291
+ console.log("\n✅ SpecDrive initialized successfully.");
292
+ return;
293
+ }
294
+
295
+ const tools = JSON.parse(fs.readFileSync(toolsPath, "utf8")).tools;
296
+
297
+ console.log("\n🔧 Select tools to set up (comma-separated numbers):\n");
298
+ tools.forEach((tool, index) => {
299
+ console.log(` ${index + 1}. ${tool.name}`);
300
+ });
301
+ console.log(" Or press Enter to generate all.\n");
302
+
303
+ const rl = readline.createInterface({
304
+ input: process.stdin,
305
+ output: process.stdout
306
+ });
307
+
308
+ rl.question("Selection: ", (answer) => {
309
+ rl.close();
310
+
311
+ let selectedTools = tools;
312
+
313
+ if (answer.trim() !== "") {
314
+ const selectedIndexes = answer.split(",").map((n) => parseInt(n.trim()) - 1);
315
+ selectedTools = selectedIndexes
316
+ .filter((i) => i >= 0 && i < tools.length)
317
+ .map((i) => tools[i]);
318
+ }
319
+
320
+ selectedTools.forEach((tool) => {
321
+ const adapterFullPath = path.join(projectRoot, tool.adapterPath);
322
+ const adapterDir = path.dirname(adapterFullPath);
323
+
324
+ if (!fs.existsSync(adapterDir)) {
325
+ fs.mkdirSync(adapterDir, { recursive: true });
326
+ }
327
+
328
+ if (fs.existsSync(adapterFullPath)) {
329
+ console.log(`⚠️ Skipped existing: ${tool.adapterPath}`);
330
+ return;
331
+ }
332
+
333
+ fs.writeFileSync(adapterFullPath, adapterContent(tool.name));
334
+ console.log(`✅ Generated: ${tool.adapterPath}`);
335
+ });
336
+
337
+ const templates = ["generic", "react-node", "nextjs", "expo", "fastapi", "turborepo"];
338
+
339
+ console.log("\n📦 Select default template:\n");
340
+ templates.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
341
+
342
+ const rl2 = readline.createInterface({
343
+ input: process.stdin,
344
+ output: process.stdout
345
+ });
346
+
347
+ rl2.question("Template (default: 1): ", (templateAnswer) => {
348
+ rl2.close();
349
+
350
+ const idx = templateAnswer.trim() === "" ? 0 : parseInt(templateAnswer.trim()) - 1;
351
+ const selectedTemplate = templates[idx] || "generic";
352
+
353
+ const cfgPath = path.join(sdriveRoot, "config.json");
354
+ fs.writeFileSync(cfgPath, JSON.stringify({ defaultTemplate: selectedTemplate }, null, 2));
355
+
356
+ console.log(`\n✅ Default template set to: ${selectedTemplate}`);
357
+
358
+ const mcpPaths = getMcpConfigPaths();
359
+
360
+ console.log("\n🔌 MCP Client Wiring\n");
361
+ mcpPaths.forEach((p, i) => {
362
+ console.log(` ${i + 1}. ${p.name} (${p.path})`);
363
+ });
364
+ console.log(" Or press Enter to skip MCP wiring.\n");
365
+
366
+ const rl3 = readline.createInterface({
367
+ input: process.stdin,
368
+ output: process.stdout
369
+ });
370
+
371
+ rl3.question("Wire MCP clients: ", (mcpAnswer) => {
372
+ rl3.close();
373
+
374
+ if (mcpAnswer.trim() === "") {
375
+ console.log("\n⚠️ MCP wiring skipped.");
376
+ console.log("\n✅ SpecDrive initialized successfully.");
377
+ return;
378
+ }
379
+
380
+ const selectedIndexes = mcpAnswer.split(",").map((n) => parseInt(n.trim()) - 1);
381
+ const selectedPaths = selectedIndexes
382
+ .filter((i) => i >= 0 && i < mcpPaths.length)
383
+ .map((i) => mcpPaths[i]);
384
+
385
+ selectedPaths.forEach((p) => {
386
+ safeMergeMcpConfig(p.path, mcpConfigContent());
387
+ });
388
+
389
+ console.log("\n✅ SpecDrive initialized successfully.");
390
+ });
391
+ });
392
+ });
393
+ });
394
+
395
+ // ---------- TEMPLATE COMMANDS ----------
396
+ program
397
+ .command("template:list")
398
+ .description("List available templates")
399
+ .action(() => {
400
+ const templates = ["generic", "react-node", "nextjs", "expo", "fastapi", "turborepo"];
401
+ console.log("\nAvailable Templates\n");
402
+ templates.forEach((t) => console.log(` ${t}`));
403
+ });
404
+
405
+ program
406
+ .command("template:set <name>")
407
+ .description("Set default template")
408
+ .action((name) => {
409
+ requirePermission("template:set");
410
+
411
+ const templates = ["generic", "react-node", "nextjs", "expo", "fastapi", "turborepo"];
412
+
413
+ if (!templates.includes(name)) {
414
+ console.error(`❌ Invalid template. Run \`sdrive template:list\` to see available templates.`);
415
+ process.exit(1);
416
+ }
417
+
418
+ const configPath = path.join(process.cwd(), ".sdrive", "config.json");
419
+
420
+ if (!fs.existsSync(configPath)) {
421
+ console.error("❌ config.json not found. Run `sdrive init` first.");
422
+ process.exit(1);
423
+ }
424
+
425
+ const config = loadConfig();
426
+ config.defaultTemplate = name;
427
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
428
+
429
+ console.log(`✅ Default template set to: ${name}`);
430
+ });
431
+
432
+ // ---------- CONFIG VALIDATE ----------
433
+ program
434
+ .command("config:validate")
435
+ .description("Validate .sdrive/config.json")
436
+ .action(() => {
437
+ const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "validate-config.js");
438
+
439
+ if (!fs.existsSync(scriptPath)) {
440
+ console.error("❌ validate-config.js not found. Run `sdrive init` first.");
441
+ process.exit(1);
442
+ }
443
+
444
+ try {
445
+ execSync(`node "${scriptPath}"`, { stdio: "inherit" });
446
+ } catch (error) {
447
+ process.exit(1);
448
+ }
449
+ });
450
+
451
+ // ---------- VALIDATE COMMAND ----------
452
+ program
453
+ .command("validate")
454
+ .description("Validate SpecDrive governance files")
455
+ .action(() => {
456
+ const projectRoot = process.cwd();
457
+ const validatorPath = path.join(projectRoot, ".sdrive", "scripts", "validate-governance.js");
458
+
459
+ if (!fs.existsSync(validatorPath)) {
460
+ console.error("❌ Validator not found. Run `sdrive init` first.");
461
+ process.exit(1);
462
+ }
463
+
464
+ try {
465
+ execSync(`node "${validatorPath}"`, { stdio: "inherit" });
466
+ } catch (error) {
467
+ console.error("\n❌ Validation failed.");
468
+ process.exit(1);
469
+ }
470
+ });
471
+
472
+ // ---------- VERIFY COMMAND ----------
473
+ program
474
+ .command("verify <feature>")
475
+ .description("Verify code matches the plan (spec-to-code diff)")
476
+ .action((feature) => {
477
+ const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "verify.js");
478
+
479
+ if (!fs.existsSync(scriptPath)) {
480
+ console.error("❌ verify.js not found. Run `sdrive init` first.");
481
+ process.exit(1);
482
+ }
483
+
484
+ try {
485
+ execSync(`node "${scriptPath}" "${feature}"`, { stdio: "inherit" });
486
+ } catch (error) {
487
+ process.exit(1);
488
+ }
489
+ });
490
+
491
+ // ---------- CREATE COMMAND ----------
492
+ program
493
+ .command("create <feature>")
494
+ .description("Create a new feature scaffold (internal)")
495
+ .action((feature) => {
496
+ if (!/^[a-z0-9-]+$/.test(feature)) {
497
+ console.error("❌ Feature name must be lowercase letters, numbers, and hyphens only.");
498
+ process.exit(1);
499
+ }
500
+
501
+ const projectRoot = process.cwd();
502
+ const featureSpecDir = path.join(projectRoot, ".sdrive", "specs", "backlog", feature);
503
+ const featureGovDir = path.join(projectRoot, ".sdrive", "governance", feature);
504
+
505
+ if (fs.existsSync(featureSpecDir) || fs.existsSync(featureGovDir)) {
506
+ console.error(`❌ Feature "${feature}" already exists.`);
507
+ process.exit(1);
508
+ }
509
+
510
+ fs.mkdirSync(featureSpecDir, { recursive: true });
511
+ fs.mkdirSync(featureGovDir, { recursive: true });
512
+
513
+ fs.writeFileSync(
514
+ path.join(featureSpecDir, "spec.md"),
515
+ `---\ntitle: ${feature}\nstatus: backlog\ncreated: ${new Date().toISOString()}\nupdated: ${new Date().toISOString()}\n---\n\n# ${feature}\n`
516
+ );
517
+
518
+ fs.writeFileSync(path.join(featureSpecDir, "plan.md"), `# Technical Plan: ${feature}\n`);
519
+ fs.writeFileSync(path.join(featureSpecDir, "tasks.md"), `# Execution Tasks: ${feature}\n`);
520
+
521
+ fs.writeFileSync(
522
+ path.join(featureGovDir, "spec.json"),
523
+ JSON.stringify({ feature, title: feature, version: 1, overview: {}, userStories: [], functionalRequirements: [] }, null, 2)
524
+ );
525
+
526
+ fs.writeFileSync(
527
+ path.join(featureGovDir, "plan.json"),
528
+ JSON.stringify({ feature, version: 1, architecture: {}, dataModel: {} }, null, 2)
529
+ );
530
+
531
+ fs.writeFileSync(
532
+ path.join(featureGovDir, "tasks.json"),
533
+ JSON.stringify({ feature, version: 1, phases: [] }, null, 2)
534
+ );
535
+
536
+ fs.writeFileSync(
537
+ path.join(featureGovDir, "traceability.json"),
538
+ JSON.stringify({ feature, version: 1, rows: [] }, null, 2)
539
+ );
540
+
541
+ console.log(`✅ Feature "${feature}" created in backlog.`);
542
+ });
543
+
544
+ // ---------- STATUS COMMAND ----------
545
+ program
546
+ .command("status")
547
+ .description("Show feature lifecycle state")
548
+ .action(() => {
549
+ const projectRoot = process.cwd();
550
+ const workflowStatePath = path.join(projectRoot, ".sdrive", "workflow-state.json");
551
+
552
+ if (!fs.existsSync(workflowStatePath)) {
553
+ console.error("❌ workflow-state.json not found. Run `sdrive init` first.");
554
+ process.exit(1);
555
+ }
556
+
557
+ const state = JSON.parse(fs.readFileSync(workflowStatePath, "utf8"));
558
+
559
+ if (!state.features || state.features.length === 0) {
560
+ console.log("No features found.");
561
+ return;
562
+ }
563
+
564
+ console.log("\nSpecDrive Feature Status\n");
565
+
566
+ state.features.forEach((f) => {
567
+ console.log(`📁 ${f.name}`);
568
+ console.log(` Current Version: ${f.currentVersion}`);
569
+
570
+ (f.versions || []).forEach((v) => {
571
+ console.log(` Version ${v.id}:`);
572
+ console.log(` Phase: ${v.phase}`);
573
+ console.log(` Current Step: ${v.currentStep}`);
574
+ console.log(` Gates:`);
575
+ console.log(` specification: ${v.gates?.specification ?? "pending"}`);
576
+ console.log(` planning: ${v.gates?.planning ?? "pending"}`);
577
+ console.log(` implementation: ${v.gates?.implementation ?? "pending"}`);
578
+ console.log(` review: ${v.gates?.review ?? "pending"}`);
579
+ });
580
+ console.log("");
581
+ });
582
+ });
583
+
584
+ // ---------- APPROVE COMMAND ----------
585
+ program
586
+ .command("approve <gate>")
587
+ .description("Approve a workflow gate (specification, planning, implementation, review)")
588
+ .action((gate) => {
589
+ requirePermission("approve");
590
+
591
+ const allowedGates = ["specification", "planning", "implementation", "review"];
592
+
593
+ if (!allowedGates.includes(gate)) {
594
+ console.error(`❌ Invalid gate. Allowed: ${allowedGates.join(", ")}`);
595
+ process.exit(1);
596
+ }
597
+
598
+ const projectRoot = process.cwd();
599
+ const workflowStatePath = path.join(projectRoot, ".sdrive", "workflow-state.json");
600
+
601
+ if (!fs.existsSync(workflowStatePath)) {
602
+ console.error("❌ workflow-state.json not found. Run `sdrive init` first.");
603
+ process.exit(1);
604
+ }
605
+
606
+ const state = JSON.parse(fs.readFileSync(workflowStatePath, "utf8"));
607
+
608
+ if (!state.features || state.features.length === 0) {
609
+ console.error("❌ No features found.");
610
+ process.exit(1);
611
+ }
612
+
613
+ const feature = state.features[0];
614
+ const versionId = feature.currentVersion;
615
+
616
+ if (!feature.versions || feature.versions.length === 0) {
617
+ console.error("❌ No versions found.");
618
+ process.exit(1);
619
+ }
620
+
621
+ const version = feature.versions.find((v) => v.id === versionId) || feature.versions[0];
622
+
623
+ if (!version.gates) {
624
+ version.gates = {};
625
+ }
626
+
627
+ version.gates[gate] = "approved";
628
+ version.history = version.history || [];
629
+ version.history.push({
630
+ event: `${gate}_approved`,
631
+ timestamp: new Date().toISOString()
632
+ });
633
+
634
+ fs.writeFileSync(workflowStatePath, JSON.stringify(state, null, 2));
635
+
636
+ console.log(`✅ Gate "${gate}" approved for feature "${feature.name}" version "${version.id}".`);
637
+ });
638
+
639
+ // ---------- TEAM COMMANDS ----------
640
+ program
641
+ .command("team:add <username> <role>")
642
+ .description("Add a new team member")
643
+ .action((username, role) => {
644
+ requirePermission("team:add");
645
+
646
+ const teamPath = path.join(process.cwd(), ".sdrive", "team.json");
647
+
648
+ if (!fs.existsSync(teamPath)) {
649
+ console.error("❌ team.json not found. Run `sdrive init` first.");
650
+ process.exit(1);
651
+ }
652
+
653
+ const team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
654
+
655
+ if (!team.members) team.members = [];
656
+
657
+ const existing = team.members.find((m) => m.username === username);
658
+ if (existing) {
659
+ console.error(`❌ User "${username}" already exists. Use team:update to change role.`);
660
+ process.exit(1);
661
+ }
662
+
663
+ team.members.push({ username, role });
664
+ fs.writeFileSync(teamPath, JSON.stringify(team, null, 2));
665
+
666
+ console.log(`✅ Added ${username} as ${role}.`);
667
+ });
668
+
669
+ program
670
+ .command("team:remove <username>")
671
+ .description("Remove a team member")
672
+ .action((username) => {
673
+ requirePermission("team:remove");
674
+
675
+ const teamPath = path.join(process.cwd(), ".sdrive", "team.json");
676
+
677
+ if (!fs.existsSync(teamPath)) {
678
+ console.error("❌ team.json not found. Run `sdrive init` first.");
679
+ process.exit(1);
680
+ }
681
+
682
+ const team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
683
+
684
+ const before = team.members?.length || 0;
685
+ team.members = (team.members || []).filter((m) => m.username !== username);
686
+ const after = team.members.length;
687
+
688
+ if (before === after) {
689
+ console.error(`❌ User "${username}" not found.`);
690
+ process.exit(1);
691
+ }
692
+
693
+ fs.writeFileSync(teamPath, JSON.stringify(team, null, 2));
694
+ console.log(`✅ Removed ${username}.`);
695
+ });
696
+
697
+ program
698
+ .command("team:list")
699
+ .description("List all team members")
700
+ .action(() => {
701
+ const teamPath = path.join(process.cwd(), ".sdrive", "team.json");
702
+
703
+ if (!fs.existsSync(teamPath)) {
704
+ console.error("❌ team.json not found. Run `sdrive init` first.");
705
+ process.exit(1);
706
+ }
707
+
708
+ const team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
709
+
710
+ if (!team.members || team.members.length === 0) {
711
+ console.log("No team members found.");
712
+ return;
713
+ }
714
+
715
+ console.log("\nSpecDrive Team Members\n");
716
+ team.members.forEach((m) => {
717
+ console.log(`👤 ${m.username} — ${m.role}`);
718
+ });
719
+ });
720
+
721
+ program
722
+ .command("team:update <username> <role>")
723
+ .description("Update a team member's role")
724
+ .action((username, role) => {
725
+ requirePermission("team:update");
726
+
727
+ const teamPath = path.join(process.cwd(), ".sdrive", "team.json");
728
+
729
+ if (!fs.existsSync(teamPath)) {
730
+ console.error("❌ team.json not found. Run `sdrive init` first.");
731
+ process.exit(1);
732
+ }
733
+
734
+ const team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
735
+
736
+ const member = team.members?.find((m) => m.username === username);
737
+
738
+ if (!member) {
739
+ console.error(`❌ User "${username}" not found.`);
740
+ process.exit(1);
741
+ }
742
+
743
+ member.role = role;
744
+ fs.writeFileSync(teamPath, JSON.stringify(team, null, 2));
745
+
746
+ console.log(`✅ Updated ${username} to ${role}.`);
747
+ });
748
+
749
+ program
750
+ .command("team:sync")
751
+ .description("Sync team members from GitHub collaborators")
752
+ .action(() => {
753
+ requirePermission("team:sync");
754
+
755
+ const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "github-team-sync.js");
756
+
757
+ if (!fs.existsSync(scriptPath)) {
758
+ console.error("❌ github-team-sync.js not found. Run `sdrive init` first.");
759
+ process.exit(1);
760
+ }
761
+
762
+ try {
763
+ execSync(`node "${scriptPath}"`, { stdio: "inherit" });
764
+ } catch (error) {
765
+ console.error("❌ Team sync failed.");
766
+ process.exit(1);
767
+ }
768
+ });
769
+
770
+ // ---------- HOOKS ----------
771
+ program
772
+ .command("hooks:install")
773
+ .description("Install SpecDrive pre-commit hook")
774
+ .action(() => {
775
+ requirePermission("hooks:install");
776
+
777
+ const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "install-hooks.js");
778
+
779
+ if (!fs.existsSync(scriptPath)) {
780
+ console.error("❌ install-hooks.js not found. Run `sdrive init` first.");
781
+ process.exit(1);
782
+ }
783
+
784
+ try {
785
+ execSync(`node "${scriptPath}"`, { stdio: "inherit" });
786
+ } catch (error) {
787
+ console.error("❌ Hook installation failed.");
788
+ process.exit(1);
789
+ }
790
+ });
791
+
792
+ // ---------- DIFF ----------
793
+ program
794
+ .command("diff <oldFile> <newFile>")
795
+ .description("Show differences between two spec files")
796
+ .action((oldFile, newFile) => {
797
+ const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "diff-spec.js");
798
+
799
+ if (!fs.existsSync(scriptPath)) {
800
+ console.error("❌ diff-spec.js not found. Run `sdrive init` first.");
801
+ process.exit(1);
802
+ }
803
+
804
+ try {
805
+ execSync(`node "${scriptPath}" "${oldFile}" "${newFile}"`, { stdio: "inherit" });
806
+ } catch (error) {
807
+ console.error("❌ Diff failed.");
808
+ process.exit(1);
809
+ }
810
+ });
811
+
812
+ // ---------- OPENAPI ----------
813
+ program
814
+ .command("openapi:generate <feature>")
815
+ .description("Generate OpenAPI spec from plan.json API contracts")
816
+ .action((feature) => {
817
+ const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "generate-openapi.js");
818
+
819
+ if (!fs.existsSync(scriptPath)) {
820
+ console.error("❌ generate-openapi.js not found. Run `sdrive init` first.");
821
+ process.exit(1);
822
+ }
823
+
824
+ try {
825
+ execSync(`node "${scriptPath}" "${feature}"`, { stdio: "inherit" });
826
+ } catch (error) {
827
+ console.error("❌ OpenAPI generation failed.");
828
+ process.exit(1);
829
+ }
830
+ });
831
+
832
+ // ---------- DASHBOARD ----------
833
+ program
834
+ .command("dashboard")
835
+ .description("Start SpecDrive web dashboard")
836
+ .action(() => {
837
+ const scriptPath = path.join(process.cwd(), ".sdrive", "dashboard", "server.js");
838
+
839
+ if (!fs.existsSync(scriptPath)) {
840
+ console.error("❌ Dashboard not found. Run `sdrive init` first.");
841
+ process.exit(1);
842
+ }
843
+
844
+ try {
845
+ execSync(`node "${scriptPath}"`, { stdio: "inherit" });
846
+ } catch (error) {
847
+ console.error("❌ Dashboard failed to start.");
848
+ process.exit(1);
849
+ }
850
+ });
851
+
852
+ // ---------- REOPEN ----------
853
+ program
854
+ .command("reopen <feature>")
855
+ .description("Reopen a completed feature")
856
+ .action((feature) => {
857
+ requirePermission("reopen");
858
+
859
+ const projectRoot = process.cwd();
860
+ const sdriveRoot = path.join(projectRoot, ".sdrive");
861
+
862
+ const completedSpecDir = path.join(sdriveRoot, "specs", "completed", feature);
863
+ const ongoingSpecDir = path.join(sdriveRoot, "specs", "ongoing", feature);
864
+
865
+ const completedGovDir = path.join(sdriveRoot, "governance", "completed", feature);
866
+ const ongoingGovDir = path.join(sdriveRoot, "governance", feature);
867
+
868
+ if (fs.existsSync(completedSpecDir)) {
869
+ fs.mkdirSync(path.dirname(ongoingSpecDir), { recursive: true });
870
+ fs.renameSync(completedSpecDir, ongoingSpecDir);
871
+ console.log(`✅ Moved spec: completed/${feature} → ongoing/${feature}`);
872
+ } else if (!fs.existsSync(ongoingSpecDir)) {
873
+ console.error(`❌ Feature "${feature}" not found in completed/ or ongoing/.`);
874
+ process.exit(1);
875
+ }
876
+
877
+ if (fs.existsSync(completedGovDir)) {
878
+ fs.mkdirSync(path.dirname(ongoingGovDir), { recursive: true });
879
+ fs.renameSync(completedGovDir, ongoingGovDir);
880
+ console.log(`✅ Moved governance: completed/${feature} → ${feature}`);
881
+ }
882
+
883
+ const workflowStatePath = path.join(sdriveRoot, "workflow-state.json");
884
+
885
+ if (!fs.existsSync(workflowStatePath)) {
886
+ console.error("❌ workflow-state.json not found.");
887
+ process.exit(1);
888
+ }
889
+
890
+ const state = JSON.parse(fs.readFileSync(workflowStatePath, "utf8"));
891
+ const target = state.features?.find((f) => f.name === feature);
892
+
893
+ if (!target) {
894
+ console.error(`❌ Feature "${feature}" not found in workflow-state.json.`);
895
+ process.exit(1);
896
+ }
897
+
898
+ if (!target.versions || target.versions.length === 0) {
899
+ console.error(`❌ Feature "${feature}" has no versions.`);
900
+ process.exit(1);
901
+ }
902
+
903
+ const currentVersion = target.versions.find((v) => v.id === target.currentVersion);
904
+
905
+ if (currentVersion) {
906
+ currentVersion.phase = "ongoing";
907
+ currentVersion.currentStep = "specification";
908
+ currentVersion.gates.review = "pending";
909
+ currentVersion.history = currentVersion.history || [];
910
+ currentVersion.history.push({
911
+ event: "feature_reopened",
912
+ timestamp: new Date().toISOString()
913
+ });
914
+ }
915
+
916
+ fs.writeFileSync(workflowStatePath, JSON.stringify(state, null, 2));
917
+
918
+ console.log(`✅ Feature "${feature}" reopened.`);
919
+ console.log(" Review gate reset to pending.");
920
+ });
921
+
922
+ // ---------- ONBOARD COMMAND ----------
923
+ // Internal helper. Usually run automatically by the Onboarding Agent.
924
+ program
925
+ .command("onboard")
926
+ .description("Scan an existing project for onboarding (usually run automatically by /sdrive:onboard)")
927
+ .action(() => {
928
+ requirePermission("onboard");
929
+
930
+ const projectRoot = process.cwd();
931
+ const sdriveRoot = path.join(projectRoot, ".sdrive");
932
+ const workflowStatePath = path.join(sdriveRoot, "workflow-state.json");
933
+
934
+ // Guard: refuse to run twice
935
+ if (fs.existsSync(workflowStatePath)) {
936
+ try {
937
+ const state = JSON.parse(fs.readFileSync(workflowStatePath, "utf8"));
938
+
939
+ if (state.onboarding && state.onboarding.date) {
940
+ console.error(
941
+ `❌ Onboarding already ran on ${state.onboarding.date}. Refusing to re-run.`
942
+ );
943
+ console.error(" If you want to re-run, remove the 'onboarding' block from workflow-state.json.");
944
+ process.exit(1);
945
+ }
946
+ } catch (error) {
947
+ // ignore parse errors and continue
948
+ }
949
+ }
950
+
951
+ const scriptPath = path.join(sdriveRoot, "scripts", "onboard.js");
952
+
953
+ if (!fs.existsSync(scriptPath)) {
954
+ console.error("❌ onboard.js not found. Run `sdrive init` first.");
955
+ process.exit(1);
956
+ }
957
+
958
+ try {
959
+ execSync(`node "${scriptPath}"`, { stdio: "inherit" });
960
+
961
+ console.log("");
962
+ console.log("📋 The scan is complete.");
963
+ console.log(" You can now run `/sdrive:onboard` in your AI tool,");
964
+ console.log(" or the agent will run this scan for you if needed.");
965
+ } catch (error) {
966
+ console.error("❌ Onboarding scan failed.");
967
+ process.exit(1);
968
+ }
969
+ });
970
+
971
+ // ---------- VERSION NEW ----------
972
+ program
973
+ .command("version:new <feature>")
974
+ .description("Create a new version of a feature")
975
+ .action((feature) => {
976
+ requirePermission("version:new");
977
+
978
+ const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "version-new.js");
979
+
980
+ if (!fs.existsSync(scriptPath)) {
981
+ console.error("❌ version-new.js not found. Run `sdrive init` first.");
982
+ process.exit(1);
983
+ }
984
+
985
+ try {
986
+ execSync(`node "${scriptPath}" "${feature}"`, { stdio: "inherit" });
987
+ } catch (error) {
988
+ process.exit(1);
989
+ }
990
+ });
991
+
992
+ // ---------- DIFF VERSION ----------
993
+ program
994
+ .command("diff:version <feature> <versionA> <versionB>")
995
+ .description("Compare two versions of a feature")
996
+ .action((feature, versionA, versionB) => {
997
+ const scriptPath = path.join(process.cwd(), ".sdrive", "scripts", "diff-version.js");
998
+
999
+ if (!fs.existsSync(scriptPath)) {
1000
+ console.error("❌ diff-version.js not found. Run `sdrive init` first.");
1001
+ process.exit(1);
1002
+ }
1003
+
1004
+ try {
1005
+ execSync(`node "${scriptPath}" "${feature}" "${versionA}" "${versionB}"`, { stdio: "inherit" });
1006
+ } catch (error) {
1007
+ process.exit(1);
1008
+ }
1009
+ });
1010
+
808
1011
  program.parse(process.argv);