omo-slim-plan 0.1.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/bin/install.js ADDED
@@ -0,0 +1,557 @@
1
+ #!/usr/bin/env node
2
+ // omo-slim-plan installer — zero deps, Node 18+ builtins only.
3
+ //
4
+ // Usage:
5
+ // node bin/install.js [--dry-run] [--force] [--uninstall]
6
+ // [--webhook <url>] [--telegram-token <t>] [--telegram-chat-id <id>]
7
+ // [--config <path>]
8
+ //
9
+ // Defaults:
10
+ // Config root : $OPencode_CONFIG || ~/.config/opencode
11
+ // Skill root : ~/.config/skillshare/skills (if directory) else <configRoot>/skills
12
+ // Commands : <configRoot>/command/
13
+ // Plugins : <configRoot>/plugin/
14
+ // Config file : <configRoot>/planflow.json
15
+ // Plans : .plans/ per project (documented, not installed here)
16
+
17
+ import {
18
+ copyFileSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ writeFileSync,
23
+ rmSync,
24
+ statSync,
25
+ } from "node:fs";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
31
+
32
+ const COMMAND_FILES = ["plan.md", "start-work.md", "plan-review.md"];
33
+ const PLUGIN_FILES = ["planflow.js", "planflow-notify.mjs", "planflow-providers.mjs"];
34
+ const DEFAULT_PLANFLOW = {
35
+ version: 1,
36
+ plansDir: ".plans",
37
+ webhook: {
38
+ provider: "telegram",
39
+ telegram: { botToken: "", chatId: "" },
40
+ generic: { url: "", headers: {} },
41
+ command: { cmd: "" },
42
+ events: {
43
+ "plan-ready": true,
44
+ "awaiting-review": true,
45
+ "task-done": false,
46
+ "awaiting-acceptance": true,
47
+ },
48
+ titlePrefix: "[omo-slim-plan]",
49
+ },
50
+ };
51
+
52
+ const useColor = process.stdout.isTTY || process.env.FORCE_COLOR;
53
+ const red = (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s);
54
+ const green = (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s);
55
+ const cyan = (s) => (useColor ? `\x1b[36m${s}\x1b[0m` : s);
56
+ const bold = (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s);
57
+
58
+ function ts() {
59
+ return new Date().toISOString().replace(/[:.]/g, "-");
60
+ }
61
+
62
+ function log(msg) {
63
+ process.stdout.write(msg + "\n");
64
+ }
65
+ function warn(msg) {
66
+ process.stderr.write(cyan("warn: ") + msg + "\n");
67
+ }
68
+ function err(msg) {
69
+ process.stderr.write(red("error: ") + msg + "\n");
70
+ }
71
+
72
+ function parseArgs(argv) {
73
+ const args = {
74
+ dryRun: false,
75
+ force: false,
76
+ uninstall: false,
77
+ webhook: "",
78
+ telegramToken: "",
79
+ telegramChatId: "",
80
+ config: "",
81
+ help: false,
82
+ };
83
+ const takesValue = new Set([
84
+ "--webhook",
85
+ "--telegram-token",
86
+ "--telegram-chat-id",
87
+ "--config",
88
+ ]);
89
+ const keyMap = {
90
+ "--webhook": "webhook",
91
+ "--telegram-token": "telegramToken",
92
+ "--telegram-chat-id": "telegramChatId",
93
+ "--config": "config",
94
+ };
95
+ for (let i = 0; i < argv.length; i++) {
96
+ let a = argv[i];
97
+ if (a === "-h" || a === "--help") {
98
+ args.help = true;
99
+ continue;
100
+ }
101
+ if (a === "--dry-run") {
102
+ args.dryRun = true;
103
+ continue;
104
+ }
105
+ if (a === "--force") {
106
+ args.force = true;
107
+ continue;
108
+ }
109
+ if (a === "--uninstall") {
110
+ args.uninstall = true;
111
+ continue;
112
+ }
113
+ if (a.startsWith("--") && a.includes("=")) {
114
+ const eq = a.indexOf("=");
115
+ const key = a.slice(0, eq);
116
+ const val = a.slice(eq + 1);
117
+ if (takesValue.has(key)) {
118
+ args[keyMap[key]] = val;
119
+ continue;
120
+ }
121
+ continue;
122
+ }
123
+ if (takesValue.has(a)) {
124
+ const next = argv[i + 1];
125
+ if (next === undefined || (next.startsWith("--") && next !== "-")) {
126
+ throw new Error(`missing value for ${a}`);
127
+ }
128
+ args[keyMap[a]] = next;
129
+ i++;
130
+ continue;
131
+ }
132
+ throw new Error(`unknown argument: ${a}`);
133
+ }
134
+ return args;
135
+ }
136
+
137
+ function usage() {
138
+ process.stdout.write(
139
+ [
140
+ "omo-slim-plan installer",
141
+ "",
142
+ "Usage:",
143
+ " node bin/install.js [options]",
144
+ "",
145
+ "Options:",
146
+ " --dry-run Print planned actions; write nothing; exit 0",
147
+ " --force Overwrite existing planflow.json / remove it on --uninstall",
148
+ " --uninstall Remove installed files; unregister plugin; keep planflow.json unless --force",
149
+ " --webhook <url> generic webhook provider (sets provider=generic, generic.url=<url>)",
150
+ " --telegram-token <t> Telegram bot token (sets provider=telegram)",
151
+ " --telegram-chat-id <id> Telegram chat id",
152
+ " --config <path> OpenCode config root (dir) or planflow.json path",
153
+ " -h, --help Show help",
154
+ "",
155
+ ].join("\n")
156
+ );
157
+ }
158
+
159
+ /** Refuse to write outside config root + skillshare root. */
160
+ function assertInside(target, roots) {
161
+ const t = path.resolve(target);
162
+ const allowed = roots.filter(Boolean).map((r) => path.resolve(r));
163
+ const bad = !allowed.some((root) => t === root || t.startsWith(root + path.sep));
164
+ if (bad) {
165
+ throw new Error(`refusing to write outside allowed roots: ${t}`);
166
+ }
167
+ }
168
+
169
+ function backupFile(target) {
170
+ if (!existsSync(target)) return null;
171
+ const bak = `${target}.bak-${ts()}`;
172
+ copyFileSync(target, bak);
173
+ return bak;
174
+ }
175
+
176
+ function resolvePaths(args) {
177
+ const home = os.homedir();
178
+ // Config root: --config (dir or json) > OPencode_CONFIG env > ~/.config/opencode
179
+ let configRoot;
180
+ let planflowPath;
181
+ const explicit = args.config || process.env.OPencode_CONFIG || "";
182
+ if (explicit) {
183
+ const p = path.resolve(explicit);
184
+ if (p.endsWith(".json")) {
185
+ planflowPath = p;
186
+ configRoot = path.dirname(p);
187
+ } else {
188
+ configRoot = p;
189
+ planflowPath = path.join(configRoot, "planflow.json");
190
+ }
191
+ } else {
192
+ configRoot = path.join(home, ".config", "opencode");
193
+ planflowPath = path.join(configRoot, "planflow.json");
194
+ }
195
+
196
+ // Skill root: skillshare if present, else configRoot/skills
197
+ const skillshare = path.join(home, ".config", "skillshare", "skills");
198
+ let skillRoot = null;
199
+ try {
200
+ if (existsSync(skillshare) && statSync(skillshare).isDirectory()) skillRoot = skillshare;
201
+ } catch {
202
+ skillRoot = null;
203
+ }
204
+ const skillDest = skillRoot
205
+ ? path.join(skillRoot, "plan-workflow", "SKILL.md")
206
+ : path.join(configRoot, "skills", "plan-workflow", "SKILL.md");
207
+
208
+ const commandDir = path.join(configRoot, "command");
209
+ const pluginDir = path.join(configRoot, "plugin");
210
+ const opencodeJson = path.join(configRoot, "opencode.json");
211
+
212
+ return {
213
+ configRoot,
214
+ planflowPath,
215
+ skillDest,
216
+ skillRoot,
217
+ commandDir,
218
+ pluginDir,
219
+ opencodeJson,
220
+ allowedRoots: [configRoot, skillRoot].filter(Boolean),
221
+ };
222
+ }
223
+
224
+ /** Trivial JSONC tolerance: strip comments + trailing commas. */
225
+ function tryParseJsonc(raw) {
226
+ const stripped = raw
227
+ .replace(/\/\*[\s\S]*?\*\//g, "")
228
+ .replace(/(^|[^:])\/\/.*$/gm, "$1")
229
+ .replace(/,(\s*[}\]])/g, "$1");
230
+ return JSON.parse(stripped);
231
+ }
232
+
233
+ function readOpencodeJson(file) {
234
+ const raw = readFileSync(file, "utf8");
235
+ try {
236
+ return { ok: true, data: JSON.parse(raw) };
237
+ } catch (e1) {
238
+ try {
239
+ return { ok: true, data: tryParseJsonc(raw), jsonc: true };
240
+ } catch (e2) {
241
+ return { ok: false, error: e1?.message || String(e1) };
242
+ }
243
+ }
244
+ }
245
+
246
+ function ensurePluginArray(data, mode) {
247
+ // mode: "add" | "remove"
248
+ let arr = data.plugin;
249
+ if (arr === undefined) {
250
+ if (mode === "remove") return { changed: false, arr: undefined };
251
+ data.plugin = ["planflow"];
252
+ return { changed: true, arr: data.plugin };
253
+ }
254
+ if (typeof arr === "string") arr = [arr];
255
+ if (!Array.isArray(arr)) {
256
+ return { changed: false, arr, invalid: true };
257
+ }
258
+ const has = arr.includes("planflow");
259
+ if (mode === "add" && !has) {
260
+ arr.push("planflow");
261
+ return { changed: true, arr };
262
+ }
263
+ if (mode === "remove" && has) {
264
+ data.plugin = arr.filter((x) => x !== "planflow");
265
+ return { changed: true, arr: data.plugin };
266
+ }
267
+ return { changed: false, arr: data.plugin };
268
+ }
269
+
270
+ function writeOpencodeJson(file, data, dryRun) {
271
+ const out = JSON.stringify(data, null, 2) + "\n";
272
+ if (dryRun) {
273
+ log(` [dry-run] would write ${file} (plugin array ${JSON.stringify(data.plugin || [])})`);
274
+ return;
275
+ }
276
+ const bak = backupFile(file);
277
+ if (bak) log(` backup: ${bak}`);
278
+ writeFileSync(file, out, "utf8");
279
+ log(` wrote: ${file}`);
280
+ }
281
+
282
+ function buildPlanflowConfig(args, existing) {
283
+ const base = existing ? { ...existing } : JSON.parse(JSON.stringify(DEFAULT_PLANFLOW));
284
+ // Ensure webhook shape exists
285
+ if (!base.webhook || typeof base.webhook !== "object") {
286
+ base.webhook = JSON.parse(JSON.stringify(DEFAULT_PLANFLOW.webhook));
287
+ }
288
+ base.webhook = { ...JSON.parse(JSON.stringify(DEFAULT_PLANFLOW.webhook)), ...base.webhook };
289
+ if (args.webhook) {
290
+ base.webhook.provider = "generic";
291
+ base.webhook.generic = { ...(base.webhook.generic || {}), url: args.webhook };
292
+ }
293
+ if (args.telegramToken || args.telegramChatId) {
294
+ base.webhook.provider = "telegram";
295
+ base.webhook.telegram = {
296
+ ...(base.webhook.telegram || {}),
297
+ ...(args.telegramToken ? { botToken: args.telegramToken } : {}),
298
+ ...(args.telegramChatId ? { chatId: args.telegramChatId } : {}),
299
+ };
300
+ }
301
+ if (base.version === undefined) base.version = 1;
302
+ if (base.plansDir === undefined) base.plansDir = ".plans";
303
+ return base;
304
+ }
305
+
306
+ function copyTemplate(src, dest, roots, dryRun, label) {
307
+ if (!existsSync(src)) {
308
+ warn(`template missing: ${src}`);
309
+ return { skipped: true, reason: "template_missing" };
310
+ }
311
+ assertInside(dest, roots);
312
+ const exists = existsSync(dest);
313
+ if (dryRun) {
314
+ log(` [dry-run] ${exists ? "backup+overwrite" : "copy"} ${src} -> ${dest}`);
315
+ return { ok: true, dryRun: true };
316
+ }
317
+ mkdirSync(path.dirname(dest), { recursive: true });
318
+ if (exists) {
319
+ const bak = backupFile(dest);
320
+ log(` backup: ${bak}`);
321
+ }
322
+ copyFileSync(src, dest);
323
+ log(` ${exists ? "updated" : "installed"}: ${dest}${label ? ` (${label})` : ""}`);
324
+ return { ok: true };
325
+ }
326
+
327
+ function removeIfExists(target, dryRun, roots, label) {
328
+ if (!existsSync(target)) {
329
+ log(` (absent) ${target}`);
330
+ return { removed: false };
331
+ }
332
+ assertInside(target, roots);
333
+ if (dryRun) {
334
+ log(` [dry-run] would remove ${target}${label ? ` (${label})` : ""}`);
335
+ return { removed: false, dryRun: true };
336
+ }
337
+ rmSync(target, { recursive: true, force: true });
338
+ log(` removed: ${target}${label ? ` (${label})` : ""}`);
339
+ return { removed: true };
340
+ }
341
+
342
+ function printNextSteps(p) {
343
+ log("");
344
+ log(bold("Next steps:"));
345
+ log(" 1. Restart OpenCode so it loads the planflow plugin.");
346
+ log(` 2. Configure Telegram (or generic/command) in ${p.planflowPath}`);
347
+ log(' - telegram: fill webhook.telegram.botToken + webhook.telegram.chatId');
348
+ log(" - generic: set webhook.provider=generic + webhook.generic.url");
349
+ log(" - command: set webhook.provider=command + webhook.command.cmd");
350
+ log(" 3. In a project, run /plan — AI writes .plans/<slug>.md, then you choose:");
351
+ log(" start-work <slug> | plan-review <slug> | revise plan");
352
+ log("");
353
+ log(` Commands installed: ${p.commandDir}/plan.md, start-work.md, plan-review.md`);
354
+ log(` Skill installed: ${p.skillDest}`);
355
+ log(` Plugin installed: ${p.pluginDir}/planflow.js (+ planflow-notify.mjs, planflow-providers.mjs)`);
356
+ log(` Notify CLI: ${p.pluginDir}/planflow-notify.mjs`);
357
+ log(` Plans convention: .plans/ per project (see templates/plans/README.md; commit plans, not boulder)`);
358
+ }
359
+
360
+ function runInstall(args, p) {
361
+ log(bold("omo-slim-plan installer"));
362
+ log(cyan("Resolving paths..."));
363
+ log(` config root : ${p.configRoot}`);
364
+ log(` planflow.json: ${p.planflowPath}`);
365
+ log(` commands : ${p.commandDir}/`);
366
+ log(` plugins : ${p.pluginDir}/`);
367
+ log(` skill dest : ${p.skillDest}`);
368
+ if (p.skillRoot) log(` skill root : ${p.skillRoot} (skillshare)`);
369
+ log(` opencode.json: ${p.opencodeJson}`);
370
+ log("");
371
+
372
+ if (args.dryRun) log(bold("Dry-run — no files will be written."));
373
+ log(bold(args.uninstall ? "Uninstall actions:" : "Install actions:"));
374
+
375
+ // --- 1/2/5/6: commands + skill + plugin files ---
376
+ if (!args.uninstall) {
377
+ log(cyan("Commands:"));
378
+ for (const name of COMMAND_FILES) {
379
+ copyTemplate(
380
+ path.join(PKG_ROOT, "templates", "command", name),
381
+ path.join(p.commandDir, name),
382
+ p.allowedRoots,
383
+ args.dryRun
384
+ );
385
+ }
386
+ log(cyan("Skill:"));
387
+ copyTemplate(
388
+ path.join(PKG_ROOT, "templates", "skills", "plan-workflow", "SKILL.md"),
389
+ p.skillDest,
390
+ p.allowedRoots,
391
+ args.dryRun,
392
+ "plan-workflow"
393
+ );
394
+ log(cyan("Plugin:"));
395
+ for (const name of PLUGIN_FILES) {
396
+ copyTemplate(
397
+ path.join(PKG_ROOT, "plugin", name),
398
+ path.join(p.pluginDir, name),
399
+ p.allowedRoots,
400
+ args.dryRun
401
+ );
402
+ }
403
+ log(cyan("Plans template (reference only):"));
404
+ log(` [ref] templates/plans/README.md — copy into projects as .plans/README.md if desired`);
405
+
406
+ // --- 7: planflow.json ---
407
+ log(cyan("Config:"));
408
+ const exists = existsSync(p.planflowPath);
409
+ if (exists && !args.force) {
410
+ if (args.dryRun) {
411
+ log(` [dry-run] ${p.planflowPath} exists — leave untouched (use --force to overwrite)`);
412
+ } else {
413
+ log(` kept existing: ${p.planflowPath} (use --force to overwrite with CLI flags)`);
414
+ }
415
+ } else if (exists && args.force) {
416
+ let existing = null;
417
+ try {
418
+ existing = JSON.parse(readFileSync(p.planflowPath, "utf8"));
419
+ } catch (parseErr) {
420
+ warn(`existing planflow.json is not valid JSON (${parseErr?.message || parseErr}) — rebuilding from defaults + CLI flags`);
421
+ }
422
+ const cfg = buildPlanflowConfig(args, existing);
423
+ if (args.dryRun) {
424
+ log(` [dry-run] would backup+overwrite ${p.planflowPath} (force)`);
425
+ } else {
426
+ const bak = backupFile(p.planflowPath);
427
+ if (bak) log(` backup: ${bak}`);
428
+ mkdirSync(path.dirname(p.planflowPath), { recursive: true });
429
+ writeFileSync(p.planflowPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
430
+ log(` wrote: ${p.planflowPath} (force overwrite)`);
431
+ }
432
+ } else {
433
+ const cfg = buildPlanflowConfig(args, null);
434
+ if (args.dryRun) {
435
+ log(` [dry-run] would write default planflow.json -> ${p.planflowPath}`);
436
+ } else {
437
+ mkdirSync(path.dirname(p.planflowPath), { recursive: true });
438
+ writeFileSync(p.planflowPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
439
+ log(` wrote: ${p.planflowPath}`);
440
+ }
441
+ }
442
+
443
+ // --- 8: opencode.json plugin registration ---
444
+ log(cyan("opencode.json:"));
445
+ mergePluginRegistration(p, "add", args.dryRun);
446
+ } else {
447
+ // --- 9: uninstall ---
448
+ log(cyan("Commands:"));
449
+ for (const name of COMMAND_FILES) {
450
+ removeIfExists(path.join(p.commandDir, name), args.dryRun, p.allowedRoots);
451
+ }
452
+ log(cyan("Skill:"));
453
+ const skillDir = path.dirname(p.skillDest);
454
+ removeIfExists(skillDir, args.dryRun, p.allowedRoots, "plan-workflow skill");
455
+ log(cyan("Plugin:"));
456
+ for (const name of PLUGIN_FILES) {
457
+ removeIfExists(path.join(p.pluginDir, name), args.dryRun, p.allowedRoots);
458
+ }
459
+ log(cyan("opencode.json:"));
460
+ mergePluginRegistration(p, "remove", args.dryRun);
461
+ log(cyan("Config:"));
462
+ if (args.force) {
463
+ removeIfExists(p.planflowPath, args.dryRun, p.allowedRoots, "planflow.json (--force)");
464
+ } else if (existsSync(p.planflowPath)) {
465
+ log(` kept: ${p.planflowPath} (use --force to delete)`);
466
+ } else {
467
+ log(` (absent) ${p.planflowPath}`);
468
+ }
469
+ log("");
470
+ log(bold("What remains after uninstall:"));
471
+ log(` - ${p.planflowPath} (unless --force) — user webhook tokens/config`);
472
+ log(` - ${p.opencodeJson} — other plugins (oh-my-opencode-slim etc.) preserved`);
473
+ log(` - project .plans/ directories — not touched by the installer`);
474
+ }
475
+
476
+ if (args.dryRun) {
477
+ log("");
478
+ log(green("Dry-run complete — nothing was written."));
479
+ return 0;
480
+ }
481
+ if (args.uninstall) {
482
+ log("");
483
+ log(green("Uninstall complete. Restart OpenCode to drop the plugin."));
484
+ return 0;
485
+ }
486
+ printNextSteps(p);
487
+ return 0;
488
+ }
489
+
490
+ function mergePluginRegistration(p, mode, dryRun) {
491
+ if (!existsSync(p.opencodeJson)) {
492
+ if (mode === "add") {
493
+ const data = { plugin: ["planflow"] };
494
+ if (dryRun) {
495
+ log(` [dry-run] would create ${p.opencodeJson} with plugin: ["planflow"]`);
496
+ } else {
497
+ mkdirSync(path.dirname(p.opencodeJson), { recursive: true });
498
+ writeFileSync(p.opencodeJson, JSON.stringify(data, null, 2) + "\n", "utf8");
499
+ log(` created: ${p.opencodeJson} (plugin: ["planflow"])`);
500
+ }
501
+ } else {
502
+ log(` (absent) ${p.opencodeJson}`);
503
+ }
504
+ return;
505
+ }
506
+ const parsed = readOpencodeJson(p.opencodeJson);
507
+ if (!parsed.ok) {
508
+ warn(`could not parse ${p.opencodeJson}: ${parsed.error}`);
509
+ warn("skipping plugin registration merge. Fix the JSON manually:");
510
+ warn(` - add "planflow" to the "plugin" array, or remove it`);
511
+ warn(` - example: "plugin": ["oh-my-opencode-slim", "planflow"]`);
512
+ warn(` - after editing, restart OpenCode`);
513
+ return;
514
+ }
515
+ if (parsed.jsonc) {
516
+ warn(`${p.opencodeJson} contained JSONC — parsed via tolerant strip; output will be normalized JSON`);
517
+ }
518
+ const data = parsed.data;
519
+ const { changed, invalid } = ensurePluginArray(data, mode);
520
+ if (invalid) {
521
+ warn(`opencode.json has a non-array "plugin" key — skipping merge`);
522
+ warn(` set "plugin": ["planflow", ...] manually in ${p.opencodeJson}`);
523
+ return;
524
+ }
525
+ if (!changed) {
526
+ log(` no change needed (plugin already ${mode === "add" ? "registered" : "absent"}): ${p.opencodeJson}`);
527
+ return;
528
+ }
529
+ writeOpencodeJson(p.opencodeJson, data, dryRun);
530
+ }
531
+
532
+ function main() {
533
+ let args;
534
+ try {
535
+ args = parseArgs(process.argv.slice(2));
536
+ } catch (e) {
537
+ err(e?.message || String(e));
538
+ usage();
539
+ process.exit(1);
540
+ return;
541
+ }
542
+ if (args.help) {
543
+ usage();
544
+ process.exit(0);
545
+ return;
546
+ }
547
+ try {
548
+ const p = resolvePaths(args);
549
+ const code = runInstall(args, p);
550
+ process.exit(code);
551
+ } catch (e) {
552
+ err(e?.message || String(e));
553
+ process.exit(1);
554
+ }
555
+ }
556
+
557
+ main();
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "omo-slim-plan",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight plan-first workflow for OpenCode + oh-my-opencode-slim: plan → human gate → start-work → acceptance",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Daedalusys/omo-slim-plan.git"
8
+ },
9
+ "homepage": "https://github.com/Daedalusys/omo-slim-plan#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/Daedalusys/omo-slim-plan/issues"
12
+ },
13
+ "type": "module",
14
+ "license": "MIT",
15
+ "bin": { "omo-slim-plan": "bin/install.js" },
16
+ "files": ["bin", "templates", "plugin", "LICENSE", "README.md"],
17
+ "engines": { "node": ">=18" },
18
+ "keywords": ["opencode", "oh-my-opencode-slim", "plan", "workflow", "omo-slim"]
19
+ }