@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
|
@@ -1,45 +1,62 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
4
5
|
import { findShadowedExports, maskToken, resolveHookCommand, } from "../../lib/agent-config.js";
|
|
5
6
|
import { verifyReceiver } from "../../lib/agent-receiver.js";
|
|
7
|
+
import { isConfigParseError, surveyAgents } from "../../lib/agents.js";
|
|
6
8
|
import { ApiError, get, post } from "../../lib/api.js";
|
|
7
|
-
import { applyTelemetrySettings, claudeConfigDir, readInstalledTelemetry, readSettings, SettingsParseError, writeSettings, } from "../../lib/claude-settings.js";
|
|
8
9
|
import { isLoggedIn, setAgentConfig } from "../../lib/config.js";
|
|
9
10
|
import { labelled } from "../../lib/format.js";
|
|
10
11
|
import { fetchRepoStatus, renderRepoBlock } from "../../lib/repo-guidance.js";
|
|
11
12
|
import { AgentEndpointSchema, IngestTokenSchema, UserSchema, } from "../../lib/validators.js";
|
|
12
13
|
/**
|
|
13
|
-
* Configures this machine to report
|
|
14
|
+
* Configures this machine's coding agents to report spend to Clocktopus.
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
16
|
+
* One command, however many agents are installed. It looks for each
|
|
17
|
+
* supported CLI on PATH and configures what it finds — silently when
|
|
18
|
+
* there is only one, after a prompt when there are several, and from
|
|
19
|
+
* `--agent` when there is neither a person nor a terminal to ask.
|
|
20
|
+
*
|
|
21
|
+
* Everything for a given agent lands in that agent's own config, and
|
|
22
|
+
* nowhere else. A single source of truth per agent is not a tidiness
|
|
23
|
+
* preference: it is what makes `agent doctor` able to say which value is in
|
|
24
|
+
* force. Configuration spread over `.envrc`, a shell profile and a CLI
|
|
25
|
+
* config file can be reported on but never resolved, and this project
|
|
26
|
+
* already lost real spend to exactly that (one session split across two
|
|
27
|
+
* accounts, silently, because two files disagreed about the token).
|
|
23
28
|
*
|
|
24
29
|
* Idempotent by default. Re-running repairs the hooks and refreshes the
|
|
25
30
|
* endpoint while keeping the existing token, so the common case — "did my
|
|
26
|
-
* setup drift?" — costs nothing
|
|
31
|
+
* setup drift?" — costs nothing, and adding a newly-installed agent later
|
|
32
|
+
* reuses the token the first one already has. `--force` mints a
|
|
33
|
+
* replacement instead.
|
|
27
34
|
*/
|
|
28
35
|
export async function setupCommand(options) {
|
|
29
36
|
if (!isLoggedIn()) {
|
|
30
37
|
console.error("Not logged in. Run 'clocktopus login' first.");
|
|
31
38
|
process.exit(1);
|
|
32
39
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
const survey = surveyAgents();
|
|
41
|
+
const unreadable = survey.filter((entry) => entry.unreadable);
|
|
42
|
+
if (unreadable.length > 0) {
|
|
43
|
+
for (const entry of unreadable)
|
|
44
|
+
console.error(`✗ ${entry.unreadable}`);
|
|
45
|
+
process.exit(1);
|
|
36
46
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
const installed = survey.filter((entry) => entry.installed);
|
|
48
|
+
if (installed.length === 0) {
|
|
49
|
+
console.error("No supported coding agent found on PATH.");
|
|
50
|
+
console.error(`\nClocktopus can track: ${survey
|
|
51
|
+
.map((entry) => `${entry.agent.label} (${entry.agent.binary})`)
|
|
52
|
+
.join(", ")}.`);
|
|
53
|
+
console.error("Install one, or re-run with --agent <id> if yours lives somewhere PATH\ncannot see.");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const selected = await selectAgents(survey, installed, options.agent);
|
|
57
|
+
if (selected.length === 0) {
|
|
58
|
+
console.log("Nothing selected — no configuration was changed.");
|
|
59
|
+
return;
|
|
43
60
|
}
|
|
44
61
|
let email = null;
|
|
45
62
|
let endpoint;
|
|
@@ -55,14 +72,15 @@ export async function setupCommand(options) {
|
|
|
55
72
|
reportApiFailure(error, "Failed to reach Clocktopus");
|
|
56
73
|
return;
|
|
57
74
|
}
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// broken hook path
|
|
62
|
-
|
|
75
|
+
// One machine, one token, however many agents. Reuse before minting:
|
|
76
|
+
// every mint leaves another live token on the account, and a machine that
|
|
77
|
+
// already has a working one has nothing to gain from a second — the
|
|
78
|
+
// reason to re-run setup is almost always a broken hook path or a newly
|
|
79
|
+
// installed agent, not a bad token.
|
|
80
|
+
let token = options.force ? null : findExistingToken(survey);
|
|
63
81
|
let tokenId = null;
|
|
64
82
|
let minted = false;
|
|
65
|
-
if (token
|
|
83
|
+
if (token) {
|
|
66
84
|
const check = await verifyReceiver(endpoint, token);
|
|
67
85
|
if (!check.ok) {
|
|
68
86
|
console.log(check.reason === "invalid_token"
|
|
@@ -71,13 +89,10 @@ export async function setupCommand(options) {
|
|
|
71
89
|
token = null;
|
|
72
90
|
}
|
|
73
91
|
}
|
|
74
|
-
else if (options.force) {
|
|
75
|
-
token = null;
|
|
76
|
-
}
|
|
77
92
|
if (!token) {
|
|
78
93
|
try {
|
|
79
94
|
const response = await post("/api/agent/ingest-token", {
|
|
80
|
-
name: options.name ??
|
|
95
|
+
name: options.name ?? hostname(),
|
|
81
96
|
}).then((data) => IngestTokenSchema.parse(data));
|
|
82
97
|
token = response.token;
|
|
83
98
|
tokenId = response.tokenId;
|
|
@@ -89,19 +104,30 @@ export async function setupCommand(options) {
|
|
|
89
104
|
return;
|
|
90
105
|
}
|
|
91
106
|
}
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
const configured = [];
|
|
108
|
+
for (const entry of selected) {
|
|
109
|
+
const hook = resolveHookCommand(entry.agent.provider);
|
|
110
|
+
try {
|
|
111
|
+
const { backupPaths } = entry.agent.apply({
|
|
112
|
+
token,
|
|
113
|
+
endpoint,
|
|
114
|
+
hookCommand: hook.command,
|
|
115
|
+
});
|
|
116
|
+
configured.push({
|
|
117
|
+
entry,
|
|
118
|
+
hookCommand: hook.command,
|
|
119
|
+
hookUsesAbsolutePath: hook.usesAbsolutePath,
|
|
120
|
+
backupPaths,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
// One agent failing must not strand the others half-configured, so
|
|
125
|
+
// the loop continues and the failure is reported in the summary.
|
|
126
|
+
console.error(`✗ ${entry.agent.label}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
127
|
+
}
|
|
102
128
|
}
|
|
103
|
-
|
|
104
|
-
console.error(
|
|
129
|
+
if (configured.length === 0) {
|
|
130
|
+
console.error("\nNothing was configured.");
|
|
105
131
|
process.exit(1);
|
|
106
132
|
}
|
|
107
133
|
setAgentConfig({
|
|
@@ -116,10 +142,15 @@ export async function setupCommand(options) {
|
|
|
116
142
|
console.log(labelled("Account", email));
|
|
117
143
|
console.log(labelled("Receiver", endpoint));
|
|
118
144
|
console.log(labelled("Token", `${maskToken(token)}${minted ? " (new)" : " (reused)"}`));
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
145
|
+
for (const { entry, hookCommand, backupPaths } of configured) {
|
|
146
|
+
console.log(`\n${entry.agent.label} ${entry.version ?? ""}`.trimEnd());
|
|
147
|
+
for (const path of entry.agent.paths()) {
|
|
148
|
+
console.log(labelled("Config", path));
|
|
149
|
+
}
|
|
150
|
+
console.log(labelled("Hook", `${hookCommand} (SessionStart, SessionEnd)`));
|
|
151
|
+
for (const backup of backupPaths)
|
|
152
|
+
console.log(labelled("Backup", backup));
|
|
153
|
+
}
|
|
123
154
|
console.log("");
|
|
124
155
|
console.log(check.ok
|
|
125
156
|
? "✓ The receiver accepted this token."
|
|
@@ -133,43 +164,151 @@ export async function setupCommand(options) {
|
|
|
133
164
|
for (const line of renderRepoBlock(repoStatus))
|
|
134
165
|
console.log(line);
|
|
135
166
|
}
|
|
136
|
-
for (const warning of collectWarnings({
|
|
137
|
-
hookUsesAbsolutePath: hook.usesAbsolutePath,
|
|
138
|
-
})) {
|
|
167
|
+
for (const warning of collectWarnings(configured)) {
|
|
139
168
|
console.log(`\n${warning}`);
|
|
140
169
|
}
|
|
141
170
|
// The single most common reason a correct setup appears dead. Both the
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
console.log(
|
|
171
|
+
// exporter and the hook read their configuration once, when the process
|
|
172
|
+
// starts, so a session already running will never see any of this.
|
|
173
|
+
console.log(`\nRestart ${labelList(configured.map((c) => c.entry.agent.label))} — the exporter and the hook\nread their configuration once, at process start.`);
|
|
174
|
+
// Agent-specific steps a person still has to take, most importantly
|
|
175
|
+
// Codex's hook trust prompt, which no command can answer for them.
|
|
176
|
+
for (const { entry } of configured) {
|
|
177
|
+
for (const action of entry.agent.pendingActions()) {
|
|
178
|
+
console.log(`\n→ ${entry.agent.label}: ${action}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
145
181
|
console.log("\nThen confirm it end to end: clocktopus agent status");
|
|
146
182
|
}
|
|
147
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Decides which agents to configure, asking only when the answer is
|
|
185
|
+
* genuinely ambiguous.
|
|
186
|
+
*
|
|
187
|
+
* The precedence is about respecting what the caller already told us:
|
|
188
|
+
* `--agent` is an explicit instruction, a lone install has no decision in
|
|
189
|
+
* it, and a non-interactive shell has nobody to ask — so only a person at a
|
|
190
|
+
* terminal with a real choice ever sees a prompt.
|
|
191
|
+
*/
|
|
192
|
+
async function selectAgents(survey, installed, requested) {
|
|
193
|
+
if (requested && requested.length > 0) {
|
|
194
|
+
const ids = requested.flatMap((value) => value.split(",").map((part) => part.trim().toLowerCase()));
|
|
195
|
+
const chosen = survey.filter((entry) => ids.includes(entry.agent.id));
|
|
196
|
+
const unknown = ids.filter((id) => !survey.some((entry) => entry.agent.id === id));
|
|
197
|
+
if (unknown.length > 0) {
|
|
198
|
+
console.error(`Unknown agent: ${unknown.join(", ")}. Valid ids: ${survey
|
|
199
|
+
.map((entry) => entry.agent.id)
|
|
200
|
+
.join(", ")}.`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
// Deliberately not filtered by `installed`: --agent is an instruction,
|
|
204
|
+
// and someone pointing it at an agent PATH cannot see has said what they
|
|
205
|
+
// want. Setup writes the config; the agent picks it up when it appears.
|
|
206
|
+
for (const entry of chosen) {
|
|
207
|
+
if (!entry.installed) {
|
|
208
|
+
console.log(`⚠ ${entry.agent.label} was not found on PATH — configuring it anyway.`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return chosen;
|
|
212
|
+
}
|
|
213
|
+
if (installed.length === 1)
|
|
214
|
+
return installed;
|
|
215
|
+
if (!process.stdin.isTTY) {
|
|
216
|
+
console.log(`Configuring every agent found: ${labelList(installed.map((entry) => entry.agent.label))}.\nPass --agent <id> to narrow it.`);
|
|
217
|
+
return installed;
|
|
218
|
+
}
|
|
219
|
+
console.log("\nAgents on this machine\n");
|
|
220
|
+
installed.forEach((entry, index) => {
|
|
221
|
+
const state = entry.configured ? "already configured" : "not configured";
|
|
222
|
+
console.log(` ${index + 1} ${entry.agent.label.padEnd(14)}${(entry.version ?? "").padEnd(10)}${state}`);
|
|
223
|
+
});
|
|
224
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
225
|
+
try {
|
|
226
|
+
const answer = (await rl.question("\nWhich should report to Clocktopus? [all]: ")).trim();
|
|
227
|
+
if (answer === "" || answer.toLowerCase() === "all")
|
|
228
|
+
return installed;
|
|
229
|
+
const picked = answer
|
|
230
|
+
.split(/[,\s]+/)
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
.flatMap((part) => {
|
|
233
|
+
const index = Number(part);
|
|
234
|
+
if (Number.isInteger(index) && index >= 1 && index <= installed.length)
|
|
235
|
+
return [installed[index - 1]];
|
|
236
|
+
const byId = installed.find((entry) => entry.agent.id === part.toLowerCase() ||
|
|
237
|
+
entry.agent.binary === part.toLowerCase());
|
|
238
|
+
return byId ? [byId] : [];
|
|
239
|
+
});
|
|
240
|
+
return [...new Set(picked)];
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
rl.close();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The token this machine already has, from whichever agent holds one.
|
|
248
|
+
*
|
|
249
|
+
* Reading across agents is what makes "install Codex next month and re-run
|
|
250
|
+
* setup" cost nothing: the new agent inherits the token the old one has
|
|
251
|
+
* been using rather than minting a second one against the same machine.
|
|
252
|
+
*/
|
|
253
|
+
function findExistingToken(survey) {
|
|
254
|
+
for (const entry of survey) {
|
|
255
|
+
try {
|
|
256
|
+
const token = entry.agent.read().token;
|
|
257
|
+
if (token)
|
|
258
|
+
return token;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (!isConfigParseError(error))
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
function collectWarnings(configured) {
|
|
148
268
|
const warnings = [];
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
269
|
+
// Only Claude Code applies config `env` over the inherited environment,
|
|
270
|
+
// so only Claude Code can silently shadow a shell export. Codex reads its
|
|
271
|
+
// token straight out of config.toml and has nothing to shadow.
|
|
272
|
+
if (configured.some(({ entry }) => entry.agent.id === "claude")) {
|
|
273
|
+
const shadowed = findShadowedExports();
|
|
274
|
+
if (shadowed.length > 0) {
|
|
275
|
+
const lines = shadowed
|
|
276
|
+
.map(({ path, keys }) => ` ${path} — ${keys.join(", ")}`)
|
|
277
|
+
.join("\n");
|
|
278
|
+
warnings.push(`⚠ These files export the same variables:\n${lines}\n` +
|
|
279
|
+
" settings.json is applied over the inherited environment, so those\n" +
|
|
280
|
+
" exports are now ignored inside Claude Code. Remove them — two\n" +
|
|
281
|
+
" sources that disagree is how spend ends up on the wrong account.");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// A versioned config directory means the ingest token is about to be
|
|
160
285
|
// committed. Worth saying plainly; the CLI cannot prevent it.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
286
|
+
const versioned = new Set();
|
|
287
|
+
for (const { entry } of configured) {
|
|
288
|
+
for (const path of entry.agent.paths()) {
|
|
289
|
+
const dir = dirname(path);
|
|
290
|
+
if (existsSync(join(dir, ".git")))
|
|
291
|
+
versioned.add(dir);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const dir of versioned) {
|
|
295
|
+
warnings.push(`⚠ ${dir} is a git repository, and its config now holds your ingest\n` +
|
|
296
|
+
" token in plaintext. Ignore the file, or revoke with\n" +
|
|
164
297
|
" 'clocktopus agent disable --revoke' if it gets committed.");
|
|
165
298
|
}
|
|
166
|
-
|
|
299
|
+
const absolute = configured.filter((c) => c.hookUsesAbsolutePath);
|
|
300
|
+
if (absolute.length > 0) {
|
|
167
301
|
warnings.push("⚠ 'clocktopus' is not on PATH as this executable, so the hook was\n" +
|
|
168
302
|
" installed with an absolute path. Reinstalling the CLI elsewhere will\n" +
|
|
169
303
|
" break it — re-run 'clocktopus agent setup' if that happens.");
|
|
170
304
|
}
|
|
171
305
|
return warnings;
|
|
172
306
|
}
|
|
307
|
+
function labelList(labels) {
|
|
308
|
+
if (labels.length <= 1)
|
|
309
|
+
return labels[0] ?? "";
|
|
310
|
+
return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`;
|
|
311
|
+
}
|
|
173
312
|
function describeCheck(check) {
|
|
174
313
|
switch (check.reason) {
|
|
175
314
|
case "invalid_token":
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/status.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/status.ts"],"names":[],"mappings":"AA2BA,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAoFnD"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { differenceInMilliseconds, parseISO } from "date-fns";
|
|
2
2
|
import { readLastRun } from "../../lib/agent-hook-state.js";
|
|
3
|
+
import { isConfigParseError, surveyAgents } from "../../lib/agents.js";
|
|
3
4
|
import { ApiError, get } from "../../lib/api.js";
|
|
4
|
-
import { readInstalledTelemetry, SettingsParseError, } from "../../lib/claude-settings.js";
|
|
5
5
|
import { isLoggedIn } from "../../lib/config.js";
|
|
6
6
|
import { formatAgo, labelled, microUsdToDisplay } from "../../lib/format.js";
|
|
7
7
|
import { AgentStatusSchema } from "../../lib/validators.js";
|
|
@@ -9,13 +9,13 @@ import { AgentStatusSchema } from "../../lib/validators.js";
|
|
|
9
9
|
* Answers "is agent telemetry actually connected?" from received data.
|
|
10
10
|
*
|
|
11
11
|
* Deliberately server-first. Local configuration is a claim — the files can
|
|
12
|
-
* be perfect while nothing arrives, because
|
|
13
|
-
*
|
|
12
|
+
* be perfect while nothing arrives, because an exporter reads its
|
|
13
|
+
* configuration once at process start, agents swallow OTLP transport
|
|
14
14
|
* errors, and a revoked token produces a 401 nobody sees.
|
|
15
15
|
*
|
|
16
16
|
* The two lanes are reported separately because they fail separately, and
|
|
17
|
-
* the asymmetric failure is the expensive one: hooks landing while
|
|
18
|
-
*
|
|
17
|
+
* the asymmetric failure is the expensive one: hooks landing while spend
|
|
18
|
+
* does not produces sessions with a repository, a branch and no cost, which
|
|
19
19
|
* reads as cheap work rather than a severed pipeline. A single "connected"
|
|
20
20
|
* line would hide precisely the case worth surfacing.
|
|
21
21
|
*/
|
|
@@ -55,7 +55,7 @@ export async function statusCommand() {
|
|
|
55
55
|
if (status.hookLane.attributed === false) {
|
|
56
56
|
console.log(labelled("", "not attributed to a project — see 'agent doctor'", 14));
|
|
57
57
|
}
|
|
58
|
-
console.log(labelled("
|
|
58
|
+
console.log(labelled("Spend lane", `${mark(metricsAge)} ${formatAgo(status.metricsLane.lastExportReceivedAt)}`, 14));
|
|
59
59
|
if (status.metricsLane.lastModel) {
|
|
60
60
|
console.log(labelled("", status.metricsLane.lastModel, 14));
|
|
61
61
|
}
|
|
@@ -109,15 +109,15 @@ function diagnose(input) {
|
|
|
109
109
|
if (hookLive && !metricsLive) {
|
|
110
110
|
return [
|
|
111
111
|
"⚠ Sessions are arriving with repository context, but no spend is.\n" +
|
|
112
|
-
" The
|
|
113
|
-
" show $0.00, which is indistinguishable from cheap work. Check
|
|
114
|
-
"
|
|
112
|
+
" The telemetry exporter is not reaching the receiver — every session\n" +
|
|
113
|
+
" will show $0.00, which is indistinguishable from cheap work. Check\n" +
|
|
114
|
+
" the exporter config with 'clocktopus agent doctor'.",
|
|
115
115
|
];
|
|
116
116
|
}
|
|
117
117
|
if (metricsLive && !hookLive) {
|
|
118
118
|
return [
|
|
119
119
|
"⚠ Spend is arriving, but no repository context is. The SessionStart /\n" +
|
|
120
|
-
" SessionEnd hook is not running, and
|
|
120
|
+
" SessionEnd hook is not running, and no telemetry stream carries path\n" +
|
|
121
121
|
" data at all — so these sessions cannot be attributed to a project.\n" +
|
|
122
122
|
" Check the hook with 'clocktopus agent doctor'.",
|
|
123
123
|
];
|
|
@@ -126,21 +126,32 @@ function diagnose(input) {
|
|
|
126
126
|
}
|
|
127
127
|
function readLocalSummary() {
|
|
128
128
|
const lines = [];
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// Every agent, not just the configured ones: an installed agent that is
|
|
130
|
+
// *not* reporting is the thing worth seeing here, and it is invisible in
|
|
131
|
+
// the server-side lanes above — its sessions simply never arrive.
|
|
132
|
+
const survey = surveyAgents().filter((entry) => entry.installed || entry.configured || entry.unreadable);
|
|
133
|
+
if (survey.length === 0) {
|
|
134
|
+
lines.push(labelled("Agents", "none found on PATH"));
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
let anyConfigured = false;
|
|
138
|
+
for (const entry of survey) {
|
|
139
|
+
if (entry.unreadable) {
|
|
140
|
+
lines.push(labelled(entry.agent.label, "config unreadable — see 'agent doctor'"));
|
|
141
|
+
continue;
|
|
134
142
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (error instanceof SettingsParseError) {
|
|
139
|
-
lines.push(labelled("Config", `unreadable — ${error.message}`));
|
|
140
|
-
return lines;
|
|
143
|
+
if (!entry.configured) {
|
|
144
|
+
lines.push(labelled(entry.agent.label, `not reporting — run 'clocktopus agent setup --agent ${entry.agent.id}'`));
|
|
145
|
+
continue;
|
|
141
146
|
}
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
anyConfigured = true;
|
|
148
|
+
const pending = safePendingActions(entry.agent);
|
|
149
|
+
lines.push(labelled(entry.agent.label, pending.length > 0
|
|
150
|
+
? "configured, waiting on approval — see 'agent doctor'"
|
|
151
|
+
: "configured"));
|
|
152
|
+
}
|
|
153
|
+
if (!anyConfigured)
|
|
154
|
+
return lines;
|
|
144
155
|
// The hook's own receipt of its last run. It cannot print or warn during a
|
|
145
156
|
// session, so this is the only place a 401 or a connection failure from
|
|
146
157
|
// the machine's side becomes visible.
|
|
@@ -151,10 +162,21 @@ function readLocalSummary() {
|
|
|
151
162
|
: lastRun.status >= 200 && lastRun.status < 300
|
|
152
163
|
? `HTTP ${lastRun.status}`
|
|
153
164
|
: `HTTP ${lastRun.status} — rejected`;
|
|
154
|
-
lines.push(labelled("Last hook", `${formatAgo(lastRun.at)} (${lastRun.event ?? "?"}) ${outcome}`));
|
|
165
|
+
lines.push(labelled("Last hook", `${formatAgo(lastRun.at)} (${lastRun.event ?? "?"}${lastRun.provider ? `, ${lastRun.provider}` : ""}) ${outcome}`));
|
|
155
166
|
}
|
|
156
167
|
else {
|
|
157
168
|
lines.push(labelled("Last hook", "has not run since setup"));
|
|
158
169
|
}
|
|
159
170
|
return lines;
|
|
160
171
|
}
|
|
172
|
+
/** `pendingActions` reads config; a broken file must not fail `status`. */
|
|
173
|
+
function safePendingActions(agent) {
|
|
174
|
+
try {
|
|
175
|
+
return agent.pendingActions();
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (isConfigParseError(error))
|
|
179
|
+
return [];
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
package/dist/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAyKA,wBAAgB,GAAG,IAAI,IAAI,CAE1B"}
|
package/dist/src/index.js
CHANGED
|
@@ -16,6 +16,10 @@ const parsedJson = z
|
|
|
16
16
|
.object({ version: z.string() })
|
|
17
17
|
.safeParse(require("../../package.json"));
|
|
18
18
|
const version = parsedJson.success ? parsedJson.data.version : "0.0.0";
|
|
19
|
+
/** Lets `--agent` be repeated as well as comma-separated. */
|
|
20
|
+
function collect(value, previous = []) {
|
|
21
|
+
return [...previous, value];
|
|
22
|
+
}
|
|
19
23
|
const program = new Command();
|
|
20
24
|
program
|
|
21
25
|
.name("clocktopus")
|
|
@@ -71,9 +75,10 @@ const agent = program
|
|
|
71
75
|
.description("Track AI agent spend from this machine");
|
|
72
76
|
agent
|
|
73
77
|
.command("setup")
|
|
74
|
-
.description("Point
|
|
78
|
+
.description("Point your coding agents at Clocktopus and install the session hooks")
|
|
75
79
|
.option("--name <name>", "Label for this machine's ingest token")
|
|
76
80
|
.option("--force", "Mint a replacement token instead of reusing the existing one")
|
|
81
|
+
.option("-a, --agent <ids>", "Configure only these agents (claude, codex). Repeatable or comma-separated.", collect)
|
|
77
82
|
.action((options) => setupCommand(options));
|
|
78
83
|
agent
|
|
79
84
|
.command("status")
|
|
@@ -87,14 +92,23 @@ agent
|
|
|
87
92
|
.command("disable")
|
|
88
93
|
.description("Remove this machine's telemetry configuration")
|
|
89
94
|
.option("--revoke", "Also revoke the ingest token, everywhere")
|
|
95
|
+
.option("-a, --agent <ids>", "Remove only these agents (claude, codex). Repeatable or comma-separated.", collect)
|
|
90
96
|
.action((options) => disableCommand(options));
|
|
91
|
-
// Invoked by
|
|
97
|
+
// Invoked by an agent, never by a person: it reads the hook payload from
|
|
92
98
|
// stdin and must not write to stdout. Hidden so it does not read as
|
|
93
99
|
// something to run by hand.
|
|
100
|
+
//
|
|
101
|
+
// `--provider` is not a convenience. Claude Code and Codex send byte-identical
|
|
102
|
+
// hook payloads, so the flag baked into the installed command is the only
|
|
103
|
+
// thing that says which agent is calling.
|
|
94
104
|
agent
|
|
95
105
|
.command("hook", { hidden: true })
|
|
96
|
-
.description("Internal:
|
|
97
|
-
.
|
|
106
|
+
.description("Internal: SessionStart/SessionEnd hook")
|
|
107
|
+
.option("--provider <provider>", "Which agent installed this hook")
|
|
108
|
+
// Set only by the hook when it re-spawns itself for an agent that will
|
|
109
|
+
// not background it. Marks the copy that does the work.
|
|
110
|
+
.option("--detached", "Internal: this is the backgrounded copy")
|
|
111
|
+
.action((options) => hookCommand(options));
|
|
98
112
|
export function run() {
|
|
99
113
|
program.parse();
|
|
100
114
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AgentId, AgentProviderId } from "./agents.js";
|
|
1
2
|
/**
|
|
2
3
|
* Where the ingest credentials come from, and what shadows what.
|
|
3
4
|
*
|
|
@@ -8,6 +9,13 @@
|
|
|
8
9
|
* shell profile, silently. That is the failure this resolver is built to
|
|
9
10
|
* make visible rather than to paper over: it reports the source alongside
|
|
10
11
|
* the value so `doctor` can name the file that is winning.
|
|
12
|
+
*
|
|
13
|
+
* Codex is the mirror image and needs its own branch. It injects nothing
|
|
14
|
+
* into the hook's environment, so `~/.codex/config.toml` is not one source
|
|
15
|
+
* among several — it is the only one, and the token has to be read back out
|
|
16
|
+
* of the exporter's `Authorization` header. Nothing shadows it, which is
|
|
17
|
+
* why the Codex path cannot produce the split-brain failure the Claude path
|
|
18
|
+
* is written to detect.
|
|
11
19
|
*/
|
|
12
20
|
export type CredentialSource = "environment" | "settings" | "none";
|
|
13
21
|
export type ResolvedAgentCredentials = {
|
|
@@ -15,8 +23,10 @@ export type ResolvedAgentCredentials = {
|
|
|
15
23
|
endpoint: string | null;
|
|
16
24
|
tokenSource: CredentialSource;
|
|
17
25
|
endpointSource: CredentialSource;
|
|
26
|
+
/** The file `settings` refers to, so `doctor` can name it. */
|
|
27
|
+
sourcePath: string;
|
|
18
28
|
};
|
|
19
|
-
export declare function resolveAgentCredentials(): ResolvedAgentCredentials;
|
|
29
|
+
export declare function resolveAgentCredentials(agent?: AgentId): ResolvedAgentCredentials;
|
|
20
30
|
/** Masks a token for display: never print more than the stored prefix. */
|
|
21
31
|
export declare function maskToken(token: string): string;
|
|
22
32
|
export declare function findShadowedExports(cwd?: string): Array<{
|
|
@@ -24,7 +34,7 @@ export declare function findShadowedExports(cwd?: string): Array<{
|
|
|
24
34
|
keys: string[];
|
|
25
35
|
}>;
|
|
26
36
|
/**
|
|
27
|
-
* The command to write into
|
|
37
|
+
* The command to write into an agent's hook configuration.
|
|
28
38
|
*
|
|
29
39
|
* Prefers the bare name, but only after confirming that `clocktopus` on
|
|
30
40
|
* PATH resolves to *this* executable. Hooks run through a shell whose PATH
|
|
@@ -32,8 +42,13 @@ export declare function findShadowedExports(cwd?: string): Array<{
|
|
|
32
42
|
* resolve there fails silently — the session runs fine and every bit of
|
|
33
43
|
* repository context is lost. An absolute path is uglier and survives that;
|
|
34
44
|
* `doctor` checks it still exists.
|
|
45
|
+
*
|
|
46
|
+
* The provider is baked into the command because the two agents send
|
|
47
|
+
* *identical* hook payloads — same field names, same event spellings, no
|
|
48
|
+
* marker of any kind. Which agent is calling can only be known from how the
|
|
49
|
+
* hook was installed.
|
|
35
50
|
*/
|
|
36
|
-
export declare function resolveHookCommand(): {
|
|
51
|
+
export declare function resolveHookCommand(provider: AgentProviderId): {
|
|
37
52
|
command: string;
|
|
38
53
|
usesAbsolutePath: boolean;
|
|
39
54
|
binaryPath: string | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-config.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-config.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"agent-config.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-config.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAK5D;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,UAAU,GAAG,MAAM,CAAC;AAEnE,MAAM,MAAM,wBAAwB,GAAG;IACrC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,gBAAgB,CAAC;IAC9B,cAAc,EAAE,gBAAgB,CAAC;IACjC,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,wBAAgB,uBAAuB,CACrC,KAAK,GAAE,OAAkB,GACxB,wBAAwB,CA0C1B;AAED,0EAA0E;AAC1E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/C;AAmBD,wBAAgB,mBAAmB,CAAC,GAAG,SAAgB,GAAG,KAAK,CAAC;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB,CAAC,CA+BD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,eAAe,GAAG;IAC7D,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CAgCA"}
|