@blastin-dev/clocktopus-cli 0.2.0 → 0.3.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 (53) hide show
  1. package/README.md +91 -36
  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 +157 -95
  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 +239 -98
  10. package/dist/src/commands/agent/setup.d.ts +1 -16
  11. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  12. package/dist/src/commands/agent/setup.js +198 -81
  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 +5 -23
  18. package/dist/src/lib/agent-config.d.ts.map +1 -1
  19. package/dist/src/lib/agent-config.js +50 -36
  20. package/dist/src/lib/agent-hook-state.d.ts +11 -7
  21. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  22. package/dist/src/lib/agent-hook-state.js +34 -29
  23. package/dist/src/lib/agents.d.ts +52 -0
  24. package/dist/src/lib/agents.d.ts.map +1 -0
  25. package/dist/src/lib/agents.js +238 -0
  26. package/dist/src/lib/claude-settings.d.ts +0 -36
  27. package/dist/src/lib/claude-settings.d.ts.map +1 -1
  28. package/dist/src/lib/claude-settings.js +37 -62
  29. package/dist/src/lib/codex-config.d.ts +87 -0
  30. package/dist/src/lib/codex-config.d.ts.map +1 -0
  31. package/dist/src/lib/codex-config.js +399 -0
  32. package/dist/src/lib/codex-config.test.d.ts +2 -0
  33. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  34. package/dist/src/lib/codex-config.test.js +359 -0
  35. package/dist/src/lib/declared-commits.d.ts +43 -0
  36. package/dist/src/lib/declared-commits.d.ts.map +1 -0
  37. package/dist/src/lib/declared-commits.js +114 -0
  38. package/dist/src/lib/declared-commits.test.d.ts +2 -0
  39. package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
  40. package/dist/src/lib/declared-commits.test.js +129 -0
  41. package/dist/src/lib/git-remotes.d.ts +9 -0
  42. package/dist/src/lib/git-remotes.d.ts.map +1 -0
  43. package/dist/src/lib/git-remotes.js +50 -0
  44. package/dist/src/lib/git-remotes.test.d.ts +2 -0
  45. package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
  46. package/dist/src/lib/git-remotes.test.js +52 -0
  47. package/dist/src/lib/opencode-config.d.ts +23 -0
  48. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  49. package/dist/src/lib/opencode-config.js +290 -0
  50. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  51. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  52. package/dist/src/lib/opencode-config.test.js +140 -0
  53. package/package.json +2 -1
@@ -0,0 +1,50 @@
1
+ // Every remote a checkout has, for attributing the session to a repository.
2
+ //
3
+ // `origin` is a naming convention, not evidence. On a fork it names the developer's
4
+ // own copy while the webhooks — and so every commit row — arrive under the upstream,
5
+ // which left those sessions attributed to a repository nothing else in the system
6
+ // ever mentions (BLA-598). The receiver decides which one wins; the hook's job is to
7
+ // report them all.
8
+ /** Ceiling matching `AgentSessionHookSchema`. A checkout with more is not a real one. */
9
+ const MAX_REMOTES = 20;
10
+ /**
11
+ * Parses `git remote -v` into a deduplicated URL list, `origin` first.
12
+ *
13
+ * Order is load-bearing in one narrow way: the first entry is what a receiver older
14
+ * than BLA-598 reads as the only remote, so keeping `origin` there preserves the
15
+ * previous behaviour exactly.
16
+ */
17
+ export function parseGitRemotes(output) {
18
+ if (!output?.trim())
19
+ return [];
20
+ const byName = new Map();
21
+ for (const line of output.split("\n")) {
22
+ // `name\turl (fetch|push)` — push URLs can differ from fetch ones, and both are
23
+ // worth reporting: a fork often fetches upstream and pushes to itself.
24
+ const match = line.trim().match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
25
+ if (!match)
26
+ continue;
27
+ const [, name, url, direction] = match;
28
+ if (!name || !url)
29
+ continue;
30
+ const key = `${name}:${direction}`;
31
+ if (!byName.has(key))
32
+ byName.set(key, url);
33
+ }
34
+ const entries = [...byName.entries()];
35
+ const ordered = [
36
+ ...entries.filter(([key]) => key.startsWith("origin:")),
37
+ ...entries.filter(([key]) => !key.startsWith("origin:")),
38
+ ];
39
+ const seen = new Set();
40
+ const urls = [];
41
+ for (const [, url] of ordered) {
42
+ if (seen.has(url))
43
+ continue;
44
+ seen.add(url);
45
+ urls.push(url);
46
+ if (urls.length === MAX_REMOTES)
47
+ break;
48
+ }
49
+ return urls;
50
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=git-remotes.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-remotes.test.d.ts","sourceRoot":"","sources":["../../../src/lib/git-remotes.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseGitRemotes } from "./git-remotes.js";
3
+ describe("parseGitRemotes", () => {
4
+ it("keeps origin first, so an older receiver reading only the first entry is unchanged", () => {
5
+ const urls = parseGitRemotes([
6
+ "upstream\tgit@github.com:acme-co/portal.git (fetch)",
7
+ "upstream\tgit@github.com:acme-co/portal.git (push)",
8
+ "origin\tgit@github.com:jordan/portal.git (fetch)",
9
+ "origin\tgit@github.com:jordan/portal.git (push)",
10
+ ].join("\n"));
11
+ expect(urls[0]).toBe("git@github.com:jordan/portal.git");
12
+ expect(urls).toContain("git@github.com:acme-co/portal.git");
13
+ });
14
+ it("collapses the fetch and push lines of one remote", () => {
15
+ const urls = parseGitRemotes([
16
+ "origin\tgit@github.com:acme-co/portal.git (fetch)",
17
+ "origin\tgit@github.com:acme-co/portal.git (push)",
18
+ ].join("\n"));
19
+ expect(urls).toEqual(["git@github.com:acme-co/portal.git"]);
20
+ });
21
+ /** A fork often fetches from upstream and pushes to itself under one remote name. */
22
+ it("keeps both URLs when a remote's push target differs from its fetch", () => {
23
+ const urls = parseGitRemotes([
24
+ "origin\tgit@github.com:acme-co/portal.git (fetch)",
25
+ "origin\tgit@github.com:jordan/portal.git (push)",
26
+ ].join("\n"));
27
+ expect(urls).toEqual([
28
+ "git@github.com:acme-co/portal.git",
29
+ "git@github.com:jordan/portal.git",
30
+ ]);
31
+ });
32
+ it("returns nothing for a checkout with no remotes", () => {
33
+ expect(parseGitRemotes("")).toEqual([]);
34
+ expect(parseGitRemotes(undefined)).toEqual([]);
35
+ });
36
+ it("works when no remote is called origin", () => {
37
+ const urls = parseGitRemotes("github\tgit@github.com:acme-co/portal.git (fetch)");
38
+ expect(urls).toEqual(["git@github.com:acme-co/portal.git"]);
39
+ });
40
+ it("ignores lines that are not remote entries", () => {
41
+ const urls = parseGitRemotes([
42
+ "origin\tgit@github.com:acme-co/portal.git (fetch)",
43
+ "warning: something else entirely",
44
+ "",
45
+ ].join("\n"));
46
+ expect(urls).toEqual(["git@github.com:acme-co/portal.git"]);
47
+ });
48
+ it("caps a pathological remote list", () => {
49
+ const lines = Array.from({ length: 40 }, (_, i) => `r${i}\tgit@github.com:acme-co/repo-${i}.git (fetch)`);
50
+ expect(parseGitRemotes(lines.join("\n"))).toHaveLength(20);
51
+ });
52
+ });
@@ -0,0 +1,23 @@
1
+ export declare const OPENCODE_PLUGIN_FILENAME = "clocktopus.js";
2
+ export declare const OPENCODE_PLUGIN_VERSION = 1;
3
+ export declare function opencodeConfigDir(): string;
4
+ export declare function opencodePluginPath(): string;
5
+ export type OpencodePluginConfig = {
6
+ endpoint: string;
7
+ token: string;
8
+ hookArgv: string[];
9
+ };
10
+ export declare function splitCommandLine(command: string): string[];
11
+ export declare function buildOpencodePlugin(config: OpencodePluginConfig): string;
12
+ export declare function readOpencodePlugin(path?: string): {
13
+ path: string;
14
+ exists: boolean;
15
+ modifiedAt: Date | null;
16
+ config: OpencodePluginConfig | null;
17
+ version: number | null;
18
+ };
19
+ export declare function writeOpencodePlugin(source: string, path?: string): {
20
+ backupPath: string | null;
21
+ };
22
+ export declare function removeOpencodePlugin(path?: string): boolean;
23
+ //# sourceMappingURL=opencode-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opencode-config.d.ts","sourceRoot":"","sources":["../../../src/lib/opencode-config.ts"],"names":[],"mappings":"AAsCA,eAAO,MAAM,wBAAwB,kBAAkB,CAAC;AAoBxD,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,wBAAgB,iBAAiB,IAAI,MAAM,CAK1C;AAED,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IAOd,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC;AAIF,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAI1D;AAMD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CA0KxE;AAED,wBAAgB,kBAAkB,CAAC,IAAI,SAAuB,GAAG;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,oBAAoB,GAAG,IAAI,CAAC;IAIpC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,CA8CA;AAED,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,EACd,IAAI,SAAuB,GAC1B;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAiB/B;AAED,wBAAgB,oBAAoB,CAAC,IAAI,SAAuB,GAAG,OAAO,CAIzE"}
@@ -0,0 +1,290 @@
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
+ // Installs and reads back the OpenCode plugin that reports to Clocktopus.
5
+ //
6
+ // OpenCode is the odd one out: Claude Code and Codex expose a *hook* — a command run
7
+ // with JSON on stdin — while OpenCode exposes a plugin API, JavaScript loaded into
8
+ // its own process. So the integration is a file of ours rather than a config entry.
9
+ //
10
+ // The install is a single file. OpenCode auto-loads everything in `<config
11
+ // dir>/plugin/` (verified against 1.18.18), so `opencode.json` is never touched —
12
+ // worth having, since that file is the user's model, provider and permission config.
13
+ // Uninstalling is deleting the file.
14
+ //
15
+ // Not OpenCode's own `experimental.openTelemetry`, which exports OTLP/JSON traces and
16
+ // looks like a one-line install. It was measured and rejected:
17
+ //
18
+ // - It ships the conversation. The AI SDK spans carry `ai.prompt`,
19
+ // `ai.prompt.messages` and `ai.response.text` verbatim, with no switch to turn
20
+ // them off.
21
+ // - It is enormous: one prompt produced 230KB, almost all OpenCode's internal spans.
22
+ // - It carries no cost and no repository, so it could not do the one job the feature
23
+ // exists for.
24
+ //
25
+ // The plugin sees a better source: `AssistantMessage` carries `cost`, a full token
26
+ // breakdown, the model, and `path.cwd`. It sends numbers and identifiers, and
27
+ // nothing a person wrote.
28
+ export const OPENCODE_PLUGIN_FILENAME = "clocktopus.js";
29
+ /** The line `readOpencodeTelemetry` parses back out of the plugin. */
30
+ const CONFIG_MARKER = "const CLOCKTOPUS = ";
31
+ /** Prefix of the line carrying the generation stamp. */
32
+ const VERSION_MARKER = "// clocktopus-plugin-version: ";
33
+ // Bumped whenever `buildOpencodePlugin` changes what it emits.
34
+ //
35
+ // OpenCode is the only agent whose integration is generated source rather than a
36
+ // command string. Claude Code and Codex hold `clocktopus agent hook …`, which means
37
+ // whatever the installed CLI means, so upgrading the CLI upgrades them. This plugin
38
+ // does not: a fix reaches nobody until they re-run `agent setup`.
39
+ //
40
+ // Stamping the generation makes that visible — `agent doctor` compares this number
41
+ // against the file. Nothing auto-rewrites it: it holds a token, and a command that
42
+ // quietly rewrites credentials is worse than one that tells you to.
43
+ //
44
+ // A plugin written before the stamp parses as `null`, which reads as stale.
45
+ export const OPENCODE_PLUGIN_VERSION = 1;
46
+ export function opencodeConfigDir() {
47
+ if (process.env.OPENCODE_CONFIG_DIR)
48
+ return process.env.OPENCODE_CONFIG_DIR;
49
+ if (process.env.XDG_CONFIG_HOME)
50
+ return join(process.env.XDG_CONFIG_HOME, "opencode");
51
+ return join(homedir(), ".config", "opencode");
52
+ }
53
+ export function opencodePluginPath() {
54
+ return join(opencodeConfigDir(), "plugin", OPENCODE_PLUGIN_FILENAME);
55
+ }
56
+ // Only as clever as `resolveHookCommand` is: double quotes around whitespace,
57
+ // nothing else. It is fed that function's output, never a user's shell.
58
+ export function splitCommandLine(command) {
59
+ return (command.match(/"[^"]*"|\S+/g) ?? []).map((part) => part.startsWith('"') && part.endsWith('"') ? part.slice(1, -1) : part);
60
+ }
61
+ // The plugin source, with this machine's configuration baked into one line.
62
+ // Generated rather than shipped because the token has to be in it: OpenCode gives a
63
+ // plugin no way to read our environment, so the credentials live where every other
64
+ // agent's do — in that agent's config, written 0600.
65
+ export function buildOpencodePlugin(config) {
66
+ return `// Generated by 'clocktopus agent setup'. Edits will be overwritten.
67
+ ${VERSION_MARKER}${OPENCODE_PLUGIN_VERSION}
68
+ //
69
+ // Reports what OpenCode sessions cost to Clocktopus. Two channels, because spend and
70
+ // repository context come from different places and neither is much use alone:
71
+ //
72
+ // spend POSTed straight to the receiver as OTel GenAI spans, because it happens
73
+ // per assistant message and spawning a process each time would be absurd.
74
+ // context handed to 'clocktopus agent hook', which already resolves git remotes,
75
+ // diffs a session's commit range and sweeps dead sessions.
76
+ //
77
+ // Nothing a person wrote is read: not the prompt, not the reply, not tool output.
78
+ import { spawn } from "node:child_process";
79
+
80
+ ${CONFIG_MARKER}${JSON.stringify(config)};
81
+
82
+ const REQUEST_TIMEOUT_MS = 4000;
83
+
84
+ /** ms since epoch -> the nanosecond string OTLP expects. */
85
+ const nano = (ms) => \`\${Math.round(ms)}000000\`;
86
+
87
+ const attr = (key, value) =>
88
+ typeof value === "number"
89
+ ? { key, value: { doubleValue: value } }
90
+ : { key, value: { stringValue: String(value) } };
91
+
92
+ function spans(message, version) {
93
+ const started = message.time.created;
94
+ const ended = message.time.completed ?? started;
95
+ const shared = [
96
+ attr("gen_ai.client.session_id", message.sessionID),
97
+ attr("gen_ai.client.name", "opencode"),
98
+ ];
99
+
100
+ return [
101
+ {
102
+ // Carries the turn's duration and nothing else. Clocktopus takes active time from
103
+ // this span alone, so one per assistant message is what makes the total "time the
104
+ // agent was working" rather than "time the window was open".
105
+ name: "gen_ai.client.session",
106
+ startTimeUnixNano: nano(started),
107
+ endTimeUnixNano: nano(ended),
108
+ attributes: shared,
109
+ },
110
+ {
111
+ name: "gen_ai.client.generation",
112
+ startTimeUnixNano: nano(started),
113
+ endTimeUnixNano: nano(ended),
114
+ attributes: [
115
+ ...shared,
116
+ attr("gen_ai.request.model", message.modelID),
117
+ attr("gen_ai.provider.name", message.providerID),
118
+ // OpenCode reports input already net of the cached prefix, so these buckets add up
119
+ // rather than overlap — the opposite of Codex, which reports input inclusive.
120
+ attr("gen_ai.usage.input_tokens", message.tokens.input),
121
+ // Reasoning tokens are output tokens that were not shown. OpenCode reports them
122
+ // separately and Clocktopus has no bucket for them, so they are folded in: its own
123
+ // totals treat them this way and it prices them at the output rate, so leaving them
124
+ // out would under-report tokens against a cost that already includes them.
125
+ attr(
126
+ "gen_ai.usage.output_tokens",
127
+ message.tokens.output + (message.tokens.reasoning ?? 0),
128
+ ),
129
+ attr("gen_ai.usage.cache_read_input_tokens", message.tokens.cache.read),
130
+ attr(
131
+ "gen_ai.usage.cache_creation_input_tokens",
132
+ message.tokens.cache.write,
133
+ ),
134
+ // Priced by OpenCode from the models.dev rate card, so Clocktopus records it as an
135
+ // estimate, never as a settled bill.
136
+ attr("gen_ai.usage.cost", message.cost),
137
+ ],
138
+ },
139
+ ];
140
+ }
141
+
142
+ async function report(message, version) {
143
+ const body = {
144
+ resourceSpans: [
145
+ {
146
+ resource: {
147
+ attributes: [
148
+ attr("service.name", "opencode"),
149
+ ...(version ? [attr("service.version", version)] : []),
150
+ ],
151
+ },
152
+ scopeSpans: [
153
+ {
154
+ scope: { name: "clocktopus.opencode", version: "1" },
155
+ spans: spans(message, version),
156
+ },
157
+ ],
158
+ },
159
+ ],
160
+ };
161
+
162
+ const controller = new AbortController();
163
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
164
+ try {
165
+ await fetch(\`\${CLOCKTOPUS.endpoint.replace(/\\/$/, "")}/v1/traces\`, {
166
+ method: "POST",
167
+ headers: {
168
+ "content-type": "application/json",
169
+ authorization: \`Bearer \${CLOCKTOPUS.token}\`,
170
+ },
171
+ body: JSON.stringify(body),
172
+ signal: controller.signal,
173
+ });
174
+ } catch {
175
+ // Telemetry must never break the session it measures.
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
179
+ }
180
+
181
+ /** Hands a session event to the CLI, which owns all the git logic. */
182
+ function notify(event, sessionID, cwd) {
183
+ try {
184
+ const [command, ...args] = CLOCKTOPUS.hookArgv;
185
+ const child = spawn(command, args, {
186
+ detached: true,
187
+ stdio: ["pipe", "ignore", "ignore"],
188
+ });
189
+ child.unref();
190
+ child.stdin.on("error", () => {});
191
+ child.stdin.end(
192
+ JSON.stringify({
193
+ session_id: sessionID,
194
+ hook_event_name: event,
195
+ cwd,
196
+ }),
197
+ );
198
+ } catch {
199
+ // Same contract as everything else here: fail silently.
200
+ }
201
+ }
202
+
203
+ export const ClocktopusPlugin = async ({ directory, worktree }) => {
204
+ const root = worktree || directory;
205
+ let version = null;
206
+
207
+ return {
208
+ event: async ({ event }) => {
209
+ if (event.type === "session.created") {
210
+ version = event.properties.info?.version ?? version;
211
+ notify("SessionStart", event.properties.sessionID, root);
212
+ return;
213
+ }
214
+
215
+ // OpenCode has no "session ended" event — 'idle' is what it emits when the agent
216
+ // stops and waits for a human. Treating that as the end keeps the session's end time
217
+ // tracking the last moment it was busy, which is the bound commit attribution needs;
218
+ // re-sending on a later turn is harmless, since the receiver merges.
219
+ if (event.type === "session.idle") {
220
+ notify("SessionEnd", event.properties.sessionID, root);
221
+ return;
222
+ }
223
+
224
+ if (event.type === "message.updated") {
225
+ const message = event.properties.info;
226
+ // Assistant messages only, and only once finished — an in-flight message is
227
+ // republished on every token, with counts that are not final.
228
+ if (message?.role !== "assistant" || !message.time?.completed) return;
229
+ await report(message, version);
230
+ }
231
+ },
232
+ };
233
+ };
234
+ `;
235
+ }
236
+ export function readOpencodePlugin(path = opencodePluginPath()) {
237
+ if (!existsSync(path)) {
238
+ return { path, exists: false, modifiedAt: null, config: null, version: null };
239
+ }
240
+ const modifiedAt = statSync(path).mtime;
241
+ let config = null;
242
+ let version = null;
243
+ try {
244
+ const lines = readFileSync(path, "utf8").split("\n");
245
+ const stamp = lines.find((candidate) => candidate.startsWith(VERSION_MARKER));
246
+ if (stamp) {
247
+ const parsedVersion = Number.parseInt(stamp.slice(VERSION_MARKER.length).trim(), 10);
248
+ if (Number.isFinite(parsedVersion))
249
+ version = parsedVersion;
250
+ }
251
+ const line = lines.find((candidate) => candidate.startsWith(CONFIG_MARKER));
252
+ if (line) {
253
+ const parsed = JSON.parse(line.slice(CONFIG_MARKER.length).replace(/;\s*$/, ""));
254
+ if (parsed &&
255
+ typeof parsed === "object" &&
256
+ typeof parsed.token === "string" &&
257
+ typeof parsed.endpoint === "string" &&
258
+ Array.isArray(parsed.hookArgv)) {
259
+ config = parsed;
260
+ }
261
+ }
262
+ }
263
+ catch {
264
+ // A plugin we cannot read the configuration out of is reported as unconfigured,
265
+ // which sends the user to `setup` — the right answer whether it was hand-edited or
266
+ // written by a version that has since changed shape.
267
+ }
268
+ return { path, exists: true, modifiedAt, config, version };
269
+ }
270
+ export function writeOpencodePlugin(source, path = opencodePluginPath()) {
271
+ mkdirSync(dirname(path), { recursive: true });
272
+ let backupPath = null;
273
+ if (existsSync(path)) {
274
+ backupPath = `${path}.clocktopus-backup`;
275
+ copyFileSync(path, backupPath);
276
+ }
277
+ // Rename rather than write in place: OpenCode loads every file in this directory at
278
+ // startup, and a half-written one would be a syntax error that takes the whole
279
+ // plugin system down with it.
280
+ const temporaryPath = `${path}.clocktopus-tmp`;
281
+ writeFileSync(temporaryPath, source, { encoding: "utf8", mode: 0o600 });
282
+ renameSync(temporaryPath, path);
283
+ return { backupPath };
284
+ }
285
+ export function removeOpencodePlugin(path = opencodePluginPath()) {
286
+ if (!existsSync(path))
287
+ return false;
288
+ rmSync(path, { force: true });
289
+ return true;
290
+ }
@@ -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.3.0",
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": {