@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.
Files changed (38) hide show
  1. package/README.md +89 -34
  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 +146 -75
  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 +152 -15
  10. package/dist/src/commands/agent/setup.d.ts +17 -10
  11. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  12. package/dist/src/commands/agent/setup.js +208 -69
  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 +18 -3
  18. package/dist/src/lib/agent-config.d.ts.map +1 -1
  19. package/dist/src/lib/agent-config.js +44 -19
  20. package/dist/src/lib/agent-hook-state.d.ts +9 -1
  21. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  22. package/dist/src/lib/agent-hook-state.js +21 -2
  23. package/dist/src/lib/agents.d.ts +115 -0
  24. package/dist/src/lib/agents.d.ts.map +1 -0
  25. package/dist/src/lib/agents.js +245 -0
  26. package/dist/src/lib/codex-config.d.ts +166 -0
  27. package/dist/src/lib/codex-config.d.ts.map +1 -0
  28. package/dist/src/lib/codex-config.js +441 -0
  29. package/dist/src/lib/codex-config.test.d.ts +2 -0
  30. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  31. package/dist/src/lib/codex-config.test.js +359 -0
  32. package/dist/src/lib/opencode-config.d.ts +108 -0
  33. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  34. package/dist/src/lib/opencode-config.js +330 -0
  35. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  36. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  37. package/dist/src/lib/opencode-config.test.js +140 -0
  38. 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 agent spend to Clocktopus.
14
+ * Configures this machine's coding agents to report spend to Clocktopus.
14
15
  *
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).
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. `--force` mints a replacement instead.
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
- let installed;
34
- try {
35
- installed = readInstalledTelemetry();
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
- catch (error) {
38
- if (error instanceof SettingsParseError) {
39
- console.error(`✗ ${error.message}`);
40
- process.exit(1);
41
- }
42
- throw error;
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
- // 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;
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 && !options.force) {
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 ?? `${hostname()} (claude code)`,
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 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));
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
- catch (error) {
104
- console.error(`✗ Could not write ${installed.path}: ${error instanceof Error ? error.message : "unknown 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
- console.log(labelled("Settings", installed.path));
120
- console.log(labelled("Hook", `${hook.command} (SessionStart, SessionEnd)`));
121
- if (backupPath)
122
- console.log(labelled("Backup", backupPath));
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
- // 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.");
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
- function collectWarnings(input) {
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
- 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
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
- 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" +
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
- if (input.hookUsesAbsolutePath) {
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":"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,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 settings.json for the hook.
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":"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;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"}