@tudeorangbiasa/sdd-multiagent-opencode 0.2.2 → 0.3.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.
@@ -3,11 +3,135 @@
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { execSync } from "node:child_process";
6
7
 
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
9
10
  const packageRoot = path.resolve(__dirname, "..");
10
11
 
12
+ const globalConfigDir = path.join(
13
+ process.env.HOME || process.env.USERPROFILE || "",
14
+ ".config",
15
+ "opencode"
16
+ );
17
+
18
+ // ─── Atomic Write with EXDEV Fallback ────────────────────────────────────────
19
+
20
+ function atomicWrite(targetPath, content) {
21
+ const dir = path.dirname(targetPath);
22
+ if (!fs.existsSync(dir)) {
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ }
25
+
26
+ const tmpPath = `${targetPath}.tmp.${process.pid}`;
27
+ fs.writeFileSync(tmpPath, content);
28
+
29
+ try {
30
+ fs.renameSync(tmpPath, targetPath);
31
+ } catch (e) {
32
+ if (e.code === "EXDEV") {
33
+ fs.copyFileSync(tmpPath, targetPath);
34
+ fs.unlinkSync(tmpPath);
35
+ } else {
36
+ fs.unlinkSync(tmpPath);
37
+ throw e;
38
+ }
39
+ }
40
+ }
41
+
42
+ function atomicWriteFromFile(srcPath, targetPath) {
43
+ const content = fs.readFileSync(srcPath, "utf-8");
44
+ atomicWrite(targetPath, content);
45
+ }
46
+
47
+ // ─── JSONC Support (lightweight comment stripper) ───────────────────────────
48
+
49
+ function stripJsonComments(text) {
50
+ const lines = text.split("\n");
51
+ const result = [];
52
+ let inBlockComment = false;
53
+
54
+ for (const line of lines) {
55
+ let cleaned = "";
56
+ let i = 0;
57
+ let inString = false;
58
+ let escapeNext = false;
59
+
60
+ while (i < line.length) {
61
+ const ch = line[i];
62
+
63
+ if (inBlockComment) {
64
+ if (ch === "*" && line[i + 1] === "/") {
65
+ inBlockComment = false;
66
+ i += 2;
67
+ continue;
68
+ }
69
+ i++;
70
+ continue;
71
+ }
72
+
73
+ if (escapeNext) {
74
+ cleaned += ch;
75
+ escapeNext = false;
76
+ i++;
77
+ continue;
78
+ }
79
+
80
+ if (inString) {
81
+ cleaned += ch;
82
+ if (ch === "\\") {
83
+ escapeNext = true;
84
+ } else if (ch === '"') {
85
+ inString = false;
86
+ }
87
+ i++;
88
+ continue;
89
+ }
90
+
91
+ if (ch === '"') {
92
+ cleaned += ch;
93
+ inString = true;
94
+ i++;
95
+ continue;
96
+ }
97
+
98
+ if (ch === "/" && line[i + 1] === "*") {
99
+ inBlockComment = true;
100
+ i += 2;
101
+ continue;
102
+ }
103
+
104
+ if (ch === "/" && line[i + 1] === "/") {
105
+ break;
106
+ }
107
+
108
+ cleaned += ch;
109
+ i++;
110
+ }
111
+
112
+ result.push(cleaned);
113
+ }
114
+
115
+ return result.join("\n");
116
+ }
117
+
118
+ function parseJsonc(filePath) {
119
+ if (!fs.existsSync(filePath)) return null;
120
+ const raw = fs.readFileSync(filePath, "utf-8");
121
+ const stripped = stripJsonComments(raw);
122
+ return JSON.parse(stripped);
123
+ }
124
+
125
+ // ─── Backup ──────────────────────────────────────────────────────────────────
126
+
127
+ function createBackup(filePath) {
128
+ if (!fs.existsSync(filePath)) return;
129
+ const backupPath = `${filePath}.bak`;
130
+ fs.copyFileSync(filePath, backupPath);
131
+ }
132
+
133
+ // ─── Arg Parsing ─────────────────────────────────────────────────────────────
134
+
11
135
  function parseArgs(argv) {
12
136
  const args = {
13
137
  command: null,
@@ -16,21 +140,30 @@ function parseArgs(argv) {
16
140
  rulesOnly: false,
17
141
  force: false,
18
142
  dryRun: false,
143
+ deep: false,
144
+ minimal: false,
145
+ full: false,
19
146
  },
20
147
  };
21
148
 
22
149
  for (let i = 2; i < argv.length; i++) {
23
150
  const arg = argv[i];
24
- if (arg === "init" || arg === "help" || arg === "test") {
151
+ if (["init", "help", "test", "doctor", "warm", "migrate"].includes(arg)) {
25
152
  args.command = arg;
26
153
  } else if (arg === "--sdd-only") {
27
154
  args.flags.sddOnly = true;
28
155
  } else if (arg === "--rules-only") {
29
156
  args.flags.rulesOnly = true;
30
- } else if (arg === "--force") {
157
+ } else if (arg === "--force" || arg === "--reset") {
31
158
  args.flags.force = true;
32
159
  } else if (arg === "--dry-run") {
33
160
  args.flags.dryRun = true;
161
+ } else if (arg === "--deep") {
162
+ args.flags.deep = true;
163
+ } else if (arg === "--minimal") {
164
+ args.flags.minimal = true;
165
+ } else if (arg === "--full") {
166
+ args.flags.full = true;
34
167
  }
35
168
  }
36
169
 
@@ -41,35 +174,52 @@ function parseArgs(argv) {
41
174
  return args;
42
175
  }
43
176
 
177
+ // ─── Usage ───────────────────────────────────────────────────────────────────
178
+
44
179
  function usage() {
45
- console.log(`SDD Multi-Agent OpenCode Installer
180
+ console.log(`SDD Multi-Agent OpenCode Installer v0.3.0
46
181
 
47
182
  Usage:
48
- sdd-opencode init [flags]
49
- sdd-opencode help
183
+ sdd-opencode init [flags] Install SDD workflow into current project
184
+ sdd-opencode doctor [--deep] Diagnose installation health
185
+ sdd-opencode warm Warm OpenCode plugin cache
186
+ sdd-opencode migrate Remove old symlinks from v0.2.x
187
+ sdd-opencode help Show this help
50
188
 
51
- Flags:
189
+ Init Flags:
52
190
  --sdd-only Install only SDD workflow (skip base rules)
53
- --rules-only Install only base rules (skip SDD)
54
- --force Overwrite existing managed files
55
- --dry-run Show what would be installed, no writes
191
+ --rules-only Install only base rules (skip SDD)
192
+ --force/--reset Overwrite existing managed files (with backup)
193
+ --dry-run Preview without writing
194
+ --minimal Core SDD only (no vendor rules)
195
+ --full Everything including vendor rules
196
+
197
+ Doctor:
198
+ L0 File structure, JSON validity, orphaned symlinks
199
+ L1 Plugin registration count (agents, skills, commands)
200
+ L2 Environment variables for configured providers
201
+ L3 Liveness probe (API call to configured model) — requires --deep
56
202
 
57
203
  Examples:
58
204
  sdd-opencode init # Install everything
59
205
  sdd-opencode init --sdd-only # SDD only
60
- sdd-opencode init --rules-only # Rules only
61
- sdd-opencode init --force # Overwrite existing
62
206
  sdd-opencode init --dry-run # Preview only
207
+ sdd-opencode doctor # Static checks (L0-L2)
208
+ sdd-opencode doctor --deep # Include liveness probe (L3)
209
+ sdd-opencode warm # Warm plugin cache
210
+ sdd-opencode migrate # Clean old symlinks
63
211
  `);
64
212
  }
65
213
 
214
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
215
+
66
216
  function ensureDir(dir) {
67
217
  if (!fs.existsSync(dir)) {
68
218
  fs.mkdirSync(dir, { recursive: true });
69
219
  }
70
220
  }
71
221
 
72
- function copyFileSafe(src, dest, ctx, options = {}) {
222
+ function copyFileSafe(src, dest, ctx) {
73
223
  const { force, dryRun } = ctx;
74
224
 
75
225
  if (fs.existsSync(dest)) {
@@ -77,6 +227,7 @@ function copyFileSafe(src, dest, ctx, options = {}) {
77
227
  ctx.skipped.push(path.relative(ctx.targetRoot, dest));
78
228
  return false;
79
229
  }
230
+ createBackup(dest);
80
231
  }
81
232
 
82
233
  if (dryRun) {
@@ -84,108 +235,74 @@ function copyFileSafe(src, dest, ctx, options = {}) {
84
235
  return true;
85
236
  }
86
237
 
87
- const content = fs.readFileSync(src, "utf-8");
88
- fs.writeFileSync(dest, content);
238
+ atomicWriteFromFile(src, dest);
89
239
  ctx.installed.push(path.relative(ctx.targetRoot, dest));
90
240
  return true;
91
241
  }
92
242
 
93
243
  function copyDirFlat(srcDir, destDir, ctx) {
94
- if (!fs.existsSync(srcDir)) {
95
- return 0;
96
- }
97
-
244
+ if (!fs.existsSync(srcDir)) return 0;
98
245
  ensureDir(destDir);
99
246
 
100
247
  let count = 0;
101
- const files = fs.readdirSync(srcDir);
102
- for (const file of files) {
248
+ for (const file of fs.readdirSync(srcDir)) {
103
249
  const srcPath = path.join(srcDir, file);
104
250
  const destPath = path.join(destDir, file);
105
-
106
251
  if (fs.statSync(srcPath).isDirectory()) {
107
252
  count += copyDirFlat(srcPath, destPath, ctx);
108
253
  } else {
109
- if (copyFileSafe(srcPath, destPath, ctx)) {
110
- count++;
111
- }
254
+ if (copyFileSafe(srcPath, destPath, ctx)) count++;
112
255
  }
113
256
  }
114
-
115
257
  return count;
116
258
  }
117
259
 
118
260
  function copyOpencodeDir(srcRelative, destRelative, ctx) {
119
261
  const srcPath = path.join(packageRoot, srcRelative);
120
262
  const destPath = path.join(ctx.targetRoot, destRelative);
121
-
122
- if (!fs.existsSync(srcPath)) {
123
- return 0;
124
- }
263
+ if (!fs.existsSync(srcPath)) return 0;
125
264
 
126
265
  let count = 0;
127
- const items = fs.readdirSync(srcPath);
128
-
129
- for (const item of items) {
266
+ for (const item of fs.readdirSync(srcPath)) {
130
267
  const srcItem = path.join(srcPath, item);
131
268
  const destItem = path.join(destPath, item);
132
-
133
269
  if (fs.statSync(srcItem).isDirectory()) {
134
270
  ensureDir(destItem);
135
- const subCount = copyOpencodeDir(
136
- `${srcRelative}/${item}`,
137
- `${destRelative}/${item}`,
138
- ctx
139
- );
140
- count += subCount;
271
+ count += copyOpencodeDir(`${srcRelative}/${item}`, `${destRelative}/${item}`, ctx);
141
272
  } else {
142
- if (copyFileSafe(srcItem, destItem, ctx)) {
143
- count++;
144
- }
273
+ if (copyFileSafe(srcItem, destItem, ctx)) count++;
145
274
  }
146
275
  }
147
-
148
276
  return count;
149
277
  }
150
278
 
279
+ // ─── Install Commands ────────────────────────────────────────────────────────
280
+
151
281
  function installRules(ctx) {
152
282
  const vendorRules = path.join(packageRoot, "vendor/opencode-agent-rules");
153
-
154
283
  if (!fs.existsSync(vendorRules)) {
155
- console.warn("⚠️ vendor/opencode-agent-rules not found, skipping rules");
284
+ console.warn("Warning: vendor/opencode-agent-rules not found, skipping rules");
156
285
  return 0;
157
286
  }
158
287
 
159
288
  let count = 0;
160
-
161
289
  const targetOpencode = path.join(ctx.targetRoot, ".opencode");
162
290
  ensureDir(path.join(targetOpencode, "rules"));
163
291
  ensureDir(path.join(targetOpencode, "plugins"));
164
292
 
165
293
  const agentsSrc = path.join(vendorRules, "AGENTS.md");
166
- const agentsDest = path.join(ctx.targetRoot, "AGENTS.md");
167
-
168
294
  if (fs.existsSync(agentsSrc)) {
169
- if (copyFileSafe(agentsSrc, agentsDest, ctx)) {
170
- count++;
171
- }
295
+ if (copyFileSafe(agentsSrc, path.join(ctx.targetRoot, "AGENTS.md"), ctx)) count++;
172
296
  }
173
297
 
174
- const rulesSrc = path.join(vendorRules, ".opencode/rules");
175
- const rulesDest = path.join(targetOpencode, "rules");
176
- count += copyDirFlat(rulesSrc, rulesDest, ctx);
177
-
178
- const pluginsSrc = path.join(vendorRules, ".opencode/plugins");
179
- if (fs.existsSync(pluginsSrc)) {
180
- count += copyDirFlat(pluginsSrc, path.join(targetOpencode, "plugins"), ctx);
181
- }
298
+ count += copyDirFlat(path.join(vendorRules, ".opencode/rules"), path.join(targetOpencode, "rules"), ctx);
299
+ count += copyDirFlat(path.join(vendorRules, ".opencode/plugins"), path.join(targetOpencode, "plugins"), ctx);
182
300
 
183
301
  return count;
184
302
  }
185
303
 
186
304
  function installSdd(ctx) {
187
305
  let count = 0;
188
-
189
306
  const targetOpencode = path.join(ctx.targetRoot, ".opencode");
190
307
 
191
308
  ensureDir(path.join(targetOpencode, "agents"));
@@ -203,74 +320,47 @@ function installSdd(ctx) {
203
320
  ensureDir(path.join(sddDir, "templates"));
204
321
 
205
322
  const configSrc = path.join(packageRoot, ".sdd/config.json");
206
- const configDest = path.join(sddDir, "config.json");
207
- if (copyFileSafe(configSrc, configDest, ctx)) {
208
- count++;
209
- }
323
+ if (copyFileSafe(configSrc, path.join(sddDir, "config.json"), ctx)) count++;
210
324
 
211
325
  const templatesSrc = path.join(packageRoot, ".sdd/templates");
212
326
  if (fs.existsSync(templatesSrc)) {
213
- const templateFiles = fs.readdirSync(templatesSrc);
214
- for (const file of templateFiles) {
215
- const src = path.join(templatesSrc, file);
216
- const dest = path.join(sddDir, "templates", file);
217
- if (copyFileSafe(src, dest, ctx)) {
218
- count++;
219
- }
327
+ for (const file of fs.readdirSync(templatesSrc)) {
328
+ if (copyFileSafe(path.join(templatesSrc, file), path.join(sddDir, "templates", file), ctx)) count++;
220
329
  }
221
330
  }
222
331
 
223
- const projectProfileDest = path.join(sddDir, "project-profile.json");
224
- const profileTemplateSrc = path.join(
225
- sddDir,
226
- "templates",
227
- "project-profile-template.json"
228
- );
332
+ const profileTemplates = [
333
+ { dest: "project-profile.json", template: "project-profile-template.json", populate: true },
334
+ { dest: "model-profile.json", template: "model-profile-template.json", populate: false },
335
+ { dest: "reasoning-profile.json", template: "reasoning-profile-template.json", populate: false },
336
+ ];
229
337
 
230
- if (fs.existsSync(projectProfileDest) && !ctx.force) {
231
- ctx.skipped.push(".sdd/project-profile.json (exists)");
232
- } else if (fs.existsSync(profileTemplateSrc)) {
233
- const profile = JSON.parse(fs.readFileSync(profileTemplateSrc, "utf-8"));
234
- profile.initialized = new Date().toISOString();
235
- profile.lastUpdated = new Date().toISOString();
338
+ for (const { dest, template, populate } of profileTemplates) {
339
+ const destPath = path.join(sddDir, dest);
340
+ const templatePath = path.join(sddDir, "templates", template);
236
341
 
237
- if (ctx.dryRun) {
238
- ctx.dryRunFiles.push(".sdd/project-profile.json");
239
- } else {
240
- fs.writeFileSync(
241
- projectProfileDest,
242
- JSON.stringify(profile, null, 2)
243
- );
244
- ctx.installed.push(".sdd/project-profile.json");
342
+ if (fs.existsSync(destPath) && !ctx.force) {
343
+ ctx.skipped.push(`.sdd/${dest} (exists)`);
344
+ continue;
245
345
  }
246
- count++;
247
- }
248
346
 
249
- const modelProfileDest = path.join(sddDir, "model-profile.json");
250
- const modelProfileTemplateSrc = path.join(
251
- sddDir,
252
- "templates",
253
- "model-profile-template.json"
254
- );
347
+ if (!fs.existsSync(templatePath)) continue;
255
348
 
256
- if (fs.existsSync(modelProfileDest) && !ctx.force) {
257
- ctx.skipped.push(".sdd/model-profile.json (exists)");
258
- } else if (fs.existsSync(modelProfileTemplateSrc)) {
259
- copyFileSafe(modelProfileTemplateSrc, modelProfileDest, ctx);
260
- count++;
261
- }
262
-
263
- const reasoningProfileDest = path.join(sddDir, "reasoning-profile.json");
264
- const reasoningProfileTemplateSrc = path.join(
265
- sddDir,
266
- "templates",
267
- "reasoning-profile-template.json"
268
- );
349
+ if (ctx.dryRun) {
350
+ ctx.dryRunFiles.push(`.sdd/${dest}`);
351
+ count++;
352
+ continue;
353
+ }
269
354
 
270
- if (fs.existsSync(reasoningProfileDest) && !ctx.force) {
271
- ctx.skipped.push(".sdd/reasoning-profile.json (exists)");
272
- } else if (fs.existsSync(reasoningProfileTemplateSrc)) {
273
- copyFileSafe(reasoningProfileTemplateSrc, reasoningProfileDest, ctx);
355
+ if (populate) {
356
+ const profile = JSON.parse(fs.readFileSync(templatePath, "utf-8"));
357
+ profile.initialized = new Date().toISOString();
358
+ profile.lastUpdated = new Date().toISOString();
359
+ atomicWrite(destPath, JSON.stringify(profile, null, 2));
360
+ } else {
361
+ atomicWriteFromFile(templatePath, destPath);
362
+ }
363
+ ctx.installed.push(`.sdd/${dest}`);
274
364
  count++;
275
365
  }
276
366
 
@@ -285,26 +375,25 @@ function installSdd(ctx) {
285
375
 
286
376
  function patchOpencodeJson(ctx) {
287
377
  const targetJsonPath = path.join(ctx.targetRoot, "opencode.json");
288
-
289
378
  let targetConfig = {};
379
+ let rawContent = "";
380
+ let isJsonc = false;
290
381
 
291
382
  if (fs.existsSync(targetJsonPath)) {
292
383
  try {
293
- targetConfig = JSON.parse(fs.readFileSync(targetJsonPath, "utf-8"));
294
- } catch (e) {
384
+ rawContent = fs.readFileSync(targetJsonPath, "utf-8");
385
+ isJsonc = rawContent.includes("//") || rawContent.includes("/*");
386
+ targetConfig = isJsonc ? parseJsonc(targetJsonPath) : JSON.parse(rawContent);
387
+ } catch {
295
388
  ctx.skipped.push("opencode.json (parse error, skipping patch)");
296
389
  return;
297
390
  }
298
391
  }
299
392
 
300
- if (!targetConfig.agent) {
301
- targetConfig.agent = {};
302
- }
393
+ if (!targetConfig.agent) targetConfig.agent = {};
303
394
 
304
- const vendorRulesJson = path.join(
305
- packageRoot,
306
- "vendor/opencode-agent-rules/opencode.json"
307
- );
395
+ // Patch compaction from vendor rules
396
+ const vendorRulesJson = path.join(packageRoot, "vendor/opencode-agent-rules/opencode.json");
308
397
  if (fs.existsSync(vendorRulesJson)) {
309
398
  const rulesConfig = JSON.parse(fs.readFileSync(vendorRulesJson, "utf-8"));
310
399
  if (rulesConfig.agent?.compaction && !targetConfig.agent.compaction) {
@@ -313,34 +402,22 @@ function patchOpencodeJson(ctx) {
313
402
  }
314
403
  }
315
404
 
405
+ // Patch plugins
316
406
  const pkgJsonPath = path.join(packageRoot, "opencode.json");
317
407
  if (fs.existsSync(pkgJsonPath)) {
318
408
  const pkgConfig = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
319
409
 
320
410
  if (Array.isArray(pkgConfig.plugin)) {
321
- if (!Array.isArray(targetConfig.plugin)) {
322
- targetConfig.plugin = [];
323
- }
324
-
411
+ if (!Array.isArray(targetConfig.plugin)) targetConfig.plugin = [];
325
412
  for (const pluginEntry of pkgConfig.plugin) {
326
413
  const key = JSON.stringify(pluginEntry);
327
- const exists = targetConfig.plugin.some((existing) => JSON.stringify(existing) === key);
328
- if (!exists) {
414
+ if (!targetConfig.plugin.some((e) => JSON.stringify(e) === key)) {
329
415
  targetConfig.plugin.push(pluginEntry);
330
416
  ctx.installed.push(`opencode.json (plugin: ${Array.isArray(pluginEntry) ? pluginEntry[0] : pluginEntry})`);
331
417
  }
332
418
  }
333
419
  }
334
420
 
335
- // NOTE: model and small_model are NOT patched from opencode.json.
336
- // Model routing is handled by .sdd/model-profile.json + sdd-model-router.js plugin.
337
- // Users edit model-profile.json to change models — opencode.json models are ignored.
338
-
339
- if (pkgConfig.agents && !targetConfig.agents) {
340
- targetConfig.agents = pkgConfig.agents;
341
- ctx.installed.push("opencode.json (agents patch)");
342
- }
343
-
344
421
  if (pkgConfig.agent) {
345
422
  for (const [key, value] of Object.entries(pkgConfig.agent)) {
346
423
  if (!targetConfig.agent[key]) {
@@ -354,10 +431,6 @@ function patchOpencodeJson(ctx) {
354
431
  targetConfig.permission = pkgConfig.permission;
355
432
  ctx.installed.push("opencode.json (permission patch)");
356
433
  }
357
-
358
- // NOTE: model and small_model are NOT patched here.
359
- // Model routing is handled by .sdd/model-profile.json + sdd-model-router.js plugin.
360
- // Users should edit model-profile.json to change models, not opencode.json.
361
434
  }
362
435
 
363
436
  if (ctx.dryRun) {
@@ -365,17 +438,26 @@ function patchOpencodeJson(ctx) {
365
438
  return;
366
439
  }
367
440
 
368
- fs.writeFileSync(targetJsonPath, JSON.stringify(targetConfig, null, 2));
441
+ createBackup(targetJsonPath);
442
+ atomicWrite(targetJsonPath, JSON.stringify(targetConfig, null, 2));
369
443
  }
370
444
 
371
445
  async function runInit(args) {
372
446
  const targetRoot = process.cwd();
373
447
 
374
448
  if (args.flags.sddOnly && args.flags.rulesOnly) {
375
- console.error(" Cannot use --sdd-only and --rules-only together.");
449
+ console.error("Error: Cannot use --sdd-only and --rules-only together.");
376
450
  process.exit(1);
377
451
  }
378
452
 
453
+ if (args.flags.minimal) {
454
+ args.flags.sddOnly = true;
455
+ }
456
+ if (args.flags.full) {
457
+ args.flags.rulesOnly = false;
458
+ args.flags.sddOnly = false;
459
+ }
460
+
379
461
  const ctx = {
380
462
  targetRoot,
381
463
  force: args.flags.force,
@@ -385,35 +467,30 @@ async function runInit(args) {
385
467
  dryRunFiles: [],
386
468
  };
387
469
 
388
- console.log(`\n📦 SDD Multi-Agent OpenCode Installer\n`);
470
+ console.log("\nSDD Multi-Agent OpenCode Installer v0.3.0\n");
389
471
  console.log(`Target: ${targetRoot}`);
390
-
391
- if (ctx.dryRun) {
392
- console.log(`Mode: DRY RUN (no files will be written)\n`);
393
- }
472
+ if (ctx.dryRun) console.log("Mode: DRY RUN (no files will be written)\n");
394
473
 
395
474
  let rulesCount = 0;
396
475
  let sddCount = 0;
397
476
 
398
477
  if (!args.flags.sddOnly) {
399
- console.log(`Installing base rules...`);
478
+ console.log("Installing base rules...");
400
479
  rulesCount = installRules(ctx);
401
480
  }
402
481
 
403
482
  if (!args.flags.rulesOnly) {
404
- console.log(`Installing SDD workflow...`);
483
+ console.log("Installing SDD workflow...");
405
484
  sddCount = installSdd(ctx);
406
485
  }
407
486
 
408
487
  patchOpencodeJson(ctx);
409
488
 
410
- console.log(`\n✅ Done!\n`);
489
+ console.log("\nDone!\n");
411
490
 
412
491
  if (ctx.dryRun) {
413
492
  console.log(`Would install (${ctx.dryRunFiles.length} files):`);
414
- for (const f of ctx.dryRunFiles) {
415
- console.log(` + ${f}`);
416
- }
493
+ for (const f of ctx.dryRunFiles) console.log(` + ${f}`);
417
494
  } else {
418
495
  console.log(`Installed:`);
419
496
  console.log(` - rules: ${rulesCount} files`);
@@ -421,24 +498,259 @@ async function runInit(args) {
421
498
 
422
499
  if (ctx.skipped.length > 0) {
423
500
  console.log(`\nSkipped (exists, use --force):`);
424
- for (const f of ctx.skipped) {
425
- console.log(` - ${f}`);
501
+ for (const f of ctx.skipped) console.log(` - ${f}`);
502
+ }
503
+
504
+ console.log("\nNext steps:");
505
+ console.log(" 1. Edit .sdd/project-profile.json with your stack");
506
+ console.log(" 2. Run: /sdd-propose \"your first feature\"");
507
+ console.log(" 3. Optional TDD: /sdd-propose my-feature --tdd");
508
+ }
509
+ }
510
+
511
+ // ─── Doctor Command ──────────────────────────────────────────────────────────
512
+
513
+ async function runDoctor(args) {
514
+ const targetRoot = process.cwd();
515
+ const results = { pass: 0, warn: 0, fail: 0, checks: [] };
516
+
517
+ function report(level, name, status, detail = "") {
518
+ const icon = status === "pass" ? "OK" : status === "warn" ? "WARN" : "FAIL";
519
+ results.checks.push({ level, name, status, detail });
520
+ if (status === "pass") results.pass++;
521
+ else if (status === "warn") results.warn++;
522
+ else results.fail++;
523
+ console.log(` [${icon}] [${level}] ${name}${detail ? ` — ${detail}` : ""}`);
524
+ }
525
+
526
+ console.log("\nSDD Doctor — Diagnostic Report\n");
527
+ console.log(`Target: ${targetRoot}\n`);
528
+
529
+ // L0: File structure, JSON validity, orphaned symlinks
530
+ console.log("L0: File Structure");
531
+
532
+ const requiredDirs = [".opencode/agents", ".opencode/commands", ".opencode/skills", ".sdd", ".sdd/templates", "specs/active", "specs/backlog", "specs/completed"];
533
+ for (const dir of requiredDirs) {
534
+ const fullPath = path.join(targetRoot, dir);
535
+ report("L0", dir, fs.existsSync(fullPath) ? "pass" : "fail", fs.existsSync(fullPath) ? "exists" : "missing");
536
+ }
537
+
538
+ const requiredFiles = ["opencode.json", ".sdd/config.json"];
539
+ for (const file of requiredFiles) {
540
+ const fullPath = path.join(targetRoot, file);
541
+ if (!fs.existsSync(fullPath)) {
542
+ report("L0", file, "fail", "missing");
543
+ continue;
544
+ }
545
+ try {
546
+ if (file.endsWith(".json")) {
547
+ parseJsonc(fullPath);
548
+ } else {
549
+ fs.readFileSync(fullPath, "utf-8");
550
+ }
551
+ report("L0", file, "pass", "valid");
552
+ } catch (e) {
553
+ report("L0", file, "fail", `parse error: ${e.message}`);
554
+ }
555
+ }
556
+
557
+ // Check for orphaned symlinks from v0.2.x
558
+ const globalCommandsDir = path.join(globalConfigDir, "commands");
559
+ const globalAgentsDir = path.join(globalConfigDir, "agents");
560
+ let orphanedCount = 0;
561
+
562
+ for (const dir of [globalCommandsDir, globalAgentsDir]) {
563
+ if (!fs.existsSync(dir)) continue;
564
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
565
+ const fullPath = path.join(dir, entry.name);
566
+ try {
567
+ const stat = fs.lstatSync(fullPath);
568
+ if (stat.isSymbolicLink()) {
569
+ const target = fs.readlinkSync(fullPath);
570
+ if (target.includes("sdd-multiagent-opencode") || target.includes("opencode-agent-rules")) {
571
+ orphanedCount++;
572
+ }
573
+ }
574
+ } catch {
575
+ // skip
576
+ }
577
+ }
578
+ }
579
+
580
+ report("L0", "orphaned symlinks", orphanedCount === 0 ? "pass" : "warn", orphanedCount > 0 ? `${orphanedCount} old symlinks found — run "sdd-opencode migrate"` : "clean");
581
+
582
+ // L1: Plugin registration count
583
+ console.log("\nL1: Plugin Registration");
584
+
585
+ const pkgOpencodeJson = parseJsonc(path.join(packageRoot, "opencode.json"));
586
+ const expectedAgents = pkgOpencodeJson?.agent ? Object.keys(pkgOpencodeJson.agent).length : 6;
587
+ const expectedSkills = 4;
588
+ const expectedCommands = 6;
589
+
590
+ // Count actual agents in target
591
+ const targetOpencodeJson = parseJsonc(path.join(targetRoot, "opencode.json"));
592
+ const sddAgents = targetOpencodeJson?.agent
593
+ ? Object.keys(targetOpencodeJson.agent).filter((k) => k.startsWith("sdd-")).length
594
+ : 0;
595
+
596
+ report("L1", "agents registered", sddAgents >= expectedAgents ? "pass" : "warn", `${sddAgents}/${expectedAgents} SDD agents in opencode.json`);
597
+
598
+ // Count skills files
599
+ const skillsDir = path.join(packageRoot, ".opencode/skills");
600
+ const skillCount = fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir).filter((f) => fs.statSync(path.join(skillsDir, f)).isDirectory()).length : 0;
601
+ report("L1", "skills available", skillCount >= expectedSkills ? "pass" : "warn", `${skillCount}/${expectedSkills} skill directories`);
602
+
603
+ // Count command files
604
+ const commandsDir = path.join(packageRoot, ".opencode/commands");
605
+ const cmdCount = fs.existsSync(commandsDir) ? fs.readdirSync(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
606
+ report("L1", "commands available", cmdCount >= expectedCommands ? "pass" : "warn", `${cmdCount}/${expectedCommands} command files`);
607
+
608
+ // L2: Environment variables
609
+ console.log("\nL2: Environment");
610
+
611
+ const modelProfilePath = path.join(targetRoot, ".sdd", "model-profile.json");
612
+ const modelProfile = fs.existsSync(modelProfilePath) ? JSON.parse(fs.readFileSync(modelProfilePath, "utf-8")) : null;
613
+
614
+ if (modelProfile) {
615
+ const models = new Set();
616
+ if (modelProfile.defaultPrimary) models.add(modelProfile.defaultPrimary);
617
+ if (modelProfile.agents) {
618
+ for (const m of Object.values(modelProfile.agents)) models.add(m);
619
+ }
620
+
621
+ const providerChecks = [
622
+ { name: "OPENAI_API_KEY", pattern: /openai|gpt|o1|o3/i },
623
+ { name: "ANTHROPIC_API_KEY", pattern: /anthropic|claude/i },
624
+ { name: "GOOGLE_API_KEY", pattern: /google|gemini/i },
625
+ { name: "OPENCODE_ZEN_API_KEY", pattern: /zen/i },
626
+ ];
627
+
628
+ for (const { name, pattern } of providerChecks) {
629
+ const modelMatch = [...models].some((m) => pattern.test(m));
630
+ if (modelMatch) {
631
+ const hasKey = !!process.env[name];
632
+ report("L2", `${name}`, hasKey ? "pass" : "warn", hasKey ? "set" : "not set — models requiring this provider were detected");
633
+ }
634
+ }
635
+ } else {
636
+ report("L2", "model-profile.json", "warn", "not found — using default model routing");
637
+ }
638
+
639
+ // L3: Liveness probe (--deep only)
640
+ if (args.flags.deep) {
641
+ console.log("\nL3: Liveness Probe");
642
+
643
+ const primaryModel = modelProfile?.defaultPrimary || process.env.DEFAULT_MODEL || "unknown";
644
+ report("L3", "model endpoint", "warn", `would probe ${primaryModel} — liveness requires valid API credentials and network access`);
645
+ report("L3", "note", "warn", "full liveness probe requires HTTP client — run manually with curl or your provider's dashboard");
646
+ }
647
+
648
+ // Summary
649
+ console.log(`\nSummary: ${results.pass} passed, ${results.warn} warnings, ${results.fail} failures`);
650
+
651
+ if (results.fail > 0) {
652
+ console.log("\nAction required: Fix failing checks before using SDD commands.");
653
+ } else if (results.warn > 0) {
654
+ console.log("\nSome checks need attention but SDD should work.");
655
+ } else {
656
+ console.log("\nAll checks passed. SDD is ready to use.");
657
+ }
658
+ }
659
+
660
+ // ─── Warm Command ────────────────────────────────────────────────────────────
661
+
662
+ function runWarm() {
663
+ console.log("\nWarming OpenCode plugin cache...\n");
664
+
665
+ const opencodeCacheDir = path.join(globalConfigDir, ".opencode");
666
+ if (!fs.existsSync(opencodeCacheDir)) {
667
+ console.log("OpenCode config directory not found. Run OpenCode once to initialize cache.");
668
+ return;
669
+ }
670
+
671
+ try {
672
+ console.log("Running bun install in plugin cache...");
673
+ execSync("bun install", { cwd: opencodeCacheDir, stdio: "inherit" });
674
+ console.log("\nPlugin cache warmed successfully.");
675
+ } catch (e) {
676
+ console.log(`Warning: bun install failed — ${e.message}`);
677
+ console.log("This is non-fatal. OpenCode will resolve plugins on first run.");
678
+ }
679
+ }
680
+
681
+ // ─── Migrate Command ─────────────────────────────────────────────────────────
682
+
683
+ function runMigrate() {
684
+ console.log("\nMigrating from v0.2.x symlink-based installation...\n");
685
+
686
+ const MIGRATION_MARKER = ".sdd-migrated-v030";
687
+ const markerPath = path.join(globalConfigDir, MIGRATION_MARKER);
688
+
689
+ if (fs.existsSync(markerPath)) {
690
+ console.log("Migration already completed. No action needed.");
691
+ return;
692
+ }
693
+
694
+ const symlinkTargets = [
695
+ { dir: "commands", prefix: "sdd-" },
696
+ { dir: "agents", prefix: "sdd-" },
697
+ { dir: "rules", prefix: null },
698
+ ];
699
+
700
+ let removed = 0;
701
+
702
+ for (const { dir, prefix } of symlinkTargets) {
703
+ const dirPath = path.join(globalConfigDir, dir);
704
+ if (!fs.existsSync(dirPath)) continue;
705
+
706
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
707
+ const fullPath = path.join(dirPath, entry.name);
708
+ try {
709
+ const stat = fs.lstatSync(fullPath);
710
+ if (!stat.isSymbolicLink()) continue;
711
+ if (prefix && !entry.name.startsWith(prefix)) continue;
712
+
713
+ const linkTarget = fs.readlinkSync(fullPath);
714
+ if (linkTarget.includes("sdd-multiagent-opencode") || linkTarget.includes("opencode-agent-rules")) {
715
+ fs.unlinkSync(fullPath);
716
+ console.log(` Removed: ${path.join(dir, entry.name)}`);
717
+ removed++;
718
+ }
719
+ } catch {
720
+ // skip
426
721
  }
427
722
  }
723
+ }
428
724
 
429
- console.log(`\nNext steps:`);
430
- console.log(` 1. Edit .sdd/project-profile.json with your stack`);
431
- console.log(` 2. Run: /brief my-feature "Feature description"`);
432
- console.log(` 3. Optional TDD: /tasks my-feature --tdd`);
725
+ if (removed > 0) {
726
+ try {
727
+ fs.writeFileSync(markerPath, new Date().toISOString());
728
+ } catch {
729
+ // skip
730
+ }
731
+ console.log(`\nMigration complete: ${removed} old symlinks removed.`);
732
+ console.log("Plugin-native registration is now active.");
733
+ } else {
734
+ console.log("No old symlinks found. Nothing to migrate.");
433
735
  }
434
736
  }
435
737
 
738
+ // ─── Main ────────────────────────────────────────────────────────────────────
739
+
436
740
  const args = parseArgs(process.argv);
437
741
 
438
742
  if (args.command === "help") {
439
743
  usage();
440
744
  } else if (args.command === "init") {
441
745
  runInit(args);
746
+ } else if (args.command === "doctor") {
747
+ runDoctor(args);
748
+ } else if (args.command === "warm") {
749
+ runWarm();
750
+ } else if (args.command === "migrate") {
751
+ runMigrate();
752
+ } else if (args.command === "test") {
753
+ console.log("Smoke test placeholder — plugin loads correctly.");
442
754
  } else {
443
755
  console.error(`Unknown command: ${args.command}`);
444
756
  usage();