@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.
- package/README.md +89 -34
- package/dist/src/commands/agent/disable.d.ts +8 -1
- package/dist/src/commands/agent/disable.d.ts.map +1 -1
- package/dist/src/commands/agent/disable.js +79 -45
- package/dist/src/commands/agent/doctor.d.ts.map +1 -1
- package/dist/src/commands/agent/doctor.js +146 -75
- package/dist/src/commands/agent/hook.d.ts +4 -1
- package/dist/src/commands/agent/hook.d.ts.map +1 -1
- package/dist/src/commands/agent/hook.js +152 -15
- package/dist/src/commands/agent/setup.d.ts +17 -10
- package/dist/src/commands/agent/setup.d.ts.map +1 -1
- package/dist/src/commands/agent/setup.js +208 -69
- package/dist/src/commands/agent/status.d.ts.map +1 -1
- package/dist/src/commands/agent/status.js +46 -24
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +18 -4
- package/dist/src/lib/agent-config.d.ts +18 -3
- package/dist/src/lib/agent-config.d.ts.map +1 -1
- package/dist/src/lib/agent-config.js +44 -19
- package/dist/src/lib/agent-hook-state.d.ts +9 -1
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
- package/dist/src/lib/agent-hook-state.js +21 -2
- package/dist/src/lib/agents.d.ts +115 -0
- package/dist/src/lib/agents.d.ts.map +1 -0
- package/dist/src/lib/agents.js +245 -0
- package/dist/src/lib/codex-config.d.ts +166 -0
- package/dist/src/lib/codex-config.d.ts.map +1 -0
- package/dist/src/lib/codex-config.js +441 -0
- package/dist/src/lib/codex-config.test.d.ts +2 -0
- package/dist/src/lib/codex-config.test.d.ts.map +1 -0
- package/dist/src/lib/codex-config.test.js +359 -0
- package/dist/src/lib/opencode-config.d.ts +108 -0
- package/dist/src/lib/opencode-config.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.js +330 -0
- package/dist/src/lib/opencode-config.test.d.ts +2 -0
- package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.test.js +140 -0
- 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 @@
|
|
|
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.
|
|
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": {
|