oc-go-usage-display 1.1.0 → 2.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/README.md +246 -93
- package/bin/lib.js +395 -37
- package/bin/oc-go-usage-display-init.js +42 -13
- package/bin/oc-go-usage-display-remove.js +22 -17
- package/bin/oc-go-usage-display-show.js +47 -39
- package/bin/oc-go-usage-display-status.js +31 -52
- package/bin/oc-go-usage-display-update.js +27 -14
- package/dist/helpers.d.ts +117 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +582 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +5 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +249 -159
- package/dist/index.js.map +1 -1
- package/dist/plugins/oc-go-usage-display.kilo.ts +699 -0
- package/dist/plugins/oc-go-usage-display.kilo.tsx +1364 -0
- package/dist/plugins/oc-go-usage-display.ts +699 -0
- package/dist/plugins/oc-go-usage-display.tsx +1108 -0
- package/dist/shared.d.ts +87 -8
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +596 -31
- package/dist/shared.js.map +1 -1
- package/dist/tui-shared.d.ts +109 -0
- package/dist/tui-shared.d.ts.map +1 -0
- package/dist/tui-shared.js +408 -0
- package/dist/tui-shared.js.map +1 -0
- package/dist/tui.d.ts +0 -17
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +229 -282
- package/dist/tui.js.map +1 -1
- package/dist/tui.kilo.d.ts +8 -0
- package/dist/tui.kilo.d.ts.map +1 -0
- package/dist/tui.kilo.js +502 -0
- package/dist/tui.kilo.js.map +1 -0
- package/package.json +49 -15
- package/src/helpers.ts +749 -0
- package/src/index.ts +316 -175
- package/src/shared.ts +693 -32
- package/src/tui-shared.tsx +755 -0
- package/src/tui.kilo.tsx +817 -0
- 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 "
|
|
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, "
|
|
154
|
-
[path.join(repoDir, "
|
|
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
|
|
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
|
|
196
|
-
const
|
|
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: [
|
|
439
|
+
writeJsonFile(filePath, { plugin: [spec] });
|
|
201
440
|
return { changed: true, present: true };
|
|
202
441
|
}
|
|
203
|
-
if (!isRecord(value)) fail(
|
|
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(
|
|
444
|
+
if (plugins.includes(spec)) {
|
|
206
445
|
return { changed: false, present: true };
|
|
207
446
|
}
|
|
208
|
-
value.plugin = [...plugins,
|
|
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 !==
|
|
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(
|
|
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: [
|
|
246
|
-
return {
|
|
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 !==
|
|
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 [
|
|
531
|
+
return [spec, { sidebar: sidebarFinal, statusline: statuslineFinal }];
|
|
270
532
|
});
|
|
271
533
|
if (!seen) {
|
|
272
|
-
next.push(
|
|
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.
|
|
286
|
-
process.env.
|
|
287
|
-
process.env.
|
|
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 [
|
|
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
|
|
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(
|
|
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(
|
|
321
|
-
value.plugin = plugins.filter((entry) => entry !==
|
|
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 !==
|
|
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
throw error;
|
|
63
|
+
exitWithError(error);
|
|
35
64
|
}
|