@blastin-dev/clocktopus-cli 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +163 -5
  2. package/dist/src/commands/agent/disable.d.ts +21 -0
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -0
  4. package/dist/src/commands/agent/disable.js +106 -0
  5. package/dist/src/commands/agent/doctor.d.ts +2 -0
  6. package/dist/src/commands/agent/doctor.d.ts.map +1 -0
  7. package/dist/src/commands/agent/doctor.js +306 -0
  8. package/dist/src/commands/agent/hook.d.ts +5 -0
  9. package/dist/src/commands/agent/hook.d.ts.map +1 -0
  10. package/dist/src/commands/agent/hook.js +368 -0
  11. package/dist/src/commands/agent/setup.d.ts +28 -0
  12. package/dist/src/commands/agent/setup.d.ts.map +1 -0
  13. package/dist/src/commands/agent/setup.js +333 -0
  14. package/dist/src/commands/agent/status.d.ts +2 -0
  15. package/dist/src/commands/agent/status.d.ts.map +1 -0
  16. package/dist/src/commands/agent/status.js +182 -0
  17. package/dist/src/index.d.ts.map +1 -1
  18. package/dist/src/index.js +51 -2
  19. package/dist/src/lib/agent-config.d.ts +56 -0
  20. package/dist/src/lib/agent-config.d.ts.map +1 -0
  21. package/dist/src/lib/agent-config.js +168 -0
  22. package/dist/src/lib/agent-hook-state.d.ts +44 -0
  23. package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
  24. package/dist/src/lib/agent-hook-state.js +155 -0
  25. package/dist/src/lib/agent-receiver.d.ts +26 -0
  26. package/dist/src/lib/agent-receiver.d.ts.map +1 -0
  27. package/dist/src/lib/agent-receiver.js +44 -0
  28. package/dist/src/lib/agents.d.ts +115 -0
  29. package/dist/src/lib/agents.d.ts.map +1 -0
  30. package/dist/src/lib/agents.js +245 -0
  31. package/dist/src/lib/claude-settings.d.ts +82 -0
  32. package/dist/src/lib/claude-settings.d.ts.map +1 -0
  33. package/dist/src/lib/claude-settings.js +271 -0
  34. package/dist/src/lib/claude-settings.test.d.ts +2 -0
  35. package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
  36. package/dist/src/lib/claude-settings.test.js +193 -0
  37. package/dist/src/lib/codex-config.d.ts +166 -0
  38. package/dist/src/lib/codex-config.d.ts.map +1 -0
  39. package/dist/src/lib/codex-config.js +441 -0
  40. package/dist/src/lib/codex-config.test.d.ts +2 -0
  41. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  42. package/dist/src/lib/codex-config.test.js +359 -0
  43. package/dist/src/lib/config.d.ts +23 -0
  44. package/dist/src/lib/config.d.ts.map +1 -1
  45. package/dist/src/lib/config.js +14 -0
  46. package/dist/src/lib/format.d.ts +6 -0
  47. package/dist/src/lib/format.d.ts.map +1 -0
  48. package/dist/src/lib/format.js +19 -0
  49. package/dist/src/lib/git.d.ts +3 -0
  50. package/dist/src/lib/git.d.ts.map +1 -0
  51. package/dist/src/lib/git.js +30 -0
  52. package/dist/src/lib/opencode-config.d.ts +108 -0
  53. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  54. package/dist/src/lib/opencode-config.js +330 -0
  55. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  56. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  57. package/dist/src/lib/opencode-config.test.js +140 -0
  58. package/dist/src/lib/repo-guidance.d.ts +40 -0
  59. package/dist/src/lib/repo-guidance.d.ts.map +1 -0
  60. package/dist/src/lib/repo-guidance.js +123 -0
  61. package/dist/src/lib/validators.d.ts +69 -0
  62. package/dist/src/lib/validators.d.ts.map +1 -1
  63. package/dist/src/lib/validators.js +69 -0
  64. package/package.json +7 -4
@@ -0,0 +1,333 @@
1
+ import { existsSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { findShadowedExports, maskToken, resolveHookCommand, } from "../../lib/agent-config.js";
6
+ import { verifyReceiver } from "../../lib/agent-receiver.js";
7
+ import { isConfigParseError, surveyAgents } from "../../lib/agents.js";
8
+ import { ApiError, get, post } from "../../lib/api.js";
9
+ import { isLoggedIn, setAgentConfig } from "../../lib/config.js";
10
+ import { labelled } from "../../lib/format.js";
11
+ import { fetchRepoStatus, renderRepoBlock } from "../../lib/repo-guidance.js";
12
+ import { AgentEndpointSchema, IngestTokenSchema, UserSchema, } from "../../lib/validators.js";
13
+ /**
14
+ * Configures this machine's coding agents to report spend to Clocktopus.
15
+ *
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).
28
+ *
29
+ * Idempotent by default. Re-running repairs the hooks and refreshes the
30
+ * endpoint while keeping the existing token, so the common case — "did my
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.
34
+ */
35
+ export async function setupCommand(options) {
36
+ if (!isLoggedIn()) {
37
+ console.error("Not logged in. Run 'clocktopus login' first.");
38
+ process.exit(1);
39
+ }
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);
46
+ }
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;
60
+ }
61
+ let email = null;
62
+ let endpoint;
63
+ try {
64
+ const [user, endpointResponse] = await Promise.all([
65
+ get("/api/auth/me").then((data) => UserSchema.parse(data)),
66
+ get("/api/agent/ingest-token").then((data) => AgentEndpointSchema.parse(data)),
67
+ ]);
68
+ email = user.user.email;
69
+ endpoint = endpointResponse.endpoint;
70
+ }
71
+ catch (error) {
72
+ reportApiFailure(error, "Failed to reach Clocktopus");
73
+ return;
74
+ }
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);
81
+ let tokenId = null;
82
+ let minted = false;
83
+ if (token) {
84
+ const check = await verifyReceiver(endpoint, token);
85
+ if (!check.ok) {
86
+ console.log(check.reason === "invalid_token"
87
+ ? "Existing token was rejected by the receiver — minting a replacement."
88
+ : "Could not confirm the existing token — minting a replacement.");
89
+ token = null;
90
+ }
91
+ }
92
+ if (!token) {
93
+ try {
94
+ const response = await post("/api/agent/ingest-token", {
95
+ name: options.name ?? hostname(),
96
+ }).then((data) => IngestTokenSchema.parse(data));
97
+ token = response.token;
98
+ tokenId = response.tokenId;
99
+ endpoint = response.endpoint;
100
+ minted = true;
101
+ }
102
+ catch (error) {
103
+ reportApiFailure(error, "Failed to mint an ingest token");
104
+ return;
105
+ }
106
+ }
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
+ }
128
+ }
129
+ if (configured.length === 0) {
130
+ console.error("\nNothing was configured.");
131
+ process.exit(1);
132
+ }
133
+ setAgentConfig({
134
+ tokenId: tokenId ?? undefined,
135
+ tokenPrefix: maskToken(token),
136
+ endpoint,
137
+ configuredAt: new Date().toISOString(),
138
+ });
139
+ const check = await verifyReceiver(endpoint, token);
140
+ console.log("\nClocktopus agent telemetry\n");
141
+ if (email)
142
+ console.log(labelled("Account", email));
143
+ console.log(labelled("Receiver", endpoint));
144
+ console.log(labelled("Token", `${maskToken(token)}${minted ? " (new)" : " (reused)"}`));
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
+ }
154
+ console.log("");
155
+ console.log(check.ok
156
+ ? "✓ The receiver accepted this token."
157
+ : `✗ The receiver did not accept this token (${describeCheck(check)}).`);
158
+ // Configuring this machine is only half the job: sessions will arrive,
159
+ // but whether they mean anything depends on the repository being attached
160
+ // to a project and on commits reaching us. Both are invisible from here,
161
+ // and staying quiet about them is how spend ends up stranded.
162
+ const repoStatus = await fetchRepoStatus();
163
+ if (repoStatus) {
164
+ for (const line of renderRepoBlock(repoStatus))
165
+ console.log(line);
166
+ }
167
+ for (const warning of collectWarnings(configured)) {
168
+ console.log(`\n${warning}`);
169
+ }
170
+ // The single most common reason a correct setup appears dead. Both the
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
+ }
181
+ console.log("\nThen confirm it end to end: clocktopus agent status");
182
+ }
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) {
268
+ const warnings = [];
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
285
+ // committed. Worth saying plainly; the CLI cannot prevent it.
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" +
297
+ " 'clocktopus agent disable --revoke' if it gets committed.");
298
+ }
299
+ const absolute = configured.filter((c) => c.hookUsesAbsolutePath);
300
+ if (absolute.length > 0) {
301
+ warnings.push("⚠ 'clocktopus' is not on PATH as this executable, so the hook was\n" +
302
+ " installed with an absolute path. Reinstalling the CLI elsewhere will\n" +
303
+ " break it — re-run 'clocktopus agent setup' if that happens.");
304
+ }
305
+ return warnings;
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
+ }
312
+ function describeCheck(check) {
313
+ switch (check.reason) {
314
+ case "invalid_token":
315
+ return "401 — token unknown or revoked";
316
+ case "unexpected_status":
317
+ return `HTTP ${check.status}`;
318
+ case "unreachable":
319
+ return check.message;
320
+ }
321
+ }
322
+ function reportApiFailure(error, prefix) {
323
+ if (error instanceof ApiError && error.status === 401) {
324
+ console.error("Session expired. Run 'clocktopus login' again.");
325
+ }
326
+ else if (error instanceof Error) {
327
+ console.error(`${prefix}: ${error.message}`);
328
+ }
329
+ else {
330
+ console.error(`${prefix}: unknown error`);
331
+ }
332
+ process.exit(1);
333
+ }
@@ -0,0 +1,2 @@
1
+ export declare function statusCommand(): Promise<void>;
2
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,182 @@
1
+ import { differenceInMilliseconds, parseISO } from "date-fns";
2
+ import { readLastRun } from "../../lib/agent-hook-state.js";
3
+ import { isConfigParseError, surveyAgents } from "../../lib/agents.js";
4
+ import { ApiError, get } from "../../lib/api.js";
5
+ import { isLoggedIn } from "../../lib/config.js";
6
+ import { formatAgo, labelled, microUsdToDisplay } from "../../lib/format.js";
7
+ import { AgentStatusSchema } from "../../lib/validators.js";
8
+ /**
9
+ * Answers "is agent telemetry actually connected?" from received data.
10
+ *
11
+ * Deliberately server-first. Local configuration is a claim — the files can
12
+ * be perfect while nothing arrives, because an exporter reads its
13
+ * configuration once at process start, agents swallow OTLP transport
14
+ * errors, and a revoked token produces a 401 nobody sees.
15
+ *
16
+ * The two lanes are reported separately because they fail separately, and
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
+ * reads as cheap work rather than a severed pipeline. A single "connected"
20
+ * line would hide precisely the case worth surfacing.
21
+ */
22
+ /** Past this, a lane has been quiet long enough to be worth flagging. */
23
+ const STALE_AFTER_MS = 24 * 60 * 60 * 1000;
24
+ export async function statusCommand() {
25
+ if (!isLoggedIn()) {
26
+ console.error("Not logged in. Run 'clocktopus login' first.");
27
+ process.exit(1);
28
+ }
29
+ let status;
30
+ try {
31
+ status = await get("/api/agent/status").then((data) => AgentStatusSchema.parse(data));
32
+ }
33
+ catch (error) {
34
+ if (error instanceof ApiError && error.status === 401) {
35
+ console.error("Session expired. Run 'clocktopus login' again.");
36
+ }
37
+ else {
38
+ console.error(`Failed to fetch agent status: ${error instanceof Error ? error.message : "unknown error"}`);
39
+ }
40
+ process.exit(1);
41
+ }
42
+ const hookAge = ageOf(status.hookLane.lastSessionStartedAt);
43
+ const metricsAge = ageOf(status.metricsLane.lastExportReceivedAt);
44
+ console.log("\nAgent telemetry\n");
45
+ console.log(labelled("Receiver", status.endpoint));
46
+ console.log("\nLanes (what the receiver has stored)\n");
47
+ console.log(labelled("Hook lane", `${mark(hookAge)} ${formatAgo(status.hookLane.lastSessionStartedAt)}`, 14));
48
+ if (status.hookLane.repositoryUrl || status.hookLane.cwd) {
49
+ const where = status.hookLane.repositoryUrl ?? status.hookLane.cwd;
50
+ const branch = status.hookLane.gitBranch
51
+ ? ` @ ${status.hookLane.gitBranch}`
52
+ : "";
53
+ console.log(labelled("", `${where}${branch}`, 14));
54
+ }
55
+ if (status.hookLane.attributed === false) {
56
+ console.log(labelled("", "not attributed to a project — see 'agent doctor'", 14));
57
+ }
58
+ console.log(labelled("Spend lane", `${mark(metricsAge)} ${formatAgo(status.metricsLane.lastExportReceivedAt)}`, 14));
59
+ if (status.metricsLane.lastModel) {
60
+ console.log(labelled("", status.metricsLane.lastModel, 14));
61
+ }
62
+ console.log("\nLast 7 days\n");
63
+ console.log(labelled("Sessions", String(status.window.sessions)));
64
+ console.log(labelled("Spend", microUsdToDisplay(status.window.costMicroUsd)));
65
+ if (status.window.unattributedSessions > 0) {
66
+ console.log(labelled("Unattributed", `${status.window.unattributedSessions} session(s) — no matching project`));
67
+ }
68
+ const local = readLocalSummary();
69
+ if (local.length > 0) {
70
+ console.log("\nThis machine\n");
71
+ for (const line of local)
72
+ console.log(line);
73
+ }
74
+ for (const diagnosis of diagnose({ hookAge, metricsAge })) {
75
+ console.log(`\n${diagnosis}`);
76
+ }
77
+ }
78
+ function ageOf(value) {
79
+ if (!value)
80
+ return null;
81
+ try {
82
+ return differenceInMilliseconds(new Date(), parseISO(value));
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ function mark(ageMs) {
89
+ if (ageMs === null)
90
+ return "✗";
91
+ return ageMs > STALE_AFTER_MS ? "⚠" : "✓";
92
+ }
93
+ /**
94
+ * The asymmetric-failure callouts — the reason the lanes are split.
95
+ *
96
+ * Each names the single next action, because "one lane is quiet" is only
97
+ * actionable once you know which half of the pipeline it implicates.
98
+ */
99
+ function diagnose(input) {
100
+ const { hookAge, metricsAge } = input;
101
+ const hookLive = hookAge !== null && hookAge <= STALE_AFTER_MS;
102
+ const metricsLive = metricsAge !== null && metricsAge <= STALE_AFTER_MS;
103
+ if (hookAge === null && metricsAge === null) {
104
+ return [
105
+ "✗ Nothing has ever arrived. Run 'clocktopus agent doctor' to find out\n" +
106
+ " where it stops.",
107
+ ];
108
+ }
109
+ if (hookLive && !metricsLive) {
110
+ return [
111
+ "⚠ Sessions are arriving with repository context, but no spend is.\n" +
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
+ ];
116
+ }
117
+ if (metricsLive && !hookLive) {
118
+ return [
119
+ "⚠ Spend is arriving, but no repository context is. The SessionStart /\n" +
120
+ " SessionEnd hook is not running, and no telemetry stream carries path\n" +
121
+ " data at all — so these sessions cannot be attributed to a project.\n" +
122
+ " Check the hook with 'clocktopus agent doctor'.",
123
+ ];
124
+ }
125
+ return [];
126
+ }
127
+ function readLocalSummary() {
128
+ const 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;
142
+ }
143
+ if (!entry.configured) {
144
+ lines.push(labelled(entry.agent.label, `not reporting — run 'clocktopus agent setup --agent ${entry.agent.id}'`));
145
+ continue;
146
+ }
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;
155
+ // The hook's own receipt of its last run. It cannot print or warn during a
156
+ // session, so this is the only place a 401 or a connection failure from
157
+ // the machine's side becomes visible.
158
+ const lastRun = readLastRun();
159
+ if (lastRun) {
160
+ const outcome = lastRun.status === null || lastRun.status === undefined
161
+ ? `failed — ${lastRun.error ?? "no response"}`
162
+ : lastRun.status >= 200 && lastRun.status < 300
163
+ ? `HTTP ${lastRun.status}`
164
+ : `HTTP ${lastRun.status} — rejected`;
165
+ lines.push(labelled("Last hook", `${formatAgo(lastRun.at)} (${lastRun.event ?? "?"}${lastRun.provider ? `, ${lastRun.provider}` : ""}) ${outcome}`));
166
+ }
167
+ else {
168
+ lines.push(labelled("Last hook", "has not run since setup"));
169
+ }
170
+ return lines;
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":"AAgGA,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
@@ -1,6 +1,11 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { Command } from "commander";
3
3
  import { z } from "zod";
4
+ import { disableCommand } from "./commands/agent/disable.js";
5
+ import { doctorCommand } from "./commands/agent/doctor.js";
6
+ import { hookCommand } from "./commands/agent/hook.js";
7
+ import { setupCommand } from "./commands/agent/setup.js";
8
+ import { statusCommand } from "./commands/agent/status.js";
4
9
  import { clockInCommand, clockOutCommand, clockStatusCommand, } from "./commands/clock.js";
5
10
  import { loginCommand } from "./commands/login.js";
6
11
  import { logoutCommand } from "./commands/logout.js";
@@ -11,17 +16,21 @@ const parsedJson = z
11
16
  .object({ version: z.string() })
12
17
  .safeParse(require("../../package.json"));
13
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
+ }
14
23
  const program = new Command();
15
24
  program
16
25
  .name("clocktopus")
17
26
  .description("CLI for Clocktopus time tracking")
18
27
  .version(version)
19
- .option("-e, --env <environment>", "Use environment (dev or prod)")
28
+ .option("-e, --env <environment>", "Use environment (dev, staging or prod)")
20
29
  .hook("preAction", (thisCommand) => {
21
30
  const opts = thisCommand.opts();
22
31
  if (opts.env) {
23
32
  if (!(opts.env in ENVIRONMENTS)) {
24
- console.error(`Invalid environment: ${opts.env}. Use 'dev' or 'prod'.`);
33
+ console.error(`Invalid environment: ${opts.env}. Use 'dev', 'staging' or 'prod'.`);
25
34
  process.exit(1);
26
35
  }
27
36
  setRuntimeEnvironment(opts.env);
@@ -60,6 +69,46 @@ clock
60
69
  .description("Show clock signals for a specific date")
61
70
  .option("-d, --date <date>", "Date in YYYY-MM-DD format (default: today)")
62
71
  .action(clockStatusCommand);
72
+ // Agent telemetry — tracks what AI agents cost, alongside human time.
73
+ const agent = program
74
+ .command("agent")
75
+ .description("Track AI agent spend from this machine");
76
+ agent
77
+ .command("setup")
78
+ .description("Point your coding agents at Clocktopus and install the session hooks")
79
+ .option("--name <name>", "Label for this machine's ingest token")
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)
82
+ .action((options) => setupCommand(options));
83
+ agent
84
+ .command("status")
85
+ .description("Show what the receiver has actually received")
86
+ .action(statusCommand);
87
+ agent
88
+ .command("doctor")
89
+ .description("Check every link in the telemetry chain and report failures")
90
+ .action(doctorCommand);
91
+ agent
92
+ .command("disable")
93
+ .description("Remove this machine's telemetry configuration")
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)
96
+ .action((options) => disableCommand(options));
97
+ // Invoked by an agent, never by a person: it reads the hook payload from
98
+ // stdin and must not write to stdout. Hidden so it does not read as
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.
104
+ agent
105
+ .command("hook", { hidden: true })
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));
63
112
  export function run() {
64
113
  program.parse();
65
114
  }
@@ -0,0 +1,56 @@
1
+ import type { AgentId, AgentProviderId } from "./agents.js";
2
+ /**
3
+ * Where the ingest credentials come from, and what shadows what.
4
+ *
5
+ * The order matters and is not arbitrary. Inside a Claude Code session the
6
+ * process environment is what the exporter and the hook actually read, and
7
+ * `settings.json` `env` is applied *over* the inherited shell environment —
8
+ * so a value in settings.json wins over one exported from `.envrc` or a
9
+ * shell profile, silently. That is the failure this resolver is built to
10
+ * make visible rather than to paper over: it reports the source alongside
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.
19
+ */
20
+ export type CredentialSource = "environment" | "settings" | "none";
21
+ export type ResolvedAgentCredentials = {
22
+ token: string | null;
23
+ endpoint: string | null;
24
+ tokenSource: CredentialSource;
25
+ endpointSource: CredentialSource;
26
+ /** The file `settings` refers to, so `doctor` can name it. */
27
+ sourcePath: string;
28
+ };
29
+ export declare function resolveAgentCredentials(agent?: AgentId): ResolvedAgentCredentials;
30
+ /** Masks a token for display: never print more than the stored prefix. */
31
+ export declare function maskToken(token: string): string;
32
+ export declare function findShadowedExports(cwd?: string): Array<{
33
+ path: string;
34
+ keys: string[];
35
+ }>;
36
+ /**
37
+ * The command to write into an agent's hook configuration.
38
+ *
39
+ * Prefers the bare name, but only after confirming that `clocktopus` on
40
+ * PATH resolves to *this* executable. Hooks run through a shell whose PATH
41
+ * may differ from the interactive one, and a bare name that does not
42
+ * resolve there fails silently — the session runs fine and every bit of
43
+ * repository context is lost. An absolute path is uglier and survives that;
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.
50
+ */
51
+ export declare function resolveHookCommand(provider: AgentProviderId): {
52
+ command: string;
53
+ usesAbsolutePath: boolean;
54
+ binaryPath: string | null;
55
+ };
56
+ //# sourceMappingURL=agent-config.d.ts.map
@@ -0,0 +1 @@
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"}