@blastin-dev/clocktopus-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +91 -36
  2. package/dist/src/commands/agent/disable.d.ts +8 -1
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -1
  4. package/dist/src/commands/agent/disable.js +79 -45
  5. package/dist/src/commands/agent/doctor.d.ts.map +1 -1
  6. package/dist/src/commands/agent/doctor.js +157 -95
  7. package/dist/src/commands/agent/hook.d.ts +4 -1
  8. package/dist/src/commands/agent/hook.d.ts.map +1 -1
  9. package/dist/src/commands/agent/hook.js +239 -98
  10. package/dist/src/commands/agent/setup.d.ts +1 -16
  11. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  12. package/dist/src/commands/agent/setup.js +198 -81
  13. package/dist/src/commands/agent/status.d.ts.map +1 -1
  14. package/dist/src/commands/agent/status.js +46 -24
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +18 -4
  17. package/dist/src/lib/agent-config.d.ts +5 -23
  18. package/dist/src/lib/agent-config.d.ts.map +1 -1
  19. package/dist/src/lib/agent-config.js +50 -36
  20. package/dist/src/lib/agent-hook-state.d.ts +11 -7
  21. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  22. package/dist/src/lib/agent-hook-state.js +34 -29
  23. package/dist/src/lib/agents.d.ts +52 -0
  24. package/dist/src/lib/agents.d.ts.map +1 -0
  25. package/dist/src/lib/agents.js +238 -0
  26. package/dist/src/lib/claude-settings.d.ts +0 -36
  27. package/dist/src/lib/claude-settings.d.ts.map +1 -1
  28. package/dist/src/lib/claude-settings.js +37 -62
  29. package/dist/src/lib/codex-config.d.ts +87 -0
  30. package/dist/src/lib/codex-config.d.ts.map +1 -0
  31. package/dist/src/lib/codex-config.js +399 -0
  32. package/dist/src/lib/codex-config.test.d.ts +2 -0
  33. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  34. package/dist/src/lib/codex-config.test.js +359 -0
  35. package/dist/src/lib/declared-commits.d.ts +43 -0
  36. package/dist/src/lib/declared-commits.d.ts.map +1 -0
  37. package/dist/src/lib/declared-commits.js +114 -0
  38. package/dist/src/lib/declared-commits.test.d.ts +2 -0
  39. package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
  40. package/dist/src/lib/declared-commits.test.js +129 -0
  41. package/dist/src/lib/git-remotes.d.ts +9 -0
  42. package/dist/src/lib/git-remotes.d.ts.map +1 -0
  43. package/dist/src/lib/git-remotes.js +50 -0
  44. package/dist/src/lib/git-remotes.test.d.ts +2 -0
  45. package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
  46. package/dist/src/lib/git-remotes.test.js +52 -0
  47. package/dist/src/lib/opencode-config.d.ts +23 -0
  48. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  49. package/dist/src/lib/opencode-config.js +290 -0
  50. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  51. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  52. package/dist/src/lib/opencode-config.test.js +140 -0
  53. package/package.json +2 -1
@@ -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
- * Configures this machine to report agent spend to Clocktopus.
14
- *
15
- * Everything lands in one file `~/.claude/settings.json` holding both
16
- * the exporter's environment and the session hooks. A single source of
17
- * truth is not a tidiness preference: it is what makes `agent doctor` able
18
- * to say which value is in force. Configuration spread over `.envrc`, a
19
- * shell profile and a CLI config file can be reported on but never
20
- * resolved, and this project already lost real spend to exactly that (one
21
- * session split across two accounts, silently, because two files disagreed
22
- * about the token).
23
- *
24
- * Idempotent by default. Re-running repairs the hooks and refreshes the
25
- * endpoint while keeping the existing token, so the common case "did my
26
- * setup drift?" — costs nothing. `--force` mints a replacement instead.
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
- let installed;
34
- try {
35
- installed = readInstalledTelemetry();
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
- catch (error) {
38
- if (error instanceof SettingsParseError) {
39
- console.error(`✗ ${error.message}`);
40
- process.exit(1);
41
- }
42
- throw error;
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
- // Reuse before minting. Every mint leaves another live token on the
59
- // account, and a machine that already has a working one has nothing to
60
- // gain from a second the reason to re-run setup is almost always a
61
- // broken hook path, not a bad token.
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 && !options.force) {
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 ?? `${hostname()} (claude code)`,
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 hook = resolveHookCommand();
93
- let backupPath = null;
94
- try {
95
- const current = readSettings();
96
- const next = applyTelemetrySettings(current.settings, {
97
- token,
98
- endpoint,
99
- hookCommand: hook.command,
100
- });
101
- ({ backupPath } = writeSettings(next, current.path));
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
- catch (error) {
104
- console.error(`✗ Could not write ${installed.path}: ${error instanceof Error ? error.message : "unknown 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
- console.log(labelled("Settings", installed.path));
120
- console.log(labelled("Hook", `${hook.command} (SessionStart, SessionEnd)`));
121
- if (backupPath)
122
- console.log(labelled("Backup", backupPath));
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
- // but whether they mean anything depends on the repository being attached
129
- // to a project and on commits reaching us. Both are invisible from here,
130
- // and staying quiet about them is how spend ends up stranded.
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. Both the
142
- // OTLP exporter and the hook read their configuration once, when the
143
- // process starts, so a session already running will never see any of this.
144
- console.log("\nRestart Claude Code the exporter and the hook read their configuration\nonce, at process start.");
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
- function collectWarnings(input) {
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
- const shadowed = findShadowedExports();
150
- if (shadowed.length > 0) {
151
- const lines = shadowed
152
- .map(({ path, keys }) => ` ${path} — ${keys.join(", ")}`)
153
- .join("\n");
154
- warnings.push(`⚠ These files export the same variables:\n${lines}\n` +
155
- " settings.json is applied over the inherited environment, so those\n" +
156
- " exports are now ignored inside Claude Code. Remove them — two\n" +
157
- " sources that disagree is how spend ends up on the wrong account.");
158
- }
159
- // A versioned settings.json means the ingest token is about to be
160
- // committed. Worth saying plainly; the CLI cannot prevent it.
161
- if (existsSync(join(claudeConfigDir(), ".git"))) {
162
- warnings.push(`⚠ ${claudeConfigDir()} is a git repository, and settings.json now holds\n` +
163
- " your ingest token in plaintext. Ignore the file, or revoke with\n" +
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
- if (input.hookUsesAbsolutePath) {
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":"AA8BA,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAoFnD"}
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 the exporter reads its
13
- * environment once at process start, Claude Code swallows OTLP transport
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 metrics
18
- * do not produces sessions with a repository, a branch and no spend, which
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("Metrics lane", `${mark(metricsAge)} ${formatAgo(status.metricsLane.lastExportReceivedAt)}`, 14));
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 OTLP exporter is not reaching the receiver — every session will\n" +
113
- " show $0.00, which is indistinguishable from cheap work. Check the\n" +
114
- " OTEL_* values with 'clocktopus agent doctor'.",
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 the metric stream carries no path\n" +
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
- try {
130
- const installed = readInstalledTelemetry();
131
- if (!installed.exists || Object.keys(installed.env).length === 0) {
132
- lines.push(labelled("Config", "not set up here run 'clocktopus agent setup'"));
133
- return lines;
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
- lines.push(labelled("Config", installed.path));
136
- }
137
- catch (error) {
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
- throw error;
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
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AA4IA,wBAAgB,GAAG,IAAI,IAAI,CAE1B"}
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 Claude Code at Clocktopus and install the session hooks")
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 Claude Code, never by a person: it reads the hook payload from
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: Claude Code SessionStart/SessionEnd hook")
97
- .action(hookCommand);
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":"AAYA;;;;;;;;;;GAUG;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;CAClC,CAAC;AAEF,wBAAgB,uBAAuB,IAAI,wBAAwB,CA2BlE;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;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,IAAI;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CA8BA"}
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"}