@tudeorangbiasa/sdd-multiagent-opencode 0.2.3 → 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.
- package/.opencode/plugins/sdd-register.js +869 -3
- package/bin/sdd-opencode.js +476 -164
- package/opencode.json +1 -2
- package/package.json +4 -2
package/bin/sdd-opencode.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
54
|
-
--force
|
|
55
|
-
--dry-run
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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("
|
|
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,
|
|
170
|
-
count++;
|
|
171
|
-
}
|
|
295
|
+
if (copyFileSafe(agentsSrc, path.join(ctx.targetRoot, "AGENTS.md"), ctx)) count++;
|
|
172
296
|
}
|
|
173
297
|
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
214
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
"
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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.
|
|
238
|
-
ctx.
|
|
239
|
-
|
|
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
|
-
|
|
250
|
-
const modelProfileTemplateSrc = path.join(
|
|
251
|
-
sddDir,
|
|
252
|
-
"templates",
|
|
253
|
-
"model-profile-template.json"
|
|
254
|
-
);
|
|
347
|
+
if (!fs.existsSync(templatePath)) continue;
|
|
255
348
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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(
|
|
478
|
+
console.log("Installing base rules...");
|
|
400
479
|
rulesCount = installRules(ctx);
|
|
401
480
|
}
|
|
402
481
|
|
|
403
482
|
if (!args.flags.rulesOnly) {
|
|
404
|
-
console.log(
|
|
483
|
+
console.log("Installing SDD workflow...");
|
|
405
484
|
sddCount = installSdd(ctx);
|
|
406
485
|
}
|
|
407
486
|
|
|
408
487
|
patchOpencodeJson(ctx);
|
|
409
488
|
|
|
410
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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();
|