@blastin-dev/clocktopus-cli 0.1.4 → 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 +163 -5
- package/dist/src/commands/agent/disable.d.ts +21 -0
- package/dist/src/commands/agent/disable.d.ts.map +1 -0
- package/dist/src/commands/agent/disable.js +106 -0
- package/dist/src/commands/agent/doctor.d.ts +2 -0
- package/dist/src/commands/agent/doctor.d.ts.map +1 -0
- package/dist/src/commands/agent/doctor.js +306 -0
- package/dist/src/commands/agent/hook.d.ts +5 -0
- package/dist/src/commands/agent/hook.d.ts.map +1 -0
- package/dist/src/commands/agent/hook.js +368 -0
- package/dist/src/commands/agent/setup.d.ts +28 -0
- package/dist/src/commands/agent/setup.d.ts.map +1 -0
- package/dist/src/commands/agent/setup.js +333 -0
- package/dist/src/commands/agent/status.d.ts +2 -0
- package/dist/src/commands/agent/status.d.ts.map +1 -0
- package/dist/src/commands/agent/status.js +182 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +51 -2
- package/dist/src/lib/agent-config.d.ts +56 -0
- package/dist/src/lib/agent-config.d.ts.map +1 -0
- package/dist/src/lib/agent-config.js +168 -0
- package/dist/src/lib/agent-hook-state.d.ts +44 -0
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
- package/dist/src/lib/agent-hook-state.js +155 -0
- package/dist/src/lib/agent-receiver.d.ts +26 -0
- package/dist/src/lib/agent-receiver.d.ts.map +1 -0
- package/dist/src/lib/agent-receiver.js +44 -0
- 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/claude-settings.d.ts +82 -0
- package/dist/src/lib/claude-settings.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.js +271 -0
- package/dist/src/lib/claude-settings.test.d.ts +2 -0
- package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.test.js +193 -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/config.d.ts +23 -0
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +14 -0
- package/dist/src/lib/format.d.ts +6 -0
- package/dist/src/lib/format.d.ts.map +1 -0
- package/dist/src/lib/format.js +19 -0
- package/dist/src/lib/git.d.ts +3 -0
- package/dist/src/lib/git.d.ts.map +1 -0
- package/dist/src/lib/git.js +30 -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/dist/src/lib/repo-guidance.d.ts +40 -0
- package/dist/src/lib/repo-guidance.d.ts.map +1 -0
- package/dist/src/lib/repo-guidance.js +123 -0
- package/dist/src/lib/validators.d.ts +69 -0
- package/dist/src/lib/validators.d.ts.map +1 -1
- package/dist/src/lib/validators.js +69 -0
- package/package.json +7 -4
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { RepoStatusResponse } from "./validators.js";
|
|
2
|
+
/**
|
|
3
|
+
* The half of the setup that is not on this machine.
|
|
4
|
+
*
|
|
5
|
+
* Configuring the exporter and the hook makes agent sessions *arrive*. It
|
|
6
|
+
* does not make them *mean* anything, and the two ways that fails are
|
|
7
|
+
* unrelated to each other and to everything `agent setup` writes locally:
|
|
8
|
+
*
|
|
9
|
+
* - The repository is not attached to a project, so spend lands in the
|
|
10
|
+
* unattributed row. Purely a Clocktopus-side mapping; no webhook affects
|
|
11
|
+
* it either way.
|
|
12
|
+
* - The push webhook is not delivering, so there are no human time entries
|
|
13
|
+
* to compare the spend against. Note this does *not* stop agent→commit
|
|
14
|
+
* links: the SessionEnd hook resolves the commit range locally and posts
|
|
15
|
+
* it. What is missing is the other side of the comparison, and a cost
|
|
16
|
+
* without the hours it bought is not a smaller answer — it is not one.
|
|
17
|
+
*
|
|
18
|
+
* Reported as two separate verdicts, because they send you to two
|
|
19
|
+
* different places, and a single "not set up" would send you to neither.
|
|
20
|
+
*/
|
|
21
|
+
export type RepoCheck = {
|
|
22
|
+
label: string;
|
|
23
|
+
ok: boolean | "warn";
|
|
24
|
+
detail: string;
|
|
25
|
+
fix?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Fetches readiness for the repository the CLI is standing in.
|
|
29
|
+
*
|
|
30
|
+
* Returns null when there is nothing to ask about — outside a git tree, or
|
|
31
|
+
* when the server is unreachable. A failure here must never fail `setup`:
|
|
32
|
+
* the local configuration it just wrote is valid regardless.
|
|
33
|
+
*/
|
|
34
|
+
export declare function fetchRepoStatus(cwd?: string): Promise<RepoStatusResponse | null>;
|
|
35
|
+
export declare function attributionCheck(status: RepoStatusResponse): RepoCheck;
|
|
36
|
+
export declare function commitLaneCheck(status: RepoStatusResponse): RepoCheck;
|
|
37
|
+
export declare function repoChecks(status: RepoStatusResponse): RepoCheck[];
|
|
38
|
+
/** The block `agent setup` prints after writing the local configuration. */
|
|
39
|
+
export declare function renderRepoBlock(status: RepoStatusResponse): string[];
|
|
40
|
+
//# sourceMappingURL=repo-guidance.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repo-guidance.d.ts","sourceRoot":"","sources":["../../../src/lib/repo-guidance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAO1D;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,OAAO,GAAG,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,GAAG,SAAgB,GAClB,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAapC;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,kBAAkB,GAAG,SAAS,CAqDtE;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,kBAAkB,GAAG,SAAS,CAqCrE;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,kBAAkB,GAAG,SAAS,EAAE,CAElE;AAED,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,EAAE,CAepE"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ApiError, get } from "./api.js";
|
|
2
|
+
import { getApiUrl } from "./config.js";
|
|
3
|
+
import { formatAgo } from "./format.js";
|
|
4
|
+
import { getRepositoryRemote } from "./git.js";
|
|
5
|
+
import { RepoStatusSchema } from "./validators.js";
|
|
6
|
+
/**
|
|
7
|
+
* Fetches readiness for the repository the CLI is standing in.
|
|
8
|
+
*
|
|
9
|
+
* Returns null when there is nothing to ask about — outside a git tree, or
|
|
10
|
+
* when the server is unreachable. A failure here must never fail `setup`:
|
|
11
|
+
* the local configuration it just wrote is valid regardless.
|
|
12
|
+
*/
|
|
13
|
+
export async function fetchRepoStatus(cwd = process.cwd()) {
|
|
14
|
+
const remote = getRepositoryRemote(cwd);
|
|
15
|
+
if (!remote)
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
const data = await get(`/api/agent/repo-status?repositoryUrl=${encodeURIComponent(remote)}`);
|
|
19
|
+
return RepoStatusSchema.parse(data);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof ApiError)
|
|
23
|
+
return null;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function attributionCheck(status) {
|
|
28
|
+
const { attribution, repository } = status;
|
|
29
|
+
const where = repository?.displayUrl ?? "this repository";
|
|
30
|
+
switch (attribution.reason) {
|
|
31
|
+
case "attributed":
|
|
32
|
+
return {
|
|
33
|
+
label: "Project",
|
|
34
|
+
ok: true,
|
|
35
|
+
detail: attribution.projectNames[0] ?? "attached",
|
|
36
|
+
};
|
|
37
|
+
case "no_matching_project":
|
|
38
|
+
return {
|
|
39
|
+
label: "Project",
|
|
40
|
+
ok: false,
|
|
41
|
+
detail: `${where} is not attached to any project`,
|
|
42
|
+
fix: "Attach it under Projects → (your project) → Repositories.\n" +
|
|
43
|
+
" Until then this repo's agent spend is recorded, but lands in\n" +
|
|
44
|
+
" the unattributed row instead of against a client.",
|
|
45
|
+
};
|
|
46
|
+
case "ambiguous_project":
|
|
47
|
+
return {
|
|
48
|
+
label: "Project",
|
|
49
|
+
ok: false,
|
|
50
|
+
// Refusing to guess is the designed behaviour, not a bug — billing
|
|
51
|
+
// one client for another's work is worse than billing nobody.
|
|
52
|
+
detail: `${where} is attached to ${attribution.projectNames.length} projects (${attribution.projectNames.join(", ")})`,
|
|
53
|
+
fix: "Clocktopus will not guess which client to bill, so these sessions\n" +
|
|
54
|
+
" stay unattributed. Remove the repository from all but one project.",
|
|
55
|
+
};
|
|
56
|
+
case "unsupported_remote":
|
|
57
|
+
return {
|
|
58
|
+
label: "Project",
|
|
59
|
+
ok: "warn",
|
|
60
|
+
detail: "remote is not GitHub or Bitbucket",
|
|
61
|
+
fix: "Spend from this repository is recorded but cannot be attributed —\n" +
|
|
62
|
+
" only GitHub and Bitbucket remotes resolve to a project.",
|
|
63
|
+
};
|
|
64
|
+
case "no_repository":
|
|
65
|
+
return {
|
|
66
|
+
label: "Project",
|
|
67
|
+
ok: "warn",
|
|
68
|
+
detail: "not a git repository",
|
|
69
|
+
fix: "Run 'clocktopus agent setup' inside a project checkout to check attribution.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function commitLaneCheck(status) {
|
|
74
|
+
const dashboard = getApiUrl().replace(/\/$/, "");
|
|
75
|
+
switch (status.commitLane.status) {
|
|
76
|
+
case "delivering":
|
|
77
|
+
return {
|
|
78
|
+
label: "Commit lane",
|
|
79
|
+
ok: true,
|
|
80
|
+
detail: `last commit received ${formatAgo(status.commitLane.lastCommitAt)}`,
|
|
81
|
+
};
|
|
82
|
+
case "no_secret":
|
|
83
|
+
return {
|
|
84
|
+
label: "Commit lane",
|
|
85
|
+
ok: false,
|
|
86
|
+
detail: "no webhook secret — no commits are reaching Clocktopus",
|
|
87
|
+
fix: `Create one at ${dashboard}/settings/webhooks, then add a push\n` +
|
|
88
|
+
` webhook to this repository. Guide:\n` +
|
|
89
|
+
` ${dashboard}/docs/guides/setup-github-webhook`,
|
|
90
|
+
};
|
|
91
|
+
case "no_delivery":
|
|
92
|
+
return {
|
|
93
|
+
label: "Commit lane",
|
|
94
|
+
ok: false,
|
|
95
|
+
detail: "no commit has ever arrived from this repository",
|
|
96
|
+
fix: "Agent spend will be recorded here, but with no human time beside it\n" +
|
|
97
|
+
" the true-cost comparison has only one side. Add a push webhook:\n" +
|
|
98
|
+
` URL ${status.webhookUrl}\n` +
|
|
99
|
+
" Events push\n" +
|
|
100
|
+
` Secret ${dashboard}/settings/webhooks\n` +
|
|
101
|
+
" The URL is per person, not per repository — on a shared repo\n" +
|
|
102
|
+
" each teammate adds their own.",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function repoChecks(status) {
|
|
107
|
+
return [attributionCheck(status), commitLaneCheck(status)];
|
|
108
|
+
}
|
|
109
|
+
/** The block `agent setup` prints after writing the local configuration. */
|
|
110
|
+
export function renderRepoBlock(status) {
|
|
111
|
+
const lines = [];
|
|
112
|
+
const heading = status.repository?.displayUrl ?? "this directory";
|
|
113
|
+
lines.push("");
|
|
114
|
+
lines.push(`Repository ${heading}`);
|
|
115
|
+
lines.push("");
|
|
116
|
+
for (const check of repoChecks(status)) {
|
|
117
|
+
const symbol = check.ok === true ? "✓" : check.ok === "warn" ? "⚠" : "✗";
|
|
118
|
+
lines.push(` ${symbol} ${check.label.padEnd(12)}${check.detail}`);
|
|
119
|
+
if (check.ok !== true && check.fix)
|
|
120
|
+
lines.push(` ${check.fix}`);
|
|
121
|
+
}
|
|
122
|
+
return lines;
|
|
123
|
+
}
|