nitm-opencode-starter 0.1.1
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/README.md +406 -0
- package/bin/cli.js +353 -0
- package/oh-my-opencode-slim.jsonc +1488 -0
- package/opencode.jsonc +19 -0
- package/package.json +35 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// nitm-opencode-starter CLI
|
|
5
|
+
// Commands: install, patch, upgrade, doctor
|
|
6
|
+
// Ships the two starter config files and applies them to the repo you run it in.
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const readline = require("readline");
|
|
11
|
+
const { execSync } = require("child_process");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
|
|
14
|
+
const CONFIG_FILES = ["opencode.jsonc", "oh-my-opencode-slim.jsonc"];
|
|
15
|
+
const PKG_DIR = path.join(__dirname, "..");
|
|
16
|
+
|
|
17
|
+
// Keys whose user value is always preserved during an upgrade merge.
|
|
18
|
+
const PRESERVE_KEYS = new Set(["preset"]);
|
|
19
|
+
// Array keys that are unioned (user extras kept) instead of replaced.
|
|
20
|
+
// Only `plugin` is unioned; all other arrays (e.g. model) and non-array leaf
|
|
21
|
+
// fields are replaced by the package value on upgrade.
|
|
22
|
+
const UNION_ARRAYS = new Set(["plugin"]);
|
|
23
|
+
|
|
24
|
+
function log(...a) {
|
|
25
|
+
console.log("[nitm-opencode-starter]", ...a);
|
|
26
|
+
}
|
|
27
|
+
function warn(...a) {
|
|
28
|
+
console.warn("[nitm-opencode-starter] WARN:", ...a);
|
|
29
|
+
}
|
|
30
|
+
function fail(...a) {
|
|
31
|
+
console.error("[nitm-opencode-starter] ERROR:", ...a);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- JSONC handling (no deps) ---
|
|
35
|
+
|
|
36
|
+
function stripJsonc(text) {
|
|
37
|
+
// Pass 1: drop comments (string-aware).
|
|
38
|
+
let out = "";
|
|
39
|
+
let i = 0;
|
|
40
|
+
const n = text.length;
|
|
41
|
+
while (i < n) {
|
|
42
|
+
const c = text[i];
|
|
43
|
+
const c2 = text[i + 1];
|
|
44
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
45
|
+
const quote = c;
|
|
46
|
+
out += c;
|
|
47
|
+
i++;
|
|
48
|
+
while (i < n) {
|
|
49
|
+
const ch = text[i];
|
|
50
|
+
out += ch;
|
|
51
|
+
if (ch === "\\") { out += text[i + 1]; i += 2; continue; }
|
|
52
|
+
if (ch === quote) { i++; break; }
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === "/" && c2 === "/") { while (i < n && text[i] !== "\n") i++; continue; }
|
|
58
|
+
if (c === "/" && c2 === "*") { i += 2; while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++; i += 2; continue; }
|
|
59
|
+
out += c;
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
// Pass 2: drop trailing commas, string-aware so values like ",]" are untouched.
|
|
63
|
+
let res = "";
|
|
64
|
+
let j = 0;
|
|
65
|
+
let inStr = false;
|
|
66
|
+
let q = "";
|
|
67
|
+
while (j < out.length) {
|
|
68
|
+
const ch = out[j];
|
|
69
|
+
if (inStr) {
|
|
70
|
+
res += ch;
|
|
71
|
+
if (ch === "\\") { res += out[j + 1]; j += 2; continue; }
|
|
72
|
+
if (ch === q) inStr = false;
|
|
73
|
+
j++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (ch === '"' || ch === "'" || ch === "`") { inStr = true; q = ch; res += ch; j++; continue; }
|
|
77
|
+
if (ch === ",") {
|
|
78
|
+
let k = j + 1;
|
|
79
|
+
while (k < out.length && /\s/.test(out[k])) k++;
|
|
80
|
+
if (out[k] === "}" || out[k] === "]") { j = k; continue; }
|
|
81
|
+
res += ch; j++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
res += ch; j++;
|
|
85
|
+
}
|
|
86
|
+
return res;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseJsonc(text) {
|
|
90
|
+
return JSON.parse(stripJsonc(text));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isPlainObject(v) {
|
|
94
|
+
return v && typeof v === "object" && !Array.isArray(v);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Merge package config over user config: user-only keys preserved, package wins
|
|
98
|
+
// on leaf conflicts, except PRESERVE_KEYS (kept from user) and UNION_ARRAYS (unioned).
|
|
99
|
+
function mergeConfig(pkg, user) {
|
|
100
|
+
const out = user ? { ...user } : {};
|
|
101
|
+
for (const [k, v] of Object.entries(pkg)) {
|
|
102
|
+
if (PRESERVE_KEYS.has(k) && k in out) continue;
|
|
103
|
+
if (Array.isArray(v) && UNION_ARRAYS.has(k) && Array.isArray(out[k])) {
|
|
104
|
+
out[k] = [...new Set([...out[k], ...v])];
|
|
105
|
+
} else if (isPlainObject(v) && isPlainObject(out[k])) {
|
|
106
|
+
out[k] = mergeConfig(v, out[k]);
|
|
107
|
+
} else {
|
|
108
|
+
out[k] = v;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- shell helpers ---
|
|
115
|
+
|
|
116
|
+
function tryRun(cmd) {
|
|
117
|
+
try {
|
|
118
|
+
execSync(cmd, { stdio: "ignore", timeout: 15000 });
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function run(cmd) {
|
|
126
|
+
try {
|
|
127
|
+
execSync(cmd, { stdio: "inherit", timeout: 300000 });
|
|
128
|
+
} catch {
|
|
129
|
+
fail("Command failed:", cmd);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ensureBun() {
|
|
135
|
+
if (tryRun("bun --version")) return;
|
|
136
|
+
log("bun not found, installing via npm...");
|
|
137
|
+
run("npm i -g bun");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function installPlugins() {
|
|
141
|
+
ensureBun();
|
|
142
|
+
log("Installing oh-my-opencode-slim plugin...");
|
|
143
|
+
run("bunx oh-my-opencode-slim@latest install");
|
|
144
|
+
log("Installing @dietrichgebert/ponytail...");
|
|
145
|
+
run("npm i -g @dietrichgebert/ponytail");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- file ops ---
|
|
149
|
+
|
|
150
|
+
function backup(p) {
|
|
151
|
+
const bakDir = fs.mkdtempSync(path.join(os.tmpdir(), "nitm-opencode-starter-bak-"));
|
|
152
|
+
fs.copyFileSync(p, path.join(bakDir, path.basename(p)));
|
|
153
|
+
log("Backed up existing", path.basename(p), "to", path.join(bakDir, path.basename(p)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function copyMissing() {
|
|
157
|
+
for (const f of CONFIG_FILES) {
|
|
158
|
+
const src = path.join(PKG_DIR, f);
|
|
159
|
+
const dest = path.join(process.cwd(), f);
|
|
160
|
+
if (fs.existsSync(dest)) {
|
|
161
|
+
log(path.basename(dest), "already present, skipping (use --force to overwrite).");
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
fs.copyFileSync(src, dest);
|
|
165
|
+
log("Copied", f, "into", process.cwd());
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function copyForce() {
|
|
170
|
+
for (const f of CONFIG_FILES) {
|
|
171
|
+
const src = path.join(PKG_DIR, f);
|
|
172
|
+
const dest = path.join(process.cwd(), f);
|
|
173
|
+
if (fs.existsSync(dest)) backup(dest);
|
|
174
|
+
fs.copyFileSync(src, dest);
|
|
175
|
+
log("Wrote", f, "into", process.cwd());
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function mergeConfigFiles() {
|
|
180
|
+
for (const f of CONFIG_FILES) {
|
|
181
|
+
const src = path.join(PKG_DIR, f);
|
|
182
|
+
const dest = path.join(process.cwd(), f);
|
|
183
|
+
if (!fs.existsSync(dest)) {
|
|
184
|
+
fs.copyFileSync(src, dest);
|
|
185
|
+
log("Copied", f, "(no existing file to merge).");
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
backup(dest);
|
|
189
|
+
const merged = mergeConfig(parseJsonc(fs.readFileSync(src, "utf8")), parseJsonc(fs.readFileSync(dest, "utf8")));
|
|
190
|
+
fs.writeFileSync(dest, JSON.stringify(merged, null, 2) + "\n");
|
|
191
|
+
log("Merged", f, "with latest starter config (comments stripped on upgrade).");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- commands ---
|
|
196
|
+
|
|
197
|
+
function runBootstrapPatch() {
|
|
198
|
+
copyMissing();
|
|
199
|
+
installPlugins();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function cmdInstall(force) {
|
|
203
|
+
log("Initializing OpenCode for", process.cwd());
|
|
204
|
+
if (force) copyForce();
|
|
205
|
+
else copyMissing();
|
|
206
|
+
installPlugins();
|
|
207
|
+
log("Install complete. Run `opencode` from this directory to start.");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function cmdPatch() {
|
|
211
|
+
log("Patching current install (bootstrap flow)...");
|
|
212
|
+
runBootstrapPatch();
|
|
213
|
+
log("Patch complete.");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function confirm(message, yes, input = process.stdin) {
|
|
217
|
+
if (yes) return Promise.resolve(true);
|
|
218
|
+
if (input.isTTY === false) {
|
|
219
|
+
warn("Non-interactive shell: refusing upgrade without --yes.");
|
|
220
|
+
return Promise.resolve(false);
|
|
221
|
+
}
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
const rl = readline.createInterface({ input });
|
|
224
|
+
rl.question(message + " [y/N] ", (ans) => {
|
|
225
|
+
rl.close();
|
|
226
|
+
resolve(ans.trim().toLowerCase() === "y" || ans.trim().toLowerCase() === "yes");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function cmdUpgrade(yes) {
|
|
232
|
+
log("Upgrade will update your starter config to the latest version.");
|
|
233
|
+
log("Versioned model assignments in opencode.jsonc / oh-my-opencode-slim.jsonc are overwritten with the latest starter values.");
|
|
234
|
+
log("Your active `preset`, extra plugins, and any custom presets/agents you added are preserved.");
|
|
235
|
+
log("A backup of each current config file is saved to a temp directory before writing.");
|
|
236
|
+
if (!await confirm("Continue with upgrade?", yes)) {
|
|
237
|
+
log("Aborted. No changes made.");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
log("Upgrading to latest starter config (re-copy + merge)...");
|
|
241
|
+
mergeConfigFiles();
|
|
242
|
+
installPlugins();
|
|
243
|
+
log("Upgrade complete.");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function findPluginDir(name) {
|
|
247
|
+
const bases = [path.join(os.homedir(), ".local/share/opencode"), path.join(os.homedir(), ".config/opencode")];
|
|
248
|
+
try {
|
|
249
|
+
bases.push(execSync("npm root -g", { stdio: "ignore" }).toString().trim());
|
|
250
|
+
} catch {}
|
|
251
|
+
for (const base of bases) {
|
|
252
|
+
if (!base || !fs.existsSync(base)) continue;
|
|
253
|
+
// ponytail: best-effort shallow + depth-2 search for the plugin directory
|
|
254
|
+
const stack = [base];
|
|
255
|
+
let depth = 0;
|
|
256
|
+
while (stack.length && depth < 3) {
|
|
257
|
+
const level = stack.splice(0);
|
|
258
|
+
depth++;
|
|
259
|
+
for (const d of level) {
|
|
260
|
+
let entries = [];
|
|
261
|
+
try {
|
|
262
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
263
|
+
} catch {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
for (const e of entries) {
|
|
267
|
+
if (!e.isDirectory()) continue;
|
|
268
|
+
if (e.name === name) return path.join(d, e.name);
|
|
269
|
+
if (depth < 3) stack.push(path.join(d, e.name));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function collectDoctorChecks() {
|
|
278
|
+
const opencodeOk = tryRun("opencode --version");
|
|
279
|
+
const bunOk = tryRun("bun --version");
|
|
280
|
+
const ponyOk = tryRun("npm ls -g @dietrichgebert/ponytail --depth=0");
|
|
281
|
+
const pluginDir = findPluginDir("oh-my-opencode-slim");
|
|
282
|
+
const cfgOk = CONFIG_FILES.every((f) => fs.existsSync(path.join(process.cwd(), f)));
|
|
283
|
+
return [
|
|
284
|
+
["OpenCode installed", opencodeOk, opencodeOk ? "" : "Install: curl -fsSL https://opencode.ai/install | bash"],
|
|
285
|
+
["bun installed", bunOk, bunOk ? "" : "Install: npm i -g bun"],
|
|
286
|
+
["@dietrichgebert/ponytail installed (global)", ponyOk, ponyOk ? "" : "Install: npm i -g @dietrichgebert/ponytail"],
|
|
287
|
+
["oh-my-opencode-slim plugin present (best-effort)", !!pluginDir, pluginDir ? "" : "Install: bunx oh-my-opencode-slim@latest install"],
|
|
288
|
+
["Starter config files present in " + process.cwd(), cfgOk, cfgOk ? "" : "Run: npx nitm-opencode-starter install"],
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function cmdDoctor() {
|
|
293
|
+
const checks = collectDoctorChecks();
|
|
294
|
+
let failed = 0;
|
|
295
|
+
console.log("\nDoctor checklist:\n");
|
|
296
|
+
for (const [label, ok, fix] of checks) {
|
|
297
|
+
const mark = ok ? "PASS" : fix ? "FAIL" : "WARN";
|
|
298
|
+
if (!ok && fix) failed++;
|
|
299
|
+
console.log(` [${mark}] ${label}${fix && !ok ? "\n fix: " + fix : ""}`);
|
|
300
|
+
}
|
|
301
|
+
console.log("");
|
|
302
|
+
if (failed) {
|
|
303
|
+
fail(failed + " check(s) failed. Address the fix lines above.");
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
log("All checks passed.");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- entry ---
|
|
310
|
+
|
|
311
|
+
async function main() {
|
|
312
|
+
const args = process.argv.slice(2);
|
|
313
|
+
const cmd = args[0];
|
|
314
|
+
const force = args.includes("--force");
|
|
315
|
+
const yes = args.includes("--yes") || args.includes("-y");
|
|
316
|
+
switch (cmd) {
|
|
317
|
+
case "install":
|
|
318
|
+
await cmdInstall(force);
|
|
319
|
+
break;
|
|
320
|
+
case "patch":
|
|
321
|
+
await cmdPatch();
|
|
322
|
+
break;
|
|
323
|
+
case "upgrade":
|
|
324
|
+
await cmdUpgrade(yes);
|
|
325
|
+
break;
|
|
326
|
+
case "doctor":
|
|
327
|
+
await cmdDoctor();
|
|
328
|
+
break;
|
|
329
|
+
case "help":
|
|
330
|
+
case "--help":
|
|
331
|
+
case "-h":
|
|
332
|
+
case undefined:
|
|
333
|
+
console.log(`nitm-opencode-starter - bootstrap OpenCode with the NITM starter config
|
|
334
|
+
|
|
335
|
+
Usage:
|
|
336
|
+
npx nitm-opencode-starter install [--force] Copy config into this repo, then run the bootstrap patch flow.
|
|
337
|
+
npx nitm-opencode-starter patch Re-run the bootstrap patch flow (ensure config + plugins).
|
|
338
|
+
npx nitm-opencode-starter upgrade [--yes] Re-copy + merge latest config, then re-run plugin installs.
|
|
339
|
+
npx nitm-opencode-starter doctor Checklist: verify tools and config are installed.
|
|
340
|
+
`);
|
|
341
|
+
break;
|
|
342
|
+
default:
|
|
343
|
+
fail("Unknown command:", cmd);
|
|
344
|
+
console.log("Run `npx nitm-opencode-starter help` for usage.");
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (require.main === module) {
|
|
350
|
+
main().catch((err) => { fail("Unexpected error:", (err && err.stack) || err); process.exit(1); });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
module.exports = { stripJsonc, parseJsonc, mergeConfig, mergeConfigFiles, copyMissing, copyForce, findPluginDir, confirm, collectDoctorChecks };
|