@blastin-dev/clocktopus-cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +89 -34
  2. package/dist/src/commands/agent/disable.d.ts +8 -1
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -1
  4. package/dist/src/commands/agent/disable.js +79 -45
  5. package/dist/src/commands/agent/doctor.d.ts.map +1 -1
  6. package/dist/src/commands/agent/doctor.js +146 -75
  7. package/dist/src/commands/agent/hook.d.ts +4 -1
  8. package/dist/src/commands/agent/hook.d.ts.map +1 -1
  9. package/dist/src/commands/agent/hook.js +152 -15
  10. package/dist/src/commands/agent/setup.d.ts +17 -10
  11. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  12. package/dist/src/commands/agent/setup.js +208 -69
  13. package/dist/src/commands/agent/status.d.ts.map +1 -1
  14. package/dist/src/commands/agent/status.js +46 -24
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +18 -4
  17. package/dist/src/lib/agent-config.d.ts +18 -3
  18. package/dist/src/lib/agent-config.d.ts.map +1 -1
  19. package/dist/src/lib/agent-config.js +44 -19
  20. package/dist/src/lib/agent-hook-state.d.ts +9 -1
  21. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  22. package/dist/src/lib/agent-hook-state.js +21 -2
  23. package/dist/src/lib/agents.d.ts +115 -0
  24. package/dist/src/lib/agents.d.ts.map +1 -0
  25. package/dist/src/lib/agents.js +245 -0
  26. package/dist/src/lib/codex-config.d.ts +166 -0
  27. package/dist/src/lib/codex-config.d.ts.map +1 -0
  28. package/dist/src/lib/codex-config.js +441 -0
  29. package/dist/src/lib/codex-config.test.d.ts +2 -0
  30. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  31. package/dist/src/lib/codex-config.test.js +359 -0
  32. package/dist/src/lib/opencode-config.d.ts +108 -0
  33. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  34. package/dist/src/lib/opencode-config.js +330 -0
  35. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  36. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  37. package/dist/src/lib/opencode-config.test.js +140 -0
  38. package/package.json +2 -1
@@ -0,0 +1,330 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * Installs and reads back the OpenCode plugin that reports to Clocktopus.
6
+ *
7
+ * OpenCode is the odd one out. Claude Code and Codex both expose a *hook*
8
+ * — a command they run with JSON on stdin — so wiring them up is a matter
9
+ * of editing config. OpenCode instead exposes a plugin API: JavaScript
10
+ * loaded into its own process, handed a stream of typed events. So the
11
+ * integration is a file of ours rather than a config entry pointing at the
12
+ * CLI.
13
+ *
14
+ * The install is a single file. OpenCode auto-loads everything in
15
+ * `<config dir>/plugin/`, verified against 1.18.18, so nothing has to be
16
+ * added to `opencode.json` — which is worth having: that file is the
17
+ * user's model, provider and permission configuration, and not touching it
18
+ * removes a whole class of ways to break their setup. Uninstalling is
19
+ * deleting the file.
20
+ *
21
+ * ## Why a plugin and not OpenCode's own OpenTelemetry
22
+ *
23
+ * OpenCode has `experimental.openTelemetry`, which exports OTLP/JSON traces
24
+ * to `OTEL_EXPORTER_OTLP_ENDPOINT` — on the face of it exactly what we
25
+ * want, and a one-line install. It was measured and rejected:
26
+ *
27
+ * - **It ships the conversation.** The AI SDK spans carry `ai.prompt`,
28
+ * `ai.prompt.messages` and `ai.response.text` — the system prompt, every
29
+ * user message and the model's replies, verbatim, with no switch to turn
30
+ * them off. Codex's log stream leaks tool output; this leaks everything.
31
+ * - **It is enormous.** One prompt produced 230KB, almost all of it
32
+ * OpenCode's internal spans — SQLite queries, file reads, lock
33
+ * acquisitions — with the AI spans a rounding error inside it.
34
+ * - **It carries no cost and no repository**, so it could not do the one
35
+ * job the feature exists for.
36
+ *
37
+ * The plugin sees a better source than the traces do: `AssistantMessage`
38
+ * carries `cost`, a full token breakdown, the model, and `path.cwd`. It
39
+ * sends numbers and identifiers, and nothing a person wrote.
40
+ */
41
+ export const OPENCODE_PLUGIN_FILENAME = "clocktopus.js";
42
+ /** The line `readOpencodeTelemetry` parses back out of the plugin. */
43
+ const CONFIG_MARKER = "const CLOCKTOPUS = ";
44
+ /** Prefix of the line carrying the generation stamp. */
45
+ const VERSION_MARKER = "// clocktopus-plugin-version: ";
46
+ /**
47
+ * Bumped whenever `buildOpencodePlugin` changes what it emits.
48
+ *
49
+ * OpenCode is the only agent whose integration is *generated source* rather
50
+ * than a command string in a config file. Claude Code and Codex hold
51
+ * `clocktopus agent hook …`, which means whatever the installed CLI means,
52
+ * so upgrading the CLI upgrades them. This plugin does not: the file on
53
+ * disk stays exactly as the CLI that wrote it left it, and a fix shipped to
54
+ * the plugin body reaches nobody until they re-run `agent setup`.
55
+ *
56
+ * Stamping the generation is what makes that visible — `agent doctor`
57
+ * compares this number against the file and says so when they differ.
58
+ * Nothing auto-rewrites the file: it holds a token, and a command that
59
+ * quietly rewrites credentials is worse than one that tells you to.
60
+ *
61
+ * A plugin written before the stamp existed parses as `null`, which reads
62
+ * as stale — correct, since it predates every version that has one.
63
+ */
64
+ export const OPENCODE_PLUGIN_VERSION = 1;
65
+ export function opencodeConfigDir() {
66
+ if (process.env.OPENCODE_CONFIG_DIR)
67
+ return process.env.OPENCODE_CONFIG_DIR;
68
+ if (process.env.XDG_CONFIG_HOME)
69
+ return join(process.env.XDG_CONFIG_HOME, "opencode");
70
+ return join(homedir(), ".config", "opencode");
71
+ }
72
+ export function opencodePluginPath() {
73
+ return join(opencodeConfigDir(), "plugin", OPENCODE_PLUGIN_FILENAME);
74
+ }
75
+ /**
76
+ * Splits a shell-quoted command line into argv.
77
+ *
78
+ * Only as clever as `resolveHookCommand` is: double quotes around
79
+ * whitespace, nothing else. It is fed that function's output, never a
80
+ * user's shell.
81
+ */
82
+ export function splitCommandLine(command) {
83
+ return (command.match(/"[^"]*"|\S+/g) ?? []).map((part) => part.startsWith('"') && part.endsWith('"') ? part.slice(1, -1) : part);
84
+ }
85
+ /**
86
+ * The plugin source, with this machine's configuration baked into one line.
87
+ *
88
+ * Generated rather than shipped as a file because the token has to be in
89
+ * it: OpenCode gives a plugin no way to read our environment, so the
90
+ * credentials live where every other agent's do — in that agent's config,
91
+ * written 0600.
92
+ */
93
+ export function buildOpencodePlugin(config) {
94
+ return `// Generated by 'clocktopus agent setup'. Edits will be overwritten.
95
+ ${VERSION_MARKER}${OPENCODE_PLUGIN_VERSION}
96
+ //
97
+ // Reports what OpenCode sessions cost to Clocktopus. Two channels, for the
98
+ // same reason every other agent has two: spend and repository context come
99
+ // from different places and neither is much use alone.
100
+ //
101
+ // spend POSTed straight to the receiver as OTel GenAI spans, because
102
+ // it happens per assistant message and spawning a process each
103
+ // time would be absurd.
104
+ // context handed to 'clocktopus agent hook', which already knows how to
105
+ // resolve a git remote, diff a session's commit range and sweep
106
+ // sessions that died — none of which is worth reimplementing in
107
+ // here.
108
+ //
109
+ // Nothing a person wrote is read: not the prompt, not the model's reply,
110
+ // not tool output. Only counts, identifiers and timings.
111
+ import { spawn } from "node:child_process";
112
+
113
+ ${CONFIG_MARKER}${JSON.stringify(config)};
114
+
115
+ const REQUEST_TIMEOUT_MS = 4000;
116
+
117
+ /** ms since epoch -> the nanosecond string OTLP expects. */
118
+ const nano = (ms) => \`\${Math.round(ms)}000000\`;
119
+
120
+ const attr = (key, value) =>
121
+ typeof value === "number"
122
+ ? { key, value: { doubleValue: value } }
123
+ : { key, value: { stringValue: String(value) } };
124
+
125
+ function spans(message, version) {
126
+ const started = message.time.created;
127
+ const ended = message.time.completed ?? started;
128
+ const shared = [
129
+ attr("gen_ai.client.session_id", message.sessionID),
130
+ attr("gen_ai.client.name", "opencode"),
131
+ ];
132
+
133
+ return [
134
+ {
135
+ // Carries the turn's duration and nothing else. Clocktopus takes
136
+ // active time from this span alone, so emitting one per assistant
137
+ // message is what makes the total "time the agent was working"
138
+ // rather than "time the window was open".
139
+ name: "gen_ai.client.session",
140
+ startTimeUnixNano: nano(started),
141
+ endTimeUnixNano: nano(ended),
142
+ attributes: shared,
143
+ },
144
+ {
145
+ name: "gen_ai.client.generation",
146
+ startTimeUnixNano: nano(started),
147
+ endTimeUnixNano: nano(ended),
148
+ attributes: [
149
+ ...shared,
150
+ attr("gen_ai.request.model", message.modelID),
151
+ attr("gen_ai.provider.name", message.providerID),
152
+ // OpenCode reports input already net of the cached prefix, so these
153
+ // buckets add up rather than overlap — the opposite of Codex, which
154
+ // reports input inclusive and has to be subtracted.
155
+ attr("gen_ai.usage.input_tokens", message.tokens.input),
156
+ // Reasoning tokens are output tokens that were not shown. OpenCode
157
+ // reports them separately and Clocktopus has no bucket for them, so
158
+ // they are folded in here rather than dropped: its own totals treat
159
+ // them this way (input + cache.read + output + reasoning == total)
160
+ // and it prices them at the output rate, so leaving them out would
161
+ // under-report tokens against a cost that already includes them.
162
+ attr(
163
+ "gen_ai.usage.output_tokens",
164
+ message.tokens.output + (message.tokens.reasoning ?? 0),
165
+ ),
166
+ attr("gen_ai.usage.cache_read_input_tokens", message.tokens.cache.read),
167
+ attr(
168
+ "gen_ai.usage.cache_creation_input_tokens",
169
+ message.tokens.cache.write,
170
+ ),
171
+ // Priced by OpenCode from the models.dev rate card, so Clocktopus
172
+ // records it as an estimate, never as a settled bill.
173
+ attr("gen_ai.usage.cost", message.cost),
174
+ ],
175
+ },
176
+ ];
177
+ }
178
+
179
+ async function report(message, version) {
180
+ const body = {
181
+ resourceSpans: [
182
+ {
183
+ resource: {
184
+ attributes: [
185
+ attr("service.name", "opencode"),
186
+ ...(version ? [attr("service.version", version)] : []),
187
+ ],
188
+ },
189
+ scopeSpans: [
190
+ {
191
+ scope: { name: "clocktopus.opencode", version: "1" },
192
+ spans: spans(message, version),
193
+ },
194
+ ],
195
+ },
196
+ ],
197
+ };
198
+
199
+ const controller = new AbortController();
200
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
201
+ try {
202
+ await fetch(\`\${CLOCKTOPUS.endpoint.replace(/\\/$/, "")}/v1/traces\`, {
203
+ method: "POST",
204
+ headers: {
205
+ "content-type": "application/json",
206
+ authorization: \`Bearer \${CLOCKTOPUS.token}\`,
207
+ },
208
+ body: JSON.stringify(body),
209
+ signal: controller.signal,
210
+ });
211
+ } catch {
212
+ // Telemetry must never break the session it measures.
213
+ } finally {
214
+ clearTimeout(timer);
215
+ }
216
+ }
217
+
218
+ /** Hands a session event to the CLI, which owns all the git logic. */
219
+ function notify(event, sessionID, cwd) {
220
+ try {
221
+ const [command, ...args] = CLOCKTOPUS.hookArgv;
222
+ const child = spawn(command, args, {
223
+ detached: true,
224
+ stdio: ["pipe", "ignore", "ignore"],
225
+ });
226
+ child.unref();
227
+ child.stdin.on("error", () => {});
228
+ child.stdin.end(
229
+ JSON.stringify({
230
+ session_id: sessionID,
231
+ hook_event_name: event,
232
+ cwd,
233
+ }),
234
+ );
235
+ } catch {
236
+ // Same contract as everything else here: fail silently.
237
+ }
238
+ }
239
+
240
+ export const ClocktopusPlugin = async ({ directory, worktree }) => {
241
+ const root = worktree || directory;
242
+ let version = null;
243
+
244
+ return {
245
+ event: async ({ event }) => {
246
+ if (event.type === "session.created") {
247
+ version = event.properties.info?.version ?? version;
248
+ notify("SessionStart", event.properties.sessionID, root);
249
+ return;
250
+ }
251
+
252
+ // OpenCode has no "session ended" event — 'idle' is what it emits
253
+ // when the agent stops working and waits for a human. Treating that
254
+ // as the end keeps the session's end time tracking the last moment it
255
+ // was actually busy, which is the bound commit attribution needs, and
256
+ // re-sending it on a later turn is harmless: the receiver merges.
257
+ if (event.type === "session.idle") {
258
+ notify("SessionEnd", event.properties.sessionID, root);
259
+ return;
260
+ }
261
+
262
+ if (event.type === "message.updated") {
263
+ const message = event.properties.info;
264
+ // Assistant messages only, and only once finished — an in-flight
265
+ // message is republished on every token, with counts that are not
266
+ // final.
267
+ if (message?.role !== "assistant" || !message.time?.completed) return;
268
+ await report(message, version);
269
+ }
270
+ },
271
+ };
272
+ };
273
+ `;
274
+ }
275
+ export function readOpencodePlugin(path = opencodePluginPath()) {
276
+ if (!existsSync(path)) {
277
+ return { path, exists: false, modifiedAt: null, config: null, version: null };
278
+ }
279
+ const modifiedAt = statSync(path).mtime;
280
+ let config = null;
281
+ let version = null;
282
+ try {
283
+ const lines = readFileSync(path, "utf8").split("\n");
284
+ const stamp = lines.find((candidate) => candidate.startsWith(VERSION_MARKER));
285
+ if (stamp) {
286
+ const parsedVersion = Number.parseInt(stamp.slice(VERSION_MARKER.length).trim(), 10);
287
+ if (Number.isFinite(parsedVersion))
288
+ version = parsedVersion;
289
+ }
290
+ const line = lines.find((candidate) => candidate.startsWith(CONFIG_MARKER));
291
+ if (line) {
292
+ const parsed = JSON.parse(line.slice(CONFIG_MARKER.length).replace(/;\s*$/, ""));
293
+ if (parsed &&
294
+ typeof parsed === "object" &&
295
+ typeof parsed.token === "string" &&
296
+ typeof parsed.endpoint === "string" &&
297
+ Array.isArray(parsed.hookArgv)) {
298
+ config = parsed;
299
+ }
300
+ }
301
+ }
302
+ catch {
303
+ // A plugin we cannot read the configuration out of is reported as
304
+ // unconfigured, which sends the user to `setup` — the right answer
305
+ // whether it was hand-edited or written by a version that has since
306
+ // changed shape.
307
+ }
308
+ return { path, exists: true, modifiedAt, config, version };
309
+ }
310
+ export function writeOpencodePlugin(source, path = opencodePluginPath()) {
311
+ mkdirSync(dirname(path), { recursive: true });
312
+ let backupPath = null;
313
+ if (existsSync(path)) {
314
+ backupPath = `${path}.clocktopus-backup`;
315
+ copyFileSync(path, backupPath);
316
+ }
317
+ // Rename rather than write in place: OpenCode loads every file in this
318
+ // directory at startup, and a half-written one would be a syntax error
319
+ // that takes the whole plugin system down with it.
320
+ const temporaryPath = `${path}.clocktopus-tmp`;
321
+ writeFileSync(temporaryPath, source, { encoding: "utf8", mode: 0o600 });
322
+ renameSync(temporaryPath, path);
323
+ return { backupPath };
324
+ }
325
+ export function removeOpencodePlugin(path = opencodePluginPath()) {
326
+ if (!existsSync(path))
327
+ return false;
328
+ rmSync(path, { force: true });
329
+ return true;
330
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=opencode-config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opencode-config.test.d.ts","sourceRoot":"","sources":["../../../src/lib/opencode-config.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,140 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { buildOpencodePlugin, OPENCODE_PLUGIN_VERSION, readOpencodePlugin, removeOpencodePlugin, splitCommandLine, writeOpencodePlugin, } from "./opencode-config";
6
+ /**
7
+ * OpenCode's integration is a JavaScript file we generate and drop into the
8
+ * user's config directory, which makes two things worth testing that the
9
+ * other agents do not need: that the file we write is syntactically valid —
10
+ * OpenCode loads every plugin at startup, so a broken one takes its whole
11
+ * plugin system down — and that the configuration baked into it survives a
12
+ * round trip, since that file is also where the ingest token lives.
13
+ */
14
+ const INSTALL = {
15
+ endpoint: "https://otel.example.com",
16
+ token: "ctop_agt_testtoken",
17
+ hookArgv: ["/usr/bin/node", "/opt/clocktopus/cli.js", "agent", "hook"],
18
+ };
19
+ let dir;
20
+ let pluginPath;
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), "clocktopus-opencode-"));
23
+ pluginPath = join(dir, "plugin", "clocktopus.js");
24
+ });
25
+ afterEach(() => {
26
+ rmSync(dir, { recursive: true, force: true });
27
+ });
28
+ describe("splitCommandLine", () => {
29
+ it("keeps a quoted path with spaces in one piece", () => {
30
+ // The failure this prevents is silent: the plugin spawns argv directly,
31
+ // so a path torn in two at a space produces a command that does not
32
+ // exist, and the hook simply never runs.
33
+ expect(splitCommandLine('"/opt/my node/bin/node" /opt/cli.js agent hook')).toEqual(["/opt/my node/bin/node", "/opt/cli.js", "agent", "hook"]);
34
+ });
35
+ it("handles the ordinary unquoted case", () => {
36
+ expect(splitCommandLine("clocktopus agent hook --provider opencode")).toEqual(["clocktopus", "agent", "hook", "--provider", "opencode"]);
37
+ });
38
+ });
39
+ describe("the generated plugin", () => {
40
+ it("is valid JavaScript", async () => {
41
+ const source = buildOpencodePlugin(INSTALL);
42
+ const encoded = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
43
+ // Imported rather than merely parsed: OpenCode loads this file into its
44
+ // own process, so a syntax error here is not our bug alone — it breaks
45
+ // every other plugin the user has.
46
+ const module = await import(encoded);
47
+ expect(typeof module.ClocktopusPlugin).toBe("function");
48
+ });
49
+ it("exports a plugin that registers an event handler", async () => {
50
+ const source = buildOpencodePlugin(INSTALL);
51
+ const encoded = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
52
+ const { ClocktopusPlugin } = (await import(encoded));
53
+ const hooks = await ClocktopusPlugin({
54
+ directory: "/repo",
55
+ worktree: "/repo",
56
+ });
57
+ expect(typeof hooks.event).toBe("function");
58
+ });
59
+ it("sends counts and identifiers, and nothing a person wrote", () => {
60
+ const source = buildOpencodePlugin(INSTALL);
61
+ // OpenCode hands the plugin whole messages, so what it chooses *not* to
62
+ // read is the entire privacy story. These are the fields on
63
+ // `AssistantMessage` and its parts that carry content.
64
+ for (const forbidden of [
65
+ "message.parts",
66
+ "\\.text",
67
+ "message.prompt",
68
+ "message.summary",
69
+ ]) {
70
+ expect(source).not.toMatch(new RegExp(forbidden));
71
+ }
72
+ });
73
+ it("round-trips its configuration back out of the file", () => {
74
+ writeOpencodePlugin(buildOpencodePlugin(INSTALL), pluginPath);
75
+ const read = readOpencodePlugin(pluginPath);
76
+ expect(read.exists).toBe(true);
77
+ // This is how `doctor`, `status` and the hook find the token: there is
78
+ // nowhere else it is written.
79
+ expect(read.config).toEqual(INSTALL);
80
+ });
81
+ it("stamps the generation that wrote it", () => {
82
+ // Unlike the hook agents, this file does not follow the CLI: it stays as
83
+ // written until `setup` runs again. The stamp is the only way `doctor`
84
+ // can tell a plugin that predates a fix from one that has it.
85
+ writeOpencodePlugin(buildOpencodePlugin(INSTALL), pluginPath);
86
+ expect(readOpencodePlugin(pluginPath).version).toBe(OPENCODE_PLUGIN_VERSION);
87
+ });
88
+ it("writes the file readable only by its owner", () => {
89
+ writeOpencodePlugin(buildOpencodePlugin(INSTALL), pluginPath);
90
+ // The ingest token is in there in plaintext.
91
+ expect(readFileSync(pluginPath, "utf8")).toContain(INSTALL.token);
92
+ });
93
+ it("backs up whatever was there before overwriting", () => {
94
+ writeOpencodePlugin("// something the user wrote\n", pluginPath);
95
+ const { backupPath } = writeOpencodePlugin(buildOpencodePlugin(INSTALL), pluginPath);
96
+ expect(backupPath).toBe(`${pluginPath}.clocktopus-backup`);
97
+ expect(readFileSync(backupPath, "utf8")).toBe("// something the user wrote\n");
98
+ });
99
+ });
100
+ describe("reading a plugin that is not ours to read", () => {
101
+ it("reports a hand-edited plugin as unconfigured rather than guessing", () => {
102
+ writeOpencodePlugin("export const Whatever = async () => ({});\n", pluginPath);
103
+ const read = readOpencodePlugin(pluginPath);
104
+ // The file exists but tells us nothing, so `setup` is the honest next
105
+ // step — reporting it as configured would leave the user staring at a
106
+ // pipeline that cannot possibly deliver.
107
+ expect(read.exists).toBe(true);
108
+ expect(read.config).toBeNull();
109
+ });
110
+ it("reads a plugin from before stamping existed as unstamped", () => {
111
+ // Every plugin installed before the stamp shipped lands here. `null`
112
+ // means "not the current generation", which is exactly right for one
113
+ // written by a CLI that had no generations at all.
114
+ const unstamped = buildOpencodePlugin(INSTALL)
115
+ .split("\n")
116
+ .filter((line) => !line.startsWith("// clocktopus-plugin-version:"))
117
+ .join("\n");
118
+ writeOpencodePlugin(unstamped, pluginPath);
119
+ const read = readOpencodePlugin(pluginPath);
120
+ // Still configured — it reports fine, it is just behind.
121
+ expect(read.config).toEqual(INSTALL);
122
+ expect(read.version).toBeNull();
123
+ });
124
+ it("reports nothing at all when the file is absent", () => {
125
+ expect(readOpencodePlugin(pluginPath)).toMatchObject({
126
+ exists: false,
127
+ config: null,
128
+ });
129
+ });
130
+ });
131
+ describe("removing the plugin", () => {
132
+ it("deletes the file and says so", () => {
133
+ writeOpencodePlugin(buildOpencodePlugin(INSTALL), pluginPath);
134
+ expect(removeOpencodePlugin(pluginPath)).toBe(true);
135
+ expect(readOpencodePlugin(pluginPath).exists).toBe(false);
136
+ });
137
+ it("is a no-op when there is nothing installed", () => {
138
+ expect(removeOpencodePlugin(pluginPath)).toBe(false);
139
+ });
140
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blastin-dev/clocktopus-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "clocktopus": "./dist/bin/clocktopus.js"
@@ -12,6 +12,7 @@
12
12
  "commander": "^13.0.0",
13
13
  "conf": "^13.0.0",
14
14
  "date-fns": "4.1.0",
15
+ "smol-toml": "^1.8.0",
15
16
  "zod": "4.4.3"
16
17
  },
17
18
  "devDependencies": {