oc-go-usage-display 1.1.0 → 2.0.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.
Files changed (42) hide show
  1. package/README.md +245 -93
  2. package/bin/lib.js +395 -37
  3. package/bin/oc-go-usage-display-init.js +42 -13
  4. package/bin/oc-go-usage-display-remove.js +22 -17
  5. package/bin/oc-go-usage-display-show.js +47 -39
  6. package/bin/oc-go-usage-display-status.js +31 -52
  7. package/bin/oc-go-usage-display-update.js +27 -14
  8. package/dist/helpers.d.ts +116 -0
  9. package/dist/helpers.d.ts.map +1 -0
  10. package/dist/helpers.js +568 -0
  11. package/dist/helpers.js.map +1 -0
  12. package/dist/index.d.ts +5 -13
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +249 -159
  15. package/dist/index.js.map +1 -1
  16. package/dist/plugins/oc-go-usage-display.kilo.ts +678 -0
  17. package/dist/plugins/oc-go-usage-display.kilo.tsx +1272 -0
  18. package/dist/plugins/oc-go-usage-display.ts +678 -0
  19. package/dist/plugins/oc-go-usage-display.tsx +1018 -0
  20. package/dist/shared.d.ts +75 -7
  21. package/dist/shared.d.ts.map +1 -1
  22. package/dist/shared.js +448 -25
  23. package/dist/shared.js.map +1 -1
  24. package/dist/tui-shared.d.ts +109 -0
  25. package/dist/tui-shared.d.ts.map +1 -0
  26. package/dist/tui-shared.js +372 -0
  27. package/dist/tui-shared.js.map +1 -0
  28. package/dist/tui.d.ts +0 -17
  29. package/dist/tui.d.ts.map +1 -1
  30. package/dist/tui.js +229 -282
  31. package/dist/tui.js.map +1 -1
  32. package/dist/tui.kilo.d.ts +8 -0
  33. package/dist/tui.kilo.d.ts.map +1 -0
  34. package/dist/tui.kilo.js +502 -0
  35. package/dist/tui.kilo.js.map +1 -0
  36. package/package.json +46 -14
  37. package/src/helpers.ts +733 -0
  38. package/src/index.ts +316 -175
  39. package/src/shared.ts +545 -28
  40. package/src/tui-shared.tsx +705 -0
  41. package/src/tui.kilo.tsx +816 -0
  42. package/src/tui.tsx +352 -366
package/bin/lib.js CHANGED
@@ -12,10 +12,87 @@ export const TUI_PLUGIN_REL = "./plugins/oc-go-usage-display.tsx";
12
12
  export const SERVER_FILE_NAME = "oc-go-usage-display.ts";
13
13
  export const TUI_FILE_NAME = "oc-go-usage-display.tsx";
14
14
 
15
+ // Supported coding-agent hosts. Both ship the same source; the Kilo bundles are
16
+ // separate build outputs (host-specific auth roots) with `.kilo` filenames.
17
+ export const HOSTS = ["opencode", "kilo"];
18
+
19
+ // Per-host install layout. Kilo is a fork with its own config root
20
+ // ($KILO_CONFIG_DIR / $XDG_CONFIG_HOME/kilo), its own bundle filenames, and a
21
+ // tui.json schema that rejects `sidebar`/`statusline` (it would skip the whole
22
+ // file), so its TUI entry is registered without options.
23
+ const HOST_LAYOUTS = {
24
+ opencode: {
25
+ serverFile: "oc-go-usage-display.ts",
26
+ tuiFile: "oc-go-usage-display.tsx",
27
+ serverRel: "./plugins/oc-go-usage-display.ts",
28
+ tuiRel: "./plugins/oc-go-usage-display.tsx",
29
+ serverConfigFile: "opencode.jsonc",
30
+ tuiOptions: true,
31
+ },
32
+ kilo: {
33
+ serverFile: "oc-go-usage-display.kilo.ts",
34
+ tuiFile: "oc-go-usage-display.kilo.tsx",
35
+ serverRel: "./plugins/oc-go-usage-display.kilo.ts",
36
+ tuiRel: "./plugins/oc-go-usage-display.kilo.tsx",
37
+ serverConfigFile: "kilo.json",
38
+ tuiOptions: false,
39
+ },
40
+ };
41
+
42
+ export function hostLayout(host) {
43
+ const layout = HOST_LAYOUTS[host];
44
+ if (layout === undefined) fail(`unknown target: ${host} (expected ${HOSTS.join("/")})`);
45
+ return layout;
46
+ }
47
+
48
+ // Plugin spec written into the host config. opencode resolves `./...` against
49
+ // its config dir (verified); Kilo does NOT (relative specs resolve against the
50
+ // project dir), so Kilo entries are absolute paths to the installed copy.
51
+ export function pluginSpec(host, configDir, kind) {
52
+ const layout = hostLayout(host);
53
+ const fileName = kind === "server" ? layout.serverFile : layout.tuiFile;
54
+ if (host === "kilo") return path.join(configDir, "plugins", fileName);
55
+ return kind === "server" ? layout.serverRel : layout.tuiRel;
56
+ }
57
+
15
58
  export function fail(message) {
59
+ if (message.startsWith("oc-go-usage-display: ")) throw new Error(message);
16
60
  throw new Error(`oc-go-usage-display: ${message}`);
17
61
  }
18
62
 
63
+ // Format any thrown value as the single line the bin commands report, with the
64
+ // `oc-go-usage-display: ` prefix applied exactly once. Newlines and other
65
+ // whitespace runs collapse to single spaces so the one-line contract holds for
66
+ // multi-line messages (e.g. a wrapped npm error). Pure and never throws.
67
+ export function cliErrorMessage(error) {
68
+ let raw;
69
+ if (error instanceof Error) {
70
+ raw = error.message;
71
+ } else {
72
+ try {
73
+ raw = String(error);
74
+ } catch {
75
+ return "oc-go-usage-display: unknown error";
76
+ }
77
+ }
78
+ const message = raw.replace(/\s+/g, " ").trim();
79
+ if (message.length === 0) return "oc-go-usage-display: unknown error";
80
+ if (message.startsWith("oc-go-usage-display: ")) return message;
81
+ return `oc-go-usage-display: ${message}`;
82
+ }
83
+
84
+ // Top-level error boundary shared by every bin: one clean line on stderr (no
85
+ // stack trace, no rethrow) and exit 1. The synchronous fd write keeps the
86
+ // message from being truncated by `process.exit` when stderr is a pipe (npx).
87
+ export function exitWithError(error) {
88
+ try {
89
+ fs.writeSync(process.stderr.fd, `${cliErrorMessage(error)}\n`);
90
+ } catch {
91
+ // No writable stderr: the exit code still carries the failure.
92
+ }
93
+ process.exit(1);
94
+ }
95
+
19
96
  export function repoDirFromArgv(argv) {
20
97
  const flagValue = readFlag(argv, "--repo");
21
98
  if (flagValue !== null) return path.resolve(flagValue);
@@ -28,13 +105,155 @@ export function openCodeDirFromArgv(argv) {
28
105
  if (flagValue !== null) return path.resolve(flagValue);
29
106
  const envValue = toNonEmptyString(process.env.OPENCODE_CONFIG_DIR);
30
107
  if (envValue !== null) return path.resolve(envValue);
31
- return path.join(os.homedir(), ".config", "opencode");
108
+ return path.join(process.env.HOME || os.homedir(), ".config", "opencode");
109
+ }
110
+
111
+ export function kiloDirFromArgv(argv) {
112
+ const flagValue = readFlag(argv, "--kilo-config-dir");
113
+ if (flagValue !== null) return path.resolve(flagValue);
114
+ const envValue = toNonEmptyString(process.env.KILO_CONFIG_DIR);
115
+ if (envValue !== null) return path.resolve(envValue);
116
+ return path.join(xdgHome("XDG_CONFIG_HOME", [".config"]), "kilo");
117
+ }
118
+
119
+ export function hostDirFromArgv(host, argv) {
120
+ return host === "kilo" ? kiloDirFromArgv(argv) : openCodeDirFromArgv(argv);
121
+ }
122
+
123
+ function xdgHome(key, fallbackSegments) {
124
+ const envValue = toNonEmptyString(process.env[key]);
125
+ if (envValue !== null) return envValue;
126
+ return path.join(process.env.HOME || os.homedir(), ...fallbackSegments);
127
+ }
128
+
129
+ // Data dir the host stores auth.json in (never written here, detection only).
130
+ function hostDataDir(host) {
131
+ return path.join(xdgHome("XDG_DATA_HOME", [".local", "share"]), host);
132
+ }
133
+
134
+ // PATH lookup without spawning: covers brew/npm/curl/source installs that put
135
+ // the binary on PATH. Never throws.
136
+ function executableOnPath(name) {
137
+ const pathValue = toNonEmptyString(process.env.PATH);
138
+ if (pathValue === null) return false;
139
+ const names = process.platform === "win32" ? [name, `${name}.exe`, `${name}.cmd`] : [name];
140
+ for (const dir of pathValue.split(path.delimiter)) {
141
+ if (dir.length === 0) continue;
142
+ for (const candidate of names) {
143
+ try {
144
+ if (fs.statSync(path.join(dir, candidate)).isFile()) return true;
145
+ } catch {
146
+ // Missing entries are the normal case while scanning PATH.
147
+ }
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
153
+ // Best-effort host detection; never fails an install. A host counts as
154
+ // installed when its binary is on PATH or its config/data dir exists, which
155
+ // covers every install flavor (brew, npm, curl, source checkout, portable).
156
+ export function detectHosts() {
157
+ try {
158
+ return {
159
+ opencode:
160
+ executableOnPath("opencode") ||
161
+ fs.existsSync(openCodeDirFromArgv([])) ||
162
+ fs.existsSync(hostDataDir("opencode")),
163
+ kilo:
164
+ executableOnPath("kilo") ||
165
+ fs.existsSync(kiloDirFromArgv([])) ||
166
+ fs.existsSync(hostDataDir("kilo")),
167
+ };
168
+ } catch {
169
+ return { opencode: false, kilo: false };
170
+ }
171
+ }
172
+
173
+ export function parseTargetList(value) {
174
+ const normalized = value.trim().toLowerCase();
175
+ if (normalized === "all" || normalized === "both") return [...HOSTS];
176
+ const targets = normalized.split(/[\s,]+/).filter((entry) => entry.length > 0);
177
+ if (targets.length === 0) fail("--target requires opencode, kilo, or all");
178
+ for (const target of targets) {
179
+ if (target !== "opencode" && target !== "kilo") fail(`unknown target: ${target} (expected opencode/kilo/all)`);
180
+ }
181
+ return [...new Set(targets)];
182
+ }
183
+
184
+ // Parse a numbered multiselect answer ("1,2", "all", "" for the default).
185
+ export function parseTargetChoice(input, choices) {
186
+ const trimmed = input.trim().toLowerCase();
187
+ if (trimmed.length === 0 || trimmed === "all" || trimmed === "both") return [...choices];
188
+ const selected = [];
189
+ for (const token of trimmed.split(/[\s,]+/).filter((entry) => entry.length > 0)) {
190
+ const index = Number.parseInt(token, 10);
191
+ if (!Number.isInteger(index) || index < 1 || index > choices.length || String(index) !== token) {
192
+ fail(`invalid selection "${token}" (expected ${choices.map((_, i) => i + 1).join("/")} or Enter)`);
193
+ }
194
+ selected.push(choices[index - 1]);
195
+ }
196
+ return selected.length > 0 ? [...new Set(selected)] : [...choices];
197
+ }
198
+
199
+ // Targets for init/update. Explicit --target or a per-host --config-dir wins;
200
+ // otherwise one detected host installs silently, two or more offer the
201
+ // multiselect, and zero (or a non-interactive shell) defaults to all — a
202
+ // missing binary must never fail the install.
203
+ export function selectTargets(
204
+ argv,
205
+ { interactive = process.stdin.isTTY === true && process.stdout.isTTY === true } = {},
206
+ ) {
207
+ const targets = explicitTargets(argv);
208
+ if (targets !== null) return targets;
209
+ const detected = detectHosts();
210
+ const installed = HOSTS.filter((host) => detected[host]);
211
+ if (installed.length === 1) return installed;
212
+ if (installed.length > 1 && interactive) return promptTargets(installed);
213
+ return [...HOSTS];
214
+ }
215
+
216
+ // Removal never prompts: explicit flags pick hosts, otherwise both are visited
217
+ // (removing from a host without an install is a no-op).
218
+ export function resolveRemoveTargets(argv) {
219
+ return explicitTargets(argv) ?? [...HOSTS];
220
+ }
221
+
222
+ // Status checks only the hosts that have something installed unless the caller
223
+ // asked for specific ones.
224
+ export function resolveStatusTargets(argv) {
225
+ return explicitTargets(argv);
226
+ }
227
+
228
+ function explicitTargets(argv) {
229
+ const explicit = readFlag(argv, "--target");
230
+ if (explicit !== null) return parseTargetList(explicit);
231
+ const dirTargets = [];
232
+ if (readFlag(argv, "--config-dir") !== null) dirTargets.push("opencode");
233
+ if (readFlag(argv, "--kilo-config-dir") !== null) dirTargets.push("kilo");
234
+ return dirTargets.length > 0 ? dirTargets : null;
235
+ }
236
+
237
+ function promptTargets(installed) {
238
+ process.stdout.write(
239
+ `Detected coding agents: ${installed.map((host, index) => `${index + 1}) ${host}`).join(" ")}\n`,
240
+ );
241
+ process.stdout.write(`Install for [${installed.map((_, index) => index + 1).join(",")}] (Enter = all): `);
242
+ let input = "";
243
+ try {
244
+ const buffer = Buffer.alloc(256);
245
+ const bytes = fs.readSync(process.stdin.fd, buffer, 0, buffer.length, null);
246
+ input = buffer.toString("utf8", 0, bytes);
247
+ } catch {
248
+ return [...installed];
249
+ }
250
+ return parseTargetChoice(input, installed);
32
251
  }
33
252
 
34
253
  export function linkModeFromArgv(argv) {
35
- if (argv.includes("--copy")) return "copy";
36
254
  if (argv.includes("--symlink")) return "symlink";
37
- return "symlink";
255
+ if (argv.includes("--copy")) return "copy";
256
+ return "copy";
38
257
  }
39
258
 
40
259
  export function readFlag(argv, name) {
@@ -146,17 +365,24 @@ export function writeJsonFile(filePath, value) {
146
365
  fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
147
366
  }
148
367
 
149
- export function linkPluginFiles(repoDir, configDir, mode) {
368
+ export function linkPluginFiles(repoDir, configDir, mode, host = "opencode") {
369
+ const layout = hostLayout(host);
150
370
  const pluginsDir = path.join(configDir, "plugins");
151
371
  fs.mkdirSync(pluginsDir, { recursive: true });
372
+ // Bundled self-contained outputs (no `./shared` import, no shared file):
373
+ // `bun run build` produces dist/plugins/*, which is what gets installed.
152
374
  const pairs = [
153
- [path.join(repoDir, "src", "index.ts"), path.join(pluginsDir, SERVER_FILE_NAME)],
154
- [path.join(repoDir, "src", "tui.tsx"), path.join(pluginsDir, TUI_FILE_NAME)],
375
+ [path.join(repoDir, "dist", "plugins", layout.serverFile), path.join(pluginsDir, layout.serverFile)],
376
+ [path.join(repoDir, "dist", "plugins", layout.tuiFile), path.join(pluginsDir, layout.tuiFile)],
155
377
  ];
156
378
  for (const [source, target] of pairs) {
157
- if (!fs.existsSync(source)) fail(`repo source missing: ${source}`);
379
+ if (!fs.existsSync(source)) fail(`repo bundle missing: ${source} (run bun run build)`);
158
380
  if (mode === "copy") {
159
381
  const stat = safeLstat(target);
382
+ if (stat?.isDirectory()) {
383
+ // copyFileSync would fail with a raw EISDIR; fail fast with the fix.
384
+ fail(`cannot replace a directory with the plugin file: ${target} (remove it, then re-run)`);
385
+ }
160
386
  if (stat?.isSymbolicLink() || stat?.isFile()) fs.rmSync(target, { force: true });
161
387
  fs.copyFileSync(source, target);
162
388
  continue;
@@ -192,25 +418,39 @@ export function describeLink(target) {
192
418
  return { state: "other", detail: null };
193
419
  }
194
420
 
195
- export function ensureServerEntry(configDir, { create = true } = {}) {
196
- const filePath = path.join(configDir, "opencode.jsonc");
421
+ export function isDanglingSymlink(target) {
422
+ const stat = safeLstat(target);
423
+ if (stat === null || !stat.isSymbolicLink()) return false;
424
+ try {
425
+ fs.statSync(target);
426
+ return false;
427
+ } catch {
428
+ return true;
429
+ }
430
+ }
431
+
432
+ export function ensureServerEntry(configDir, { create = true, host = "opencode" } = {}) {
433
+ const layout = hostLayout(host);
434
+ const spec = pluginSpec(host, configDir, "server");
435
+ const filePath = path.join(configDir, layout.serverConfigFile);
197
436
  const { found, value } = readJsonFile(filePath);
198
437
  if (!found) {
199
438
  if (!create) return { changed: false, present: false };
200
- writeJsonFile(filePath, { plugin: [SERVER_PLUGIN_REL] });
439
+ writeJsonFile(filePath, { plugin: [spec] });
201
440
  return { changed: true, present: true };
202
441
  }
203
- if (!isRecord(value)) fail(`opencode.jsonc is not an object: ${filePath}`);
442
+ if (!isRecord(value)) fail(`${layout.serverConfigFile} is not an object: ${filePath}`);
204
443
  const plugins = Array.isArray(value.plugin) ? value.plugin : [];
205
- if (plugins.includes(SERVER_PLUGIN_REL)) {
444
+ if (plugins.includes(spec)) {
206
445
  return { changed: false, present: true };
207
446
  }
208
- value.plugin = [...plugins, SERVER_PLUGIN_REL];
447
+ value.plugin = [...plugins, spec];
209
448
  writeJsonFile(filePath, value);
210
449
  return { changed: true, present: true };
211
450
  }
212
451
 
213
- export function readTuiSelection(configDir) {
452
+ export function readTuiSelection(configDir, host = "opencode") {
453
+ const spec = pluginSpec(host, configDir, "tui");
214
454
  const filePath = path.join(configDir, "tui.json");
215
455
  const { found, value } = readJsonFile(filePath);
216
456
  if (!found) return { found: false, entry: false, sidebar: null, statusline: null };
@@ -218,7 +458,7 @@ export function readTuiSelection(configDir) {
218
458
  for (const entry of plugins) {
219
459
  const normalized = normalizeTuiEntry(entry);
220
460
  if (normalized === null) continue;
221
- if (normalized.spec !== TUI_PLUGIN_REL) continue;
461
+ if (normalized.spec !== spec) continue;
222
462
  return { found: true, entry: true, sidebar: normalized.sidebar, statusline: normalized.statusline };
223
463
  }
224
464
  return { found: true, entry: false, sidebar: null, statusline: null };
@@ -235,15 +475,33 @@ export function normalizeTuiEntry(entry) {
235
475
  };
236
476
  }
237
477
 
238
- export function ensureTuiEntry(configDir, { sidebar = null, statusline = null, create = true } = {}) {
478
+ export function ensureTuiEntry(
479
+ configDir,
480
+ { sidebar = null, statusline = null, create = true, host = "opencode" } = {},
481
+ ) {
482
+ const layout = hostLayout(host);
483
+ if (!layout.tuiOptions && (sidebar !== null || statusline !== null)) {
484
+ fail(`--sidebar/--statusline do not apply to ${host} (its tui.json rejects those options)`);
485
+ }
486
+ const spec = pluginSpec(host, configDir, "tui");
239
487
  const filePath = path.join(configDir, "tui.json");
240
488
  const { found, value } = readJsonFile(filePath);
241
489
  const sidebarNext = sidebar ?? true;
242
490
  const statuslineNext = statusline ?? true;
491
+ // Kilo rejects `sidebar`/`statusline` and skips the whole file, so its entry
492
+ // stays a plain plugin spec without options.
493
+ const desiredEntry = layout.tuiOptions
494
+ ? [spec, { sidebar: sidebarNext, statusline: statuslineNext }]
495
+ : spec;
243
496
  if (!found) {
244
- if (!create) return { changed: false, sidebar: null, statusline: null };
245
- writeJsonFile(filePath, { plugin: [[TUI_PLUGIN_REL, { sidebar: sidebarNext, statusline: statuslineNext }]] });
246
- return { changed: true, sidebar: sidebarNext, statusline: statuslineNext };
497
+ if (!create) return { changed: false, entry: false, sidebar: null, statusline: null };
498
+ writeJsonFile(filePath, { plugin: [desiredEntry] });
499
+ return {
500
+ changed: true,
501
+ entry: true,
502
+ sidebar: layout.tuiOptions ? sidebarNext : null,
503
+ statusline: layout.tuiOptions ? statuslineNext : null,
504
+ };
247
505
  }
248
506
  if (!isRecord(value)) fail(`tui.json is not an object: ${filePath}`);
249
507
  const plugins = Array.isArray(value.plugin) ? value.plugin : [];
@@ -252,12 +510,16 @@ export function ensureTuiEntry(configDir, { sidebar = null, statusline = null, c
252
510
  const next = plugins.map((entry) => {
253
511
  const normalized = normalizeTuiEntry(entry);
254
512
  if (normalized === null) return entry;
255
- if (normalized.spec !== TUI_PLUGIN_REL) return entry;
513
+ if (normalized.spec !== spec) return entry;
256
514
  seen = true;
515
+ if (!layout.tuiOptions) {
516
+ if (typeof entry === "string") return entry;
517
+ changed = true;
518
+ return spec;
519
+ }
257
520
  const sidebarFinal = sidebar ?? normalized.sidebar ?? true;
258
521
  const statuslineFinal = statusline ?? normalized.statusline ?? true;
259
522
  if (
260
- normalized.spec === TUI_PLUGIN_REL &&
261
523
  Array.isArray(entry) &&
262
524
  isRecord(entry[1]) &&
263
525
  entry[1].sidebar === sidebarFinal &&
@@ -266,25 +528,25 @@ export function ensureTuiEntry(configDir, { sidebar = null, statusline = null, c
266
528
  return entry;
267
529
  }
268
530
  changed = true;
269
- return [TUI_PLUGIN_REL, { sidebar: sidebarFinal, statusline: statuslineFinal }];
531
+ return [spec, { sidebar: sidebarFinal, statusline: statuslineFinal }];
270
532
  });
271
533
  if (!seen) {
272
- next.push([TUI_PLUGIN_REL, { sidebar: sidebarNext, statusline: statuslineNext }]);
534
+ next.push(desiredEntry);
273
535
  changed = true;
274
536
  }
275
537
  if (changed) {
276
538
  value.plugin = next;
277
539
  writeJsonFile(filePath, value);
278
540
  }
279
- const selection = readTuiSelection(configDir);
280
- return { changed, sidebar: selection.sidebar, statusline: selection.statusline };
541
+ const selection = readTuiSelection(configDir, host);
542
+ return { changed, entry: selection.entry, sidebar: selection.sidebar, statusline: selection.statusline };
281
543
  }
282
544
 
283
545
  export function secretPresence(configDir) {
284
546
  const candidates = [
285
- process.env.OPENCODE_GO_API_KEY,
286
- process.env.OPENCODE_GO_AUTH_COOKIE,
287
- process.env.OPENCODE_GO_WORKSPACE_ID,
547
+ process.env.OPENCODE_OC_GO_API_KEY,
548
+ process.env.OPENCODE_OC_GO_AUTH_COOKIE,
549
+ process.env.OPENCODE_OC_GO_WORKSPACE_ID,
288
550
  ];
289
551
  const envPresent = candidates.some((value) => toNonEmptyString(value) !== null);
290
552
  const fileConfig = path.join(configDir, "oc-go-usage-display.json");
@@ -294,10 +556,11 @@ export function secretPresence(configDir) {
294
556
  };
295
557
  }
296
558
 
297
- export function removePluginFiles(configDir) {
559
+ export function removePluginFiles(configDir, host = "opencode") {
560
+ const layout = hostLayout(host);
298
561
  const removed = [];
299
562
  const kept = [];
300
- for (const fileName of [SERVER_FILE_NAME, TUI_FILE_NAME]) {
563
+ for (const fileName of [layout.serverFile, layout.tuiFile]) {
301
564
  const target = path.join(configDir, "plugins", fileName);
302
565
  const stat = safeLstat(target);
303
566
  if (stat === null) continue;
@@ -311,19 +574,22 @@ export function removePluginFiles(configDir) {
311
574
  return { removed, kept };
312
575
  }
313
576
 
314
- export function removeServerEntry(configDir) {
315
- const filePath = path.join(configDir, "opencode.jsonc");
577
+ export function removeServerEntry(configDir, { host = "opencode" } = {}) {
578
+ const layout = hostLayout(host);
579
+ const spec = pluginSpec(host, configDir, "server");
580
+ const filePath = path.join(configDir, layout.serverConfigFile);
316
581
  const { found, value } = readJsonFile(filePath);
317
582
  if (!found) return { changed: false, present: false };
318
- if (!isRecord(value)) fail(`opencode.jsonc is not an object: ${filePath}`);
583
+ if (!isRecord(value)) fail(`${layout.serverConfigFile} is not an object: ${filePath}`);
319
584
  const plugins = Array.isArray(value.plugin) ? value.plugin : [];
320
- if (!plugins.includes(SERVER_PLUGIN_REL)) return { changed: false, present: false };
321
- value.plugin = plugins.filter((entry) => entry !== SERVER_PLUGIN_REL);
585
+ if (!plugins.includes(spec)) return { changed: false, present: false };
586
+ value.plugin = plugins.filter((entry) => entry !== spec);
322
587
  writeJsonFile(filePath, value);
323
588
  return { changed: true, present: false };
324
589
  }
325
590
 
326
- export function removeTuiEntry(configDir) {
591
+ export function removeTuiEntry(configDir, host = "opencode") {
592
+ const spec = pluginSpec(host, configDir, "tui");
327
593
  const filePath = path.join(configDir, "tui.json");
328
594
  const { found, value } = readJsonFile(filePath);
329
595
  if (!found) return { changed: false, present: false };
@@ -332,10 +598,102 @@ export function removeTuiEntry(configDir) {
332
598
  const next = plugins.filter((entry) => {
333
599
  const normalized = normalizeTuiEntry(entry);
334
600
  if (normalized === null) return true;
335
- return normalized.spec !== TUI_PLUGIN_REL;
601
+ return normalized.spec !== spec;
336
602
  });
337
603
  if (next.length === plugins.length) return { changed: false, present: false };
338
604
  value.plugin = next;
339
605
  writeJsonFile(filePath, value);
340
606
  return { changed: true, present: false };
341
607
  }
608
+
609
+ // Effective install report for one host (show/status share this so the two
610
+ // commands can never disagree about what "installed" means).
611
+ export function describeHostInstall(host, configDir) {
612
+ const layout = hostLayout(host);
613
+ const serverSpec = pluginSpec(host, configDir, "server");
614
+ const tuiSpec = pluginSpec(host, configDir, "tui");
615
+ const pluginsDir = path.join(configDir, "plugins");
616
+ const serverLink = describeLink(path.join(pluginsDir, layout.serverFile));
617
+ const tuiLink = describeLink(path.join(pluginsDir, layout.tuiFile));
618
+ const serverConfig = readJsonFile(path.join(configDir, layout.serverConfigFile));
619
+ const serverEntry =
620
+ serverConfig.found &&
621
+ isRecord(serverConfig.value) &&
622
+ Array.isArray(serverConfig.value.plugin) &&
623
+ serverConfig.value.plugin.includes(serverSpec);
624
+ const tui = readTuiSelection(configDir, host);
625
+ return {
626
+ configDir,
627
+ server: { expected: serverSpec, entry: serverEntry, ...serverLink },
628
+ tui: {
629
+ expected: tuiSpec,
630
+ entry: tui.entry,
631
+ sidebar: tui.sidebar,
632
+ statusline: tui.statusline,
633
+ ...tuiLink,
634
+ },
635
+ };
636
+ }
637
+
638
+ // Health check for one host: symlinks must point at this repo, copy installs
639
+ // are accepted with a note, config entries must exist (toggles only where the
640
+ // host supports them).
641
+ export function checkHostInstall(host, configDir, repoDir) {
642
+ const layout = hostLayout(host);
643
+ const problems = [];
644
+ const notes = [];
645
+ const expectedServer = path.join(repoDir, "dist", "plugins", layout.serverFile);
646
+ const expectedTui = path.join(repoDir, "dist", "plugins", layout.tuiFile);
647
+ const serverPath = path.join(configDir, "plugins", layout.serverFile);
648
+ const tuiPath = path.join(configDir, "plugins", layout.tuiFile);
649
+
650
+ // Copy installs intentionally diverge, so a stale copy stays NOTE (never
651
+ // FAIL): users who pin a copy must not break on every repo update.
652
+ function copyMatchesRepo(installedPath, repoPath) {
653
+ let installed;
654
+ let source;
655
+ try {
656
+ installed = fs.readFileSync(installedPath, "utf8");
657
+ source = fs.readFileSync(repoPath, "utf8");
658
+ } catch {
659
+ return false;
660
+ }
661
+ return installed === source;
662
+ }
663
+
664
+ function checkPlugin(label, link, installedPath, expected) {
665
+ if (link.state === "symlink") {
666
+ if (isDanglingSymlink(installedPath)) {
667
+ problems.push(`${label} symlink target missing (dangling): ${link.detail ?? ""} (re-run install or build)`);
668
+ } else if (path.resolve(configDir, "plugins", link.detail ?? "") !== expected) {
669
+ problems.push(`${label} symlink points at ${link.detail ?? ""}, expected ${expected}`);
670
+ }
671
+ } else if (link.state === "file") {
672
+ if (copyMatchesRepo(installedPath, expected)) {
673
+ notes.push(`${label} plugin is a copy install (file, matches repo)`);
674
+ } else {
675
+ notes.push(`${label} copy install differs from repo ${expected}`);
676
+ }
677
+ } else {
678
+ problems.push(`${label} plugin is ${link.state}, expected symlink -> ${expected}`);
679
+ }
680
+ }
681
+
682
+ checkPlugin("server", describeLink(serverPath), serverPath, expectedServer);
683
+ checkPlugin("tui", describeLink(tuiPath), tuiPath, expectedTui);
684
+
685
+ const serverConfig = readJsonFile(path.join(configDir, layout.serverConfigFile));
686
+ const serverEntry =
687
+ serverConfig.found &&
688
+ isRecord(serverConfig.value) &&
689
+ Array.isArray(serverConfig.value.plugin) &&
690
+ serverConfig.value.plugin.includes(pluginSpec(host, configDir, "server"));
691
+ if (!serverEntry) problems.push(`${layout.serverConfigFile} is missing the server plugin entry`);
692
+
693
+ const tui = readTuiSelection(configDir, host);
694
+ if (!tui.entry) problems.push("tui.json is missing the tui plugin entry");
695
+ if (layout.tuiOptions && (tui.sidebar === null || tui.statusline === null)) {
696
+ problems.push(`tui toggles incomplete: sidebar=${tui.sidebar} statusline=${tui.statusline}`);
697
+ }
698
+ return { problems, notes };
699
+ }
@@ -1,35 +1,64 @@
1
1
  #!/usr/bin/env node
2
- // Install (or re-install) the plugin from this repo into ~/.config/opencode:
3
- // symlinks src/* -> plugins/* by default (--copy to copy instead),
4
- // then registers the opencode.jsonc + tui.json entries (toggles preserved).
2
+ // Install (or re-install) the plugin into the detected coding agent(s):
3
+ // copies dist/plugins/* (bundled, self-contained) -> plugins/* by default
4
+ // (--symlink is dev-only: edits apply after restart but dangle if the source
5
+ // tree moves), then registers the server + TUI config entries (toggles
6
+ // preserved for opencode).
7
+ //
8
+ // Targets: --target opencode|kilo|all, or --config-dir / --kilo-config-dir.
9
+ // Without an explicit target the hosts are detected (binary on PATH or config
10
+ // dir present; brew/npm/curl/source installs all count): one host installs
11
+ // silently, two or more offer a numbered multiselect, zero defaults to all —
12
+ // detection never fails the install.
13
+ //
14
+ // Supported flows:
15
+ // npm install oc-go-usage-display@latest && npx oc-go-usage-display-init --copy
16
+ // (or declare "plugin": ["oc-go-usage-display@latest"] in config instead).
17
+ // npx -p oc-go-usage-display@latest oc-go-usage-display-init --copy (one-shot from the package cache)
18
+ // bunx -p oc-go-usage-display@latest oc-go-usage-display-init --copy (same, via Bun)
19
+ // Copy-first needs the published latest+: 1.1.0 symlinks and ignores --copy.
20
+ // Any source path is accepted; --repo only overrides the default repo root.
5
21
 
6
22
  import {
7
23
  ensureServerEntry,
8
24
  ensureTuiEntry,
25
+ exitWithError,
9
26
  fail,
27
+ hostDirFromArgv,
10
28
  linkModeFromArgv,
11
29
  linkPluginFiles,
12
- openCodeDirFromArgv,
13
30
  parseOptionalToggle,
14
31
  repoDirFromArgv,
32
+ selectTargets,
15
33
  } from "./lib.js";
16
34
 
17
35
  try {
18
36
  const argv = process.argv.slice(2);
19
37
  const repoDir = repoDirFromArgv(argv);
20
- const configDir = openCodeDirFromArgv(argv);
21
38
  const mode = linkModeFromArgv(argv);
22
39
  const sidebar = parseOptionalToggle(argv, "--sidebar");
23
40
  const statusline = parseOptionalToggle(argv, "--statusline");
24
-
25
- linkPluginFiles(repoDir, configDir, mode);
26
- const server = ensureServerEntry(configDir);
27
- const tui = ensureTuiEntry(configDir, { sidebar, statusline });
41
+ const targets = selectTargets(argv);
42
+ if (targets.includes("kilo") && (sidebar !== null || statusline !== null)) {
43
+ fail("--sidebar/--statusline do not apply to kilo (its tui.json rejects those options)");
44
+ }
28
45
 
29
46
  console.log(`installed from ${repoDir} (${mode})`);
30
- console.log(`server entry: ${server.present ? "present" : "missing"}`);
31
- console.log(`tui toggles: sidebar=${tui.sidebar} statusline=${tui.statusline}`);
47
+ for (const host of targets) {
48
+ const configDir = hostDirFromArgv(host, argv);
49
+ linkPluginFiles(repoDir, configDir, mode, host);
50
+ const server = ensureServerEntry(configDir, { host });
51
+ const tui = ensureTuiEntry(configDir, { sidebar, statusline, host });
52
+
53
+ console.log(`${host}: ${configDir}`);
54
+ console.log(`${host} server entry: ${server.present ? "present" : "missing"}`);
55
+ if (host === "kilo") {
56
+ console.log(`kilo tui entry: ${tui.entry ? "present" : "missing"} (no toggles: kilo rejects sidebar/statusline)`);
57
+ } else {
58
+ console.log(`tui toggles: sidebar=${tui.sidebar} statusline=${tui.statusline}`);
59
+ }
60
+ }
61
+ console.log("restart the host(s) to pick up the plugin");
32
62
  } catch (error) {
33
- if (error instanceof Error) fail(error.message);
34
- throw error;
63
+ exitWithError(error);
35
64
  }