oc-go-usage-display 1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oc-go-usage-display contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # oc-go-usage-display
2
+
3
+ OpenCode Go subscription usage plugin (dual target):
4
+
5
+ - **server** (`src/index.ts`): `go_usage` tool (manual query: `Go 5h … | 7d … | 30d …`).
6
+ - **tui** (`src/tui.tsx`): `Go Usage` sidebar block (muted `5h`/`7d`/`30d` rows) + `session_prompt_right` statusline.
7
+
8
+ ![Go Usage sidebar and statusline showing 5h, 7d, and 30d subscription usage](docs/screenshot.png)
9
+
10
+ ## Install (npm)
11
+
12
+ ```sh
13
+ npm install oc-go-usage-display
14
+ npx oc-go-usage-display-init
15
+ ```
16
+
17
+ Or one-shot without installing:
18
+
19
+ ```sh
20
+ npx -y oc-go-usage-display@1.0.0 oc-go-usage-display-init
21
+ ```
22
+
23
+ This links `src/*` into `~/.config/opencode/plugins/*` and registers the
24
+ `opencode.jsonc` + `tui.json` entries. Restart opencode afterwards.
25
+ No secrets are touched. Requires Node >= 22.
26
+
27
+ ## Install (manual plugin entries)
28
+
29
+ If you prefer to wire the plugin by hand (e.g. npm / OpenDock managed
30
+ plugins), add the package to your config and restart opencode:
31
+
32
+ ```jsonc
33
+ // opencode.jsonc — server target (go_usage tool)
34
+ {
35
+ "plugin": ["oc-go-usage-display"]
36
+ }
37
+ ```
38
+
39
+ ```jsonc
40
+ // tui.json — TUI target (Go Usage sidebar + statusline)
41
+ {
42
+ "plugin": [["oc-go-usage-display", { "sidebar": true, "statusline": true }]]
43
+ }
44
+ ```
45
+
46
+ The `oc-plugin: ["server", "tui"]` manifest auto-discovers both targets
47
+ from the published package (`dist/`). Set `OPENCODE_GO_API_KEY` (Bearer for
48
+ `GET https://opencode.ai/zen/go/v1/usage`) or sign in via the `opencode-go`
49
+ provider so `auth.json` supplies the key.
50
+
51
+ ## Install (local repo, easy update)
52
+
53
+ ```sh
54
+ ./install.sh
55
+ ```
56
+
57
+ This symlinks `src/*` into `~/.config/opencode/plugins/*` and registers the
58
+ `opencode.jsonc` + `tui.json` entries. Edits in this repo apply after an
59
+ opencode restart. No secrets are touched.
60
+
61
+ ## Commands
62
+
63
+ ```sh
64
+ node ./bin/oc-go-usage-display-init.js # (re-)install
65
+ node ./bin/oc-go-usage-display-show.js # show effective config (secrets redacted)
66
+ node ./bin/oc-go-usage-display-status.js # health check (exit 0/1)
67
+ node ./bin/oc-go-usage-display-update.js # re-link (+ git pull when a remote exists)
68
+ ```
69
+
70
+ After installing the package, the commands are also available as
71
+ `oc-go-usage-display-init|show|status|update`.
72
+
73
+ ## Display toggles
74
+
75
+ Both surfaces default on and are independent:
76
+
77
+ ```json
78
+ ["./plugins/oc-go-usage-display.tsx", { "sidebar": true, "statusline": true }]
79
+ ```
80
+
81
+ `OPENCODE_GO_SIDEBAR` / `OPENCODE_GO_STATUSLINE` env vars override when the
82
+ toggles are absent. Restart opencode after changing them.
83
+
84
+ ## Build
85
+
86
+ ```sh
87
+ npm install
88
+ npm run build # tsc -> dist/
89
+ npm run check # typecheck only
90
+ ```
package/bin/lib.js ADDED
@@ -0,0 +1,312 @@
1
+ // Shared helpers for the oc-go-usage-display bin commands.
2
+ // Dependency-free (node:fs/os/path only). Secrets are never printed;
3
+ // only their presence is reported.
4
+
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const SERVER_PLUGIN_REL = "./plugins/oc-go-usage-display.ts";
11
+ export const TUI_PLUGIN_REL = "./plugins/oc-go-usage-display.tsx";
12
+ export const SERVER_FILE_NAME = "oc-go-usage-display.ts";
13
+ export const TUI_FILE_NAME = "oc-go-usage-display.tsx";
14
+ // Legacy (pre-rename) paths, removed/migrated on install.
15
+ export const LEGACY_SERVER_PLUGIN_REL = "./plugins/opencode-go-usage.ts";
16
+ export const LEGACY_TUI_PLUGIN_REL = "./plugins/opencode-go-usage.tsx";
17
+ export const LEGACY_SERVER_FILE_NAME = "opencode-go-usage.ts";
18
+ export const LEGACY_TUI_FILE_NAME = "opencode-go-usage.tsx";
19
+
20
+ export function fail(message) {
21
+ throw new Error(`oc-go-usage-display: ${message}`);
22
+ }
23
+
24
+ export function repoDirFromArgv(argv) {
25
+ const flagValue = readFlag(argv, "--repo");
26
+ if (flagValue !== null) return path.resolve(flagValue);
27
+ const here = path.dirname(fileURLToPath(import.meta.url));
28
+ return path.resolve(here, "..");
29
+ }
30
+
31
+ export function openCodeDirFromArgv(argv) {
32
+ const flagValue = readFlag(argv, "--config-dir");
33
+ if (flagValue !== null) return path.resolve(flagValue);
34
+ const envValue = toNonEmptyString(process.env.OPENCODE_CONFIG_DIR);
35
+ if (envValue !== null) return path.resolve(envValue);
36
+ return path.join(os.homedir(), ".config", "opencode");
37
+ }
38
+
39
+ export function linkModeFromArgv(argv) {
40
+ if (argv.includes("--copy")) return "copy";
41
+ if (argv.includes("--symlink")) return "symlink";
42
+ return "symlink";
43
+ }
44
+
45
+ export function readFlag(argv, name) {
46
+ const prefix = `${name}=`;
47
+ for (const arg of argv) {
48
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
49
+ }
50
+ const index = argv.indexOf(name);
51
+ if (index !== -1 && index + 1 < argv.length && !argv[index + 1].startsWith("--")) {
52
+ return argv[index + 1];
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export function parseOptionalToggle(argv, name) {
58
+ const raw = readFlag(argv, name);
59
+ if (raw === null) return null;
60
+ const normalized = raw.trim().toLowerCase();
61
+ if (normalized === "1" || normalized === "true") return true;
62
+ if (normalized === "0" || normalized === "false") return false;
63
+ fail(`invalid value for ${name}: expected 0/1/true/false, got ${JSON.stringify(raw)}`);
64
+ }
65
+
66
+ function toNonEmptyString(value) {
67
+ if (typeof value !== "string") return null;
68
+ const trimmed = value.trim();
69
+ return trimmed.length > 0 ? trimmed : null;
70
+ }
71
+
72
+ function isRecord(value) {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
76
+ function stripJsonComments(text) {
77
+ let out = "";
78
+ let inString = false;
79
+ let escaped = false;
80
+ let lineComment = false;
81
+ let blockComment = false;
82
+ for (let i = 0; i < text.length; i++) {
83
+ const ch = text[i];
84
+ const next = text[i + 1];
85
+ if (lineComment) {
86
+ if (ch === "\n") {
87
+ lineComment = false;
88
+ out += ch;
89
+ }
90
+ continue;
91
+ }
92
+ if (blockComment) {
93
+ if (ch === "*" && next === "/") {
94
+ blockComment = false;
95
+ i++;
96
+ }
97
+ continue;
98
+ }
99
+ if (inString) {
100
+ out += ch;
101
+ if (escaped) escaped = false;
102
+ else if (ch === "\\") escaped = true;
103
+ else if (ch === '"') inString = false;
104
+ continue;
105
+ }
106
+ if (ch === '"') {
107
+ inString = true;
108
+ out += ch;
109
+ continue;
110
+ }
111
+ if (ch === "/" && next === "/") {
112
+ lineComment = true;
113
+ i++;
114
+ continue;
115
+ }
116
+ if (ch === "/" && next === "*") {
117
+ blockComment = true;
118
+ i++;
119
+ continue;
120
+ }
121
+ out += ch;
122
+ }
123
+ return out;
124
+ }
125
+
126
+ export function parseJsonTolerant(text, filePath) {
127
+ try {
128
+ return JSON.parse(text);
129
+ } catch {
130
+ // Fall through to comment-stripped parse (JSONC).
131
+ }
132
+ try {
133
+ return JSON.parse(stripJsonComments(text));
134
+ } catch {
135
+ fail(`cannot parse ${filePath} as JSON/JSONC`);
136
+ }
137
+ }
138
+
139
+ export function readJsonFile(filePath) {
140
+ let raw;
141
+ try {
142
+ raw = fs.readFileSync(filePath, "utf8");
143
+ } catch {
144
+ return { found: false, value: null };
145
+ }
146
+ return { found: true, value: parseJsonTolerant(raw, filePath) };
147
+ }
148
+
149
+ export function writeJsonFile(filePath, value) {
150
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
151
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
152
+ }
153
+
154
+ export function linkPluginFiles(repoDir, configDir, mode) {
155
+ const pluginsDir = path.join(configDir, "plugins");
156
+ fs.mkdirSync(pluginsDir, { recursive: true });
157
+ const pairs = [
158
+ [path.join(repoDir, "src", "index.ts"), path.join(pluginsDir, SERVER_FILE_NAME)],
159
+ [path.join(repoDir, "src", "tui.tsx"), path.join(pluginsDir, TUI_FILE_NAME)],
160
+ ];
161
+ for (const [source, target] of pairs) {
162
+ if (!fs.existsSync(source)) fail(`repo source missing: ${source}`);
163
+ if (mode === "copy") {
164
+ const stat = safeLstat(target);
165
+ if (stat?.isSymbolicLink() || stat?.isFile()) fs.rmSync(target, { force: true });
166
+ fs.copyFileSync(source, target);
167
+ continue;
168
+ }
169
+ const existing = safeReadlink(target);
170
+ if (existing !== null && path.resolve(path.dirname(target), existing) === source) continue;
171
+ fs.rmSync(target, { force: true });
172
+ fs.symlinkSync(source, target);
173
+ }
174
+ // Remove legacy (pre-rename) plugin files left by older installs.
175
+ for (const legacy of [LEGACY_SERVER_FILE_NAME, LEGACY_TUI_FILE_NAME]) {
176
+ if (legacy === SERVER_FILE_NAME || legacy === TUI_FILE_NAME) continue;
177
+ fs.rmSync(path.join(pluginsDir, legacy), { force: true });
178
+ }
179
+ }
180
+
181
+ function safeLstat(target) {
182
+ try {
183
+ return fs.lstatSync(target);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ function safeReadlink(target) {
190
+ try {
191
+ return fs.readlinkSync(target);
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ export function describeLink(target) {
198
+ const stat = safeLstat(target);
199
+ if (stat === null) return { state: "missing", detail: null };
200
+ if (stat.isSymbolicLink()) return { state: "symlink", detail: safeReadlink(target) };
201
+ if (stat.isFile()) return { state: "file", detail: null };
202
+ return { state: "other", detail: null };
203
+ }
204
+
205
+ export function ensureServerEntry(configDir, { create = true } = {}) {
206
+ const filePath = path.join(configDir, "opencode.jsonc");
207
+ const { found, value } = readJsonFile(filePath);
208
+ if (!found) {
209
+ if (!create) return { changed: false, present: false };
210
+ writeJsonFile(filePath, { plugin: [SERVER_PLUGIN_REL] });
211
+ return { changed: true, present: true };
212
+ }
213
+ if (!isRecord(value)) fail(`opencode.jsonc is not an object: ${filePath}`);
214
+ const plugins = Array.isArray(value.plugin) ? value.plugin : [];
215
+ const withoutLegacy = plugins.filter((entry) => entry !== LEGACY_SERVER_PLUGIN_REL);
216
+ const changed = withoutLegacy.length !== plugins.length;
217
+ if (withoutLegacy.includes(SERVER_PLUGIN_REL)) {
218
+ if (changed) {
219
+ value.plugin = withoutLegacy;
220
+ writeJsonFile(filePath, value);
221
+ }
222
+ return { changed, present: true };
223
+ }
224
+ value.plugin = [...withoutLegacy, SERVER_PLUGIN_REL];
225
+ writeJsonFile(filePath, value);
226
+ return { changed: true, present: true };
227
+ }
228
+
229
+ export function readTuiSelection(configDir) {
230
+ const filePath = path.join(configDir, "tui.json");
231
+ const { found, value } = readJsonFile(filePath);
232
+ if (!found) return { found: false, entry: false, sidebar: null, statusline: null };
233
+ const plugins = isRecord(value) && Array.isArray(value.plugin) ? value.plugin : [];
234
+ for (const entry of plugins) {
235
+ const normalized = normalizeTuiEntry(entry);
236
+ if (normalized === null) continue;
237
+ if (normalized.spec !== TUI_PLUGIN_REL && normalized.spec !== LEGACY_TUI_PLUGIN_REL) continue;
238
+ return { found: true, entry: true, sidebar: normalized.sidebar, statusline: normalized.statusline };
239
+ }
240
+ return { found: true, entry: false, sidebar: null, statusline: null };
241
+ }
242
+
243
+ function normalizeTuiEntry(entry) {
244
+ if (typeof entry === "string") return { spec: entry, sidebar: null, statusline: null };
245
+ if (!Array.isArray(entry) || typeof entry[0] !== "string") return null;
246
+ const options = entry.length > 1 && isRecord(entry[1]) ? entry[1] : {};
247
+ return {
248
+ spec: entry[0],
249
+ sidebar: typeof options.sidebar === "boolean" ? options.sidebar : null,
250
+ statusline: typeof options.statusline === "boolean" ? options.statusline : null,
251
+ };
252
+ }
253
+
254
+ export function ensureTuiEntry(configDir, { sidebar = null, statusline = null, create = true } = {}) {
255
+ const filePath = path.join(configDir, "tui.json");
256
+ const { found, value } = readJsonFile(filePath);
257
+ const sidebarNext = sidebar ?? true;
258
+ const statuslineNext = statusline ?? true;
259
+ if (!found) {
260
+ if (!create) return { changed: false, sidebar: null, statusline: null };
261
+ writeJsonFile(filePath, { plugin: [[TUI_PLUGIN_REL, { sidebar: sidebarNext, statusline: statuslineNext }]] });
262
+ return { changed: true, sidebar: sidebarNext, statusline: statuslineNext };
263
+ }
264
+ if (!isRecord(value)) fail(`tui.json is not an object: ${filePath}`);
265
+ const plugins = Array.isArray(value.plugin) ? value.plugin : [];
266
+ let changed = false;
267
+ let seen = false;
268
+ const next = plugins.map((entry) => {
269
+ const normalized = normalizeTuiEntry(entry);
270
+ if (normalized === null) return entry;
271
+ if (normalized.spec !== TUI_PLUGIN_REL && normalized.spec !== LEGACY_TUI_PLUGIN_REL) return entry;
272
+ seen = true;
273
+ const sidebarFinal = sidebar ?? normalized.sidebar ?? true;
274
+ const statuslineFinal = statusline ?? normalized.statusline ?? true;
275
+ if (
276
+ normalized.spec === TUI_PLUGIN_REL &&
277
+ Array.isArray(entry) &&
278
+ isRecord(entry[1]) &&
279
+ entry[1].sidebar === sidebarFinal &&
280
+ entry[1].statusline === statuslineFinal
281
+ ) {
282
+ return entry;
283
+ }
284
+ changed = true;
285
+ return [TUI_PLUGIN_REL, { sidebar: sidebarFinal, statusline: statuslineFinal }];
286
+ });
287
+ if (!seen) {
288
+ next.push([TUI_PLUGIN_REL, { sidebar: sidebarNext, statusline: statuslineNext }]);
289
+ changed = true;
290
+ }
291
+ if (changed) {
292
+ value.plugin = next;
293
+ writeJsonFile(filePath, value);
294
+ }
295
+ const selection = readTuiSelection(configDir);
296
+ return { changed, sidebar: selection.sidebar, statusline: selection.statusline };
297
+ }
298
+
299
+ export function secretPresence(configDir) {
300
+ const candidates = [
301
+ process.env.OPENCODE_GO_API_KEY,
302
+ process.env.OPENCODE_GO_AUTH_COOKIE,
303
+ process.env.OPENCODE_GO_WORKSPACE_ID,
304
+ ];
305
+ const envPresent = candidates.some((value) => toNonEmptyString(value) !== null);
306
+ const fileConfig = path.join(configDir, "oc-go-usage-display.json");
307
+ const legacyFileConfig = path.join(configDir, "opencode-go-usage.json");
308
+ return {
309
+ envPresent,
310
+ filePresent: fs.existsSync(fileConfig) || fs.existsSync(legacyFileConfig),
311
+ };
312
+ }
@@ -0,0 +1,35 @@
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).
5
+
6
+ import {
7
+ ensureServerEntry,
8
+ ensureTuiEntry,
9
+ fail,
10
+ linkModeFromArgv,
11
+ linkPluginFiles,
12
+ openCodeDirFromArgv,
13
+ parseOptionalToggle,
14
+ repoDirFromArgv,
15
+ } from "./lib.js";
16
+
17
+ try {
18
+ const argv = process.argv.slice(2);
19
+ const repoDir = repoDirFromArgv(argv);
20
+ const configDir = openCodeDirFromArgv(argv);
21
+ const mode = linkModeFromArgv(argv);
22
+ const sidebar = parseOptionalToggle(argv, "--sidebar");
23
+ const statusline = parseOptionalToggle(argv, "--statusline");
24
+
25
+ linkPluginFiles(repoDir, configDir, mode);
26
+ const server = ensureServerEntry(configDir);
27
+ const tui = ensureTuiEntry(configDir, { sidebar, statusline });
28
+
29
+ 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}`);
32
+ } catch (error) {
33
+ if (error instanceof Error) fail(error.message);
34
+ throw error;
35
+ }
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // Show the effective installation: link targets, config entries, toggles.
3
+ // Secrets are never printed; only presence (env/file) is reported.
4
+
5
+ import * as path from "node:path";
6
+ import {
7
+ SERVER_FILE_NAME,
8
+ TUI_FILE_NAME,
9
+ TUI_PLUGIN_REL,
10
+ SERVER_PLUGIN_REL,
11
+ describeLink,
12
+ fail,
13
+ openCodeDirFromArgv,
14
+ readJsonFile,
15
+ readTuiSelection,
16
+ repoDirFromArgv,
17
+ secretPresence,
18
+ } from "./lib.js";
19
+
20
+ try {
21
+ const argv = process.argv.slice(2);
22
+ const repoDir = repoDirFromArgv(argv);
23
+ const configDir = openCodeDirFromArgv(argv);
24
+ const asJson = argv.includes("--json");
25
+
26
+ const serverLink = describeLink(path.join(configDir, "plugins", SERVER_FILE_NAME));
27
+ const tuiLink = describeLink(path.join(configDir, "plugins", TUI_FILE_NAME));
28
+ const tui = readTuiSelection(configDir);
29
+ const opencodeJson = readJsonFile(path.join(configDir, "opencode.jsonc"));
30
+ const serverEntry =
31
+ opencodeJson.found &&
32
+ typeof opencodeJson.value === "object" &&
33
+ opencodeJson.value !== null &&
34
+ Array.isArray(opencodeJson.value.plugin) &&
35
+ opencodeJson.value.plugin.includes(SERVER_PLUGIN_REL);
36
+ const secrets = secretPresence(configDir);
37
+
38
+ const report = {
39
+ repoDir,
40
+ configDir,
41
+ server: { expected: SERVER_PLUGIN_REL, entry: serverEntry, ...serverLink },
42
+ tui: {
43
+ expected: TUI_PLUGIN_REL,
44
+ entry: tui.entry,
45
+ sidebar: tui.sidebar,
46
+ statusline: tui.statusline,
47
+ ...tuiLink,
48
+ },
49
+ secrets: { envPresent: secrets.envPresent, filePresent: secrets.filePresent },
50
+ };
51
+
52
+ if (asJson) {
53
+ console.log(JSON.stringify(report, null, 2));
54
+ } else {
55
+ console.log(`repo: ${repoDir}`);
56
+ console.log(`config: ${configDir}`);
57
+ console.log(`server: ${serverLink.state}${serverLink.detail ? ` -> ${serverLink.detail}` : ""} (entry: ${serverEntry ? "present" : "missing"})`);
58
+ console.log(`tui: ${tuiLink.state}${tuiLink.detail ? ` -> ${tuiLink.detail}` : ""} (entry: ${tui.entry ? "present" : "missing"})`);
59
+ console.log(`toggles: sidebar=${tui.sidebar} statusline=${tui.statusline}`);
60
+ console.log(`secrets: env=${secrets.envPresent ? "set" : "unset"} file=${secrets.filePresent ? "present" : "absent"} (values never shown)`);
61
+ }
62
+ } catch (error) {
63
+ if (error instanceof Error) fail(error.message);
64
+ throw error;
65
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // Check the installation. Exit 0 when healthy, 1 with reasons otherwise.
3
+ // Verifies: symlinks point at this repo, config entries exist, toggles set.
4
+
5
+ import * as path from "node:path";
6
+ import {
7
+ SERVER_FILE_NAME,
8
+ TUI_FILE_NAME,
9
+ describeLink,
10
+ fail,
11
+ openCodeDirFromArgv,
12
+ readJsonFile,
13
+ SERVER_PLUGIN_REL,
14
+ readTuiSelection,
15
+ repoDirFromArgv,
16
+ } from "./lib.js";
17
+
18
+ try {
19
+ const argv = process.argv.slice(2);
20
+ const repoDir = repoDirFromArgv(argv);
21
+ const configDir = openCodeDirFromArgv(argv);
22
+ const problems = [];
23
+
24
+ const expectedServer = path.join(repoDir, "src", "index.ts");
25
+ const expectedTui = path.join(repoDir, "src", "tui.tsx");
26
+ const serverLink = describeLink(path.join(configDir, "plugins", SERVER_FILE_NAME));
27
+ const tuiLink = describeLink(path.join(configDir, "plugins", TUI_FILE_NAME));
28
+
29
+ if (serverLink.state !== "symlink") {
30
+ problems.push(`server plugin is ${serverLink.state}, expected symlink -> ${expectedServer}`);
31
+ } else if (path.resolve(configDir, "plugins", serverLink.detail) !== expectedServer) {
32
+ problems.push(`server symlink points at ${serverLink.detail}, expected ${expectedServer}`);
33
+ }
34
+ if (tuiLink.state !== "symlink") {
35
+ problems.push(`tui plugin is ${tuiLink.state}, expected symlink -> ${expectedTui}`);
36
+ } else if (path.resolve(configDir, "plugins", tuiLink.detail ?? "") !== expectedTui) {
37
+ problems.push(`tui symlink points at ${tuiLink.detail}, expected ${expectedTui}`);
38
+ }
39
+
40
+ const opencodeJson = readJsonFile(path.join(configDir, "opencode.jsonc"));
41
+ const serverEntry =
42
+ opencodeJson.found &&
43
+ typeof opencodeJson.value === "object" &&
44
+ opencodeJson.value !== null &&
45
+ Array.isArray(opencodeJson.value.plugin) &&
46
+ opencodeJson.value.plugin.includes(SERVER_PLUGIN_REL);
47
+ if (!serverEntry) problems.push("opencode.jsonc is missing the server plugin entry");
48
+
49
+ const tui = readTuiSelection(configDir);
50
+ if (!tui.entry) problems.push("tui.json is missing the tui plugin entry");
51
+ if (tui.sidebar === null || tui.statusline === null) {
52
+ problems.push(`tui toggles incomplete: sidebar=${tui.sidebar} statusline=${tui.statusline}`);
53
+ }
54
+
55
+ if (problems.length > 0) {
56
+ for (const problem of problems) console.error(`FAIL: ${problem}`);
57
+ process.exitCode = 1;
58
+ } else {
59
+ console.log(`OK: installed from ${repoDir} (sidebar=${tui.sidebar} statusline=${tui.statusline})`);
60
+ }
61
+ } catch (error) {
62
+ if (error instanceof Error) fail(error.message);
63
+ throw error;
64
+ }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // Update the installation: re-link plugin files, re-register config entries,
3
+ // then fast-forward the repo (only when it has a remote; otherwise re-link is
4
+ // the update). Restart opencode afterwards to pick up changes.
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import {
10
+ ensureServerEntry,
11
+ ensureTuiEntry,
12
+ fail,
13
+ linkModeFromArgv,
14
+ linkPluginFiles,
15
+ openCodeDirFromArgv,
16
+ parseOptionalToggle,
17
+ repoDirFromArgv,
18
+ } from "./lib.js";
19
+
20
+ function hasRemote(repoDir) {
21
+ try {
22
+ const out = execFileSync("git", ["-C", repoDir, "remote"], { encoding: "utf8" });
23
+ return out.trim().length > 0;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function isClean(repoDir) {
30
+ try {
31
+ const out = execFileSync("git", ["-C", repoDir, "status", "--porcelain"], { encoding: "utf8" });
32
+ return out.trim().length === 0;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ try {
39
+ const argv = process.argv.slice(2);
40
+ const repoDir = repoDirFromArgv(argv);
41
+ const configDir = openCodeDirFromArgv(argv);
42
+ const mode = linkModeFromArgv(argv);
43
+ const sidebar = parseOptionalToggle(argv, "--sidebar");
44
+ const statusline = parseOptionalToggle(argv, "--statusline");
45
+
46
+ if (!fs.existsSync(path.join(repoDir, ".git"))) fail(`not a git repo: ${repoDir}`);
47
+
48
+ if (hasRemote(repoDir)) {
49
+ if (!isClean(repoDir)) fail(`repo has local changes; commit or stash before updating: ${repoDir}`);
50
+ execFileSync("git", ["-C", repoDir, "pull", "--ff-only"], { stdio: "inherit" });
51
+ } else {
52
+ console.log("no remote configured; re-linking local files (no fetch)");
53
+ }
54
+
55
+ linkPluginFiles(repoDir, configDir, mode);
56
+ const server = ensureServerEntry(configDir);
57
+ const tui = ensureTuiEntry(configDir, { sidebar, statusline });
58
+
59
+ console.log(`updated from ${repoDir} (${mode})`);
60
+ console.log(`server entry: ${server.present ? "present" : "missing"}`);
61
+ console.log(`tui toggles: sidebar=${tui.sidebar} statusline=${tui.statusline}`);
62
+ console.log("restart opencode to pick up plugin changes");
63
+ } catch (error) {
64
+ if (error instanceof Error) fail(error.message);
65
+ throw error;
66
+ }
@@ -0,0 +1,11 @@
1
+ declare const _default: () => Promise<{
2
+ tool: {
3
+ go_usage: {
4
+ description: string;
5
+ args: {};
6
+ execute(args: Record<string, never>, context: import("@opencode-ai/plugin").ToolContext): Promise<import("@opencode-ai/plugin").ToolResult>;
7
+ };
8
+ };
9
+ }>;
10
+ export default _default;
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;AAwiBA,wBAiBoB"}