@blastin-dev/clocktopus-cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -36
- 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 +157 -95
- 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 +239 -98
- package/dist/src/commands/agent/setup.d.ts +1 -16
- package/dist/src/commands/agent/setup.d.ts.map +1 -1
- package/dist/src/commands/agent/setup.js +198 -81
- 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 +5 -23
- package/dist/src/lib/agent-config.d.ts.map +1 -1
- package/dist/src/lib/agent-config.js +50 -36
- package/dist/src/lib/agent-hook-state.d.ts +11 -7
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
- package/dist/src/lib/agent-hook-state.js +34 -29
- package/dist/src/lib/agents.d.ts +52 -0
- package/dist/src/lib/agents.d.ts.map +1 -0
- package/dist/src/lib/agents.js +238 -0
- package/dist/src/lib/claude-settings.d.ts +0 -36
- package/dist/src/lib/claude-settings.d.ts.map +1 -1
- package/dist/src/lib/claude-settings.js +37 -62
- package/dist/src/lib/codex-config.d.ts +87 -0
- package/dist/src/lib/codex-config.d.ts.map +1 -0
- package/dist/src/lib/codex-config.js +399 -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/declared-commits.d.ts +43 -0
- package/dist/src/lib/declared-commits.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.js +114 -0
- package/dist/src/lib/declared-commits.test.d.ts +2 -0
- package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.test.js +129 -0
- package/dist/src/lib/git-remotes.d.ts +9 -0
- package/dist/src/lib/git-remotes.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.js +50 -0
- package/dist/src/lib/git-remotes.test.d.ts +2 -0
- package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.test.js +52 -0
- package/dist/src/lib/opencode-config.d.ts +23 -0
- package/dist/src/lib/opencode-config.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.js +290 -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,55 @@
|
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*/
|
|
13
|
+
// Configures this machine's coding agents to report spend to Clocktopus.
|
|
14
|
+
//
|
|
15
|
+
// One command, however many agents are installed: it configures what it finds on PATH —
|
|
16
|
+
// silently when there is only one, after a prompt when there are several, and from
|
|
17
|
+
// `--agent` when there is neither a person nor a terminal to ask.
|
|
18
|
+
//
|
|
19
|
+
// Everything for a given agent lands in that agent's own config and nowhere else. A
|
|
20
|
+
// single source of truth per agent is what makes `agent doctor` able to say which value
|
|
21
|
+
// is in force; configuration spread over `.envrc`, a shell profile and a CLI config can
|
|
22
|
+
// be reported on but never resolved, and this project already lost real spend to exactly
|
|
23
|
+
// that.
|
|
24
|
+
//
|
|
25
|
+
// Idempotent by default: re-running repairs the hooks and refreshes the endpoint while
|
|
26
|
+
// keeping the existing token, so adding a newly-installed agent later reuses it.
|
|
27
|
+
// `--force` mints a replacement.
|
|
28
28
|
export async function setupCommand(options) {
|
|
29
29
|
if (!isLoggedIn()) {
|
|
30
30
|
console.error("Not logged in. Run 'clocktopus login' first.");
|
|
31
31
|
process.exit(1);
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const survey = surveyAgents();
|
|
34
|
+
const unreadable = survey.filter((entry) => entry.unreadable);
|
|
35
|
+
if (unreadable.length > 0) {
|
|
36
|
+
for (const entry of unreadable)
|
|
37
|
+
console.error(`✗ ${entry.unreadable}`);
|
|
38
|
+
process.exit(1);
|
|
36
39
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const installed = survey.filter((entry) => entry.installed);
|
|
41
|
+
if (installed.length === 0) {
|
|
42
|
+
console.error("No supported coding agent found on PATH.");
|
|
43
|
+
console.error(`\nClocktopus can track: ${survey
|
|
44
|
+
.map((entry) => `${entry.agent.label} (${entry.agent.binary})`)
|
|
45
|
+
.join(", ")}.`);
|
|
46
|
+
console.error("Install one, or re-run with --agent <id> if yours lives somewhere PATH\ncannot see.");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
const selected = await selectAgents(survey, installed, options.agent);
|
|
50
|
+
if (selected.length === 0) {
|
|
51
|
+
console.log("Nothing selected — no configuration was changed.");
|
|
52
|
+
return;
|
|
43
53
|
}
|
|
44
54
|
let email = null;
|
|
45
55
|
let endpoint;
|
|
@@ -55,14 +65,13 @@ export async function setupCommand(options) {
|
|
|
55
65
|
reportApiFailure(error, "Failed to reach Clocktopus");
|
|
56
66
|
return;
|
|
57
67
|
}
|
|
58
|
-
//
|
|
59
|
-
// account, and
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
let token = installed.env.CLOCKTOPUS_INGEST_TOKEN ?? null;
|
|
68
|
+
// One machine, one token, however many agents. Every mint leaves another live token on
|
|
69
|
+
// the account, and the reason to re-run setup is almost always a broken hook path or a
|
|
70
|
+
// newly installed agent, not a bad token.
|
|
71
|
+
let token = options.force ? null : findExistingToken(survey);
|
|
63
72
|
let tokenId = null;
|
|
64
73
|
let minted = false;
|
|
65
|
-
if (token
|
|
74
|
+
if (token) {
|
|
66
75
|
const check = await verifyReceiver(endpoint, token);
|
|
67
76
|
if (!check.ok) {
|
|
68
77
|
console.log(check.reason === "invalid_token"
|
|
@@ -71,13 +80,10 @@ export async function setupCommand(options) {
|
|
|
71
80
|
token = null;
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
|
-
else if (options.force) {
|
|
75
|
-
token = null;
|
|
76
|
-
}
|
|
77
83
|
if (!token) {
|
|
78
84
|
try {
|
|
79
85
|
const response = await post("/api/agent/ingest-token", {
|
|
80
|
-
name: options.name ??
|
|
86
|
+
name: options.name ?? hostname(),
|
|
81
87
|
}).then((data) => IngestTokenSchema.parse(data));
|
|
82
88
|
token = response.token;
|
|
83
89
|
tokenId = response.tokenId;
|
|
@@ -89,19 +95,29 @@ export async function setupCommand(options) {
|
|
|
89
95
|
return;
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
const configured = [];
|
|
99
|
+
for (const entry of selected) {
|
|
100
|
+
const hook = resolveHookCommand(entry.agent.provider);
|
|
101
|
+
try {
|
|
102
|
+
const { backupPaths } = entry.agent.apply({
|
|
103
|
+
token,
|
|
104
|
+
endpoint,
|
|
105
|
+
hookCommand: hook.command,
|
|
106
|
+
});
|
|
107
|
+
configured.push({
|
|
108
|
+
entry,
|
|
109
|
+
hookCommand: hook.command,
|
|
110
|
+
hookUsesAbsolutePath: hook.usesAbsolutePath,
|
|
111
|
+
backupPaths,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
// One agent failing must not strand the others half-configured.
|
|
116
|
+
console.error(`✗ ${entry.agent.label}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
117
|
+
}
|
|
102
118
|
}
|
|
103
|
-
|
|
104
|
-
console.error(
|
|
119
|
+
if (configured.length === 0) {
|
|
120
|
+
console.error("\nNothing was configured.");
|
|
105
121
|
process.exit(1);
|
|
106
122
|
}
|
|
107
123
|
setAgentConfig({
|
|
@@ -116,60 +132,161 @@ export async function setupCommand(options) {
|
|
|
116
132
|
console.log(labelled("Account", email));
|
|
117
133
|
console.log(labelled("Receiver", endpoint));
|
|
118
134
|
console.log(labelled("Token", `${maskToken(token)}${minted ? " (new)" : " (reused)"}`));
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
for (const { entry, hookCommand, backupPaths } of configured) {
|
|
136
|
+
console.log(`\n${entry.agent.label} ${entry.version ?? ""}`.trimEnd());
|
|
137
|
+
for (const path of entry.agent.paths()) {
|
|
138
|
+
console.log(labelled("Config", path));
|
|
139
|
+
}
|
|
140
|
+
console.log(labelled("Hook", `${hookCommand} (SessionStart, SessionEnd)`));
|
|
141
|
+
for (const backup of backupPaths)
|
|
142
|
+
console.log(labelled("Backup", backup));
|
|
143
|
+
}
|
|
123
144
|
console.log("");
|
|
124
145
|
console.log(check.ok
|
|
125
146
|
? "✓ The receiver accepted this token."
|
|
126
147
|
: `✗ The receiver did not accept this token (${describeCheck(check)}).`);
|
|
127
|
-
// Configuring this machine is only half the job: sessions will arrive,
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
148
|
+
// Configuring this machine is only half the job: sessions will arrive, but whether they
|
|
149
|
+
// mean anything depends on the repository being attached to a project and on commits
|
|
150
|
+
// reaching us. Both are invisible from here, and staying quiet is how spend ends up
|
|
151
|
+
// stranded.
|
|
131
152
|
const repoStatus = await fetchRepoStatus();
|
|
132
153
|
if (repoStatus) {
|
|
133
154
|
for (const line of renderRepoBlock(repoStatus))
|
|
134
155
|
console.log(line);
|
|
135
156
|
}
|
|
136
|
-
for (const warning of collectWarnings({
|
|
137
|
-
hookUsesAbsolutePath: hook.usesAbsolutePath,
|
|
138
|
-
})) {
|
|
157
|
+
for (const warning of collectWarnings(configured)) {
|
|
139
158
|
console.log(`\n${warning}`);
|
|
140
159
|
}
|
|
141
|
-
// The single most common reason a correct setup appears dead
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
// The single most common reason a correct setup appears dead: both the exporter and the
|
|
161
|
+
// hook read their configuration once, at process start.
|
|
162
|
+
console.log(`\nRestart ${labelList(configured.map((c) => c.entry.agent.label))} — the exporter and the hook\nread their configuration once, at process start.`);
|
|
163
|
+
// Agent-specific steps a person still has to take, most importantly Codex's hook trust
|
|
164
|
+
// prompt, which no command can answer for them.
|
|
165
|
+
for (const { entry } of configured) {
|
|
166
|
+
for (const action of entry.agent.pendingActions()) {
|
|
167
|
+
console.log(`\n→ ${entry.agent.label}: ${action}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
145
170
|
console.log("\nThen confirm it end to end: clocktopus agent status");
|
|
146
171
|
}
|
|
147
|
-
|
|
172
|
+
// Decides which agents to configure, asking only when the answer is genuinely ambiguous.
|
|
173
|
+
// `--agent` is an explicit instruction, a lone install has no decision in it, and a
|
|
174
|
+
// non-interactive shell has nobody to ask — so only a person at a terminal with a real
|
|
175
|
+
// choice ever sees a prompt.
|
|
176
|
+
async function selectAgents(survey, installed, requested) {
|
|
177
|
+
if (requested && requested.length > 0) {
|
|
178
|
+
const ids = requested.flatMap((value) => value.split(",").map((part) => part.trim().toLowerCase()));
|
|
179
|
+
const chosen = survey.filter((entry) => ids.includes(entry.agent.id));
|
|
180
|
+
const unknown = ids.filter((id) => !survey.some((entry) => entry.agent.id === id));
|
|
181
|
+
if (unknown.length > 0) {
|
|
182
|
+
console.error(`Unknown agent: ${unknown.join(", ")}. Valid ids: ${survey
|
|
183
|
+
.map((entry) => entry.agent.id)
|
|
184
|
+
.join(", ")}.`);
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
// Deliberately not filtered by `installed`: `--agent` is an instruction, and someone
|
|
188
|
+
// pointing it at an agent PATH cannot see has said what they want. Setup writes the
|
|
189
|
+
// config; the agent picks it up when it appears.
|
|
190
|
+
for (const entry of chosen) {
|
|
191
|
+
if (!entry.installed) {
|
|
192
|
+
console.log(`⚠ ${entry.agent.label} was not found on PATH — configuring it anyway.`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return chosen;
|
|
196
|
+
}
|
|
197
|
+
if (installed.length === 1)
|
|
198
|
+
return installed;
|
|
199
|
+
if (!process.stdin.isTTY) {
|
|
200
|
+
console.log(`Configuring every agent found: ${labelList(installed.map((entry) => entry.agent.label))}.\nPass --agent <id> to narrow it.`);
|
|
201
|
+
return installed;
|
|
202
|
+
}
|
|
203
|
+
console.log("\nAgents on this machine\n");
|
|
204
|
+
installed.forEach((entry, index) => {
|
|
205
|
+
const state = entry.configured ? "already configured" : "not configured";
|
|
206
|
+
console.log(` ${index + 1} ${entry.agent.label.padEnd(14)}${(entry.version ?? "").padEnd(10)}${state}`);
|
|
207
|
+
});
|
|
208
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
209
|
+
try {
|
|
210
|
+
const answer = (await rl.question("\nWhich should report to Clocktopus? [all]: ")).trim();
|
|
211
|
+
if (answer === "" || answer.toLowerCase() === "all")
|
|
212
|
+
return installed;
|
|
213
|
+
const picked = answer
|
|
214
|
+
.split(/[,\s]+/)
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.flatMap((part) => {
|
|
217
|
+
const index = Number(part);
|
|
218
|
+
if (Number.isInteger(index) && index >= 1 && index <= installed.length)
|
|
219
|
+
return [installed[index - 1]];
|
|
220
|
+
const byId = installed.find((entry) => entry.agent.id === part.toLowerCase() ||
|
|
221
|
+
entry.agent.binary === part.toLowerCase());
|
|
222
|
+
return byId ? [byId] : [];
|
|
223
|
+
});
|
|
224
|
+
return [...new Set(picked)];
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
rl.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// The token this machine already has, from whichever agent holds one. Reading across
|
|
231
|
+
// agents is what makes "install Codex next month and re-run setup" cost nothing.
|
|
232
|
+
function findExistingToken(survey) {
|
|
233
|
+
for (const entry of survey) {
|
|
234
|
+
try {
|
|
235
|
+
const token = entry.agent.read().token;
|
|
236
|
+
if (token)
|
|
237
|
+
return token;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (!isConfigParseError(error))
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
function collectWarnings(configured) {
|
|
148
247
|
const warnings = [];
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
248
|
+
// Only Claude Code applies config `env` over the inherited environment, so only it can
|
|
249
|
+
// silently shadow a shell export. Codex reads its token straight out of config.toml.
|
|
250
|
+
if (configured.some(({ entry }) => entry.agent.id === "claude")) {
|
|
251
|
+
const shadowed = findShadowedExports();
|
|
252
|
+
if (shadowed.length > 0) {
|
|
253
|
+
const lines = shadowed
|
|
254
|
+
.map(({ path, keys }) => ` ${path} — ${keys.join(", ")}`)
|
|
255
|
+
.join("\n");
|
|
256
|
+
warnings.push(`⚠ These files export the same variables:\n${lines}\n` +
|
|
257
|
+
" settings.json is applied over the inherited environment, so those\n" +
|
|
258
|
+
" exports are now ignored inside Claude Code. Remove them — two\n" +
|
|
259
|
+
" sources that disagree is how spend ends up on the wrong account.");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// A versioned config directory means the ingest token is about to be committed. Worth
|
|
263
|
+
// saying plainly; the CLI cannot prevent it.
|
|
264
|
+
const versioned = new Set();
|
|
265
|
+
for (const { entry } of configured) {
|
|
266
|
+
for (const path of entry.agent.paths()) {
|
|
267
|
+
const dir = dirname(path);
|
|
268
|
+
if (existsSync(join(dir, ".git")))
|
|
269
|
+
versioned.add(dir);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const dir of versioned) {
|
|
273
|
+
warnings.push(`⚠ ${dir} is a git repository, and its config now holds your ingest\n` +
|
|
274
|
+
" token in plaintext. Ignore the file, or revoke with\n" +
|
|
164
275
|
" 'clocktopus agent disable --revoke' if it gets committed.");
|
|
165
276
|
}
|
|
166
|
-
|
|
277
|
+
const absolute = configured.filter((c) => c.hookUsesAbsolutePath);
|
|
278
|
+
if (absolute.length > 0) {
|
|
167
279
|
warnings.push("⚠ 'clocktopus' is not on PATH as this executable, so the hook was\n" +
|
|
168
280
|
" installed with an absolute path. Reinstalling the CLI elsewhere will\n" +
|
|
169
281
|
" break it — re-run 'clocktopus agent setup' if that happens.");
|
|
170
282
|
}
|
|
171
283
|
return warnings;
|
|
172
284
|
}
|
|
285
|
+
function labelList(labels) {
|
|
286
|
+
if (labels.length <= 1)
|
|
287
|
+
return labels[0] ?? "";
|
|
288
|
+
return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`;
|
|
289
|
+
}
|
|
173
290
|
function describeCheck(check) {
|
|
174
291
|
switch (check.reason) {
|
|
175
292
|
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,39 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
* Where the ingest credentials come from, and what shadows what.
|
|
3
|
-
*
|
|
4
|
-
* The order matters and is not arbitrary. Inside a Claude Code session the
|
|
5
|
-
* process environment is what the exporter and the hook actually read, and
|
|
6
|
-
* `settings.json` `env` is applied *over* the inherited shell environment —
|
|
7
|
-
* so a value in settings.json wins over one exported from `.envrc` or a
|
|
8
|
-
* shell profile, silently. That is the failure this resolver is built to
|
|
9
|
-
* make visible rather than to paper over: it reports the source alongside
|
|
10
|
-
* the value so `doctor` can name the file that is winning.
|
|
11
|
-
*/
|
|
1
|
+
import type { AgentId, AgentProviderId } from "./agents.js";
|
|
12
2
|
export type CredentialSource = "environment" | "settings" | "none";
|
|
13
3
|
export type ResolvedAgentCredentials = {
|
|
14
4
|
token: string | null;
|
|
15
5
|
endpoint: string | null;
|
|
16
6
|
tokenSource: CredentialSource;
|
|
17
7
|
endpointSource: CredentialSource;
|
|
8
|
+
/** The file `settings` refers to, so `doctor` can name it. */
|
|
9
|
+
sourcePath: string;
|
|
18
10
|
};
|
|
19
|
-
export declare function resolveAgentCredentials(): ResolvedAgentCredentials;
|
|
11
|
+
export declare function resolveAgentCredentials(agent?: AgentId): ResolvedAgentCredentials;
|
|
20
12
|
/** Masks a token for display: never print more than the stored prefix. */
|
|
21
13
|
export declare function maskToken(token: string): string;
|
|
22
14
|
export declare function findShadowedExports(cwd?: string): Array<{
|
|
23
15
|
path: string;
|
|
24
16
|
keys: string[];
|
|
25
17
|
}>;
|
|
26
|
-
|
|
27
|
-
* The command to write into settings.json for the hook.
|
|
28
|
-
*
|
|
29
|
-
* Prefers the bare name, but only after confirming that `clocktopus` on
|
|
30
|
-
* PATH resolves to *this* executable. Hooks run through a shell whose PATH
|
|
31
|
-
* may differ from the interactive one, and a bare name that does not
|
|
32
|
-
* resolve there fails silently — the session runs fine and every bit of
|
|
33
|
-
* repository context is lost. An absolute path is uglier and survives that;
|
|
34
|
-
* `doctor` checks it still exists.
|
|
35
|
-
*/
|
|
36
|
-
export declare function resolveHookCommand(): {
|
|
18
|
+
export declare function resolveHookCommand(provider: AgentProviderId): {
|
|
37
19
|
command: string;
|
|
38
20
|
usesAbsolutePath: boolean;
|
|
39
21
|
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;AAkB5D,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,CAyC1B;AAED,0EAA0E;AAC1E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/C;AAcD,wBAAgB,mBAAmB,CAAC,GAAG,SAAgB,GAAG,KAAK,CAAC;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB,CAAC,CA+BD;AAYD,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"}
|