@blastin-dev/clocktopus-cli 0.1.3 → 0.2.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 (56) hide show
  1. package/README.md +124 -6
  2. package/dist/src/commands/agent/disable.d.ts +14 -0
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -0
  4. package/dist/src/commands/agent/disable.js +72 -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 +235 -0
  8. package/dist/src/commands/agent/hook.d.ts +2 -0
  9. package/dist/src/commands/agent/hook.d.ts.map +1 -0
  10. package/dist/src/commands/agent/hook.js +231 -0
  11. package/dist/src/commands/agent/setup.d.ts +21 -0
  12. package/dist/src/commands/agent/setup.d.ts.map +1 -0
  13. package/dist/src/commands/agent/setup.js +194 -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 +160 -0
  17. package/dist/src/commands/clock.d.ts +26 -4
  18. package/dist/src/commands/clock.d.ts.map +1 -1
  19. package/dist/src/commands/clock.js +99 -6
  20. package/dist/src/commands/login.d.ts.map +1 -1
  21. package/dist/src/commands/login.js +5 -5
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/index.js +45 -6
  24. package/dist/src/lib/agent-config.d.ts +41 -0
  25. package/dist/src/lib/agent-config.d.ts.map +1 -0
  26. package/dist/src/lib/agent-config.js +143 -0
  27. package/dist/src/lib/agent-hook-state.d.ts +36 -0
  28. package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
  29. package/dist/src/lib/agent-hook-state.js +136 -0
  30. package/dist/src/lib/agent-receiver.d.ts +26 -0
  31. package/dist/src/lib/agent-receiver.d.ts.map +1 -0
  32. package/dist/src/lib/agent-receiver.js +44 -0
  33. package/dist/src/lib/api.d.ts.map +1 -1
  34. package/dist/src/lib/api.js +28 -1
  35. package/dist/src/lib/claude-settings.d.ts +82 -0
  36. package/dist/src/lib/claude-settings.d.ts.map +1 -0
  37. package/dist/src/lib/claude-settings.js +271 -0
  38. package/dist/src/lib/claude-settings.test.d.ts +2 -0
  39. package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
  40. package/dist/src/lib/claude-settings.test.js +193 -0
  41. package/dist/src/lib/config.d.ts +23 -0
  42. package/dist/src/lib/config.d.ts.map +1 -1
  43. package/dist/src/lib/config.js +14 -0
  44. package/dist/src/lib/format.d.ts +6 -0
  45. package/dist/src/lib/format.d.ts.map +1 -0
  46. package/dist/src/lib/format.js +19 -0
  47. package/dist/src/lib/git.d.ts +3 -0
  48. package/dist/src/lib/git.d.ts.map +1 -0
  49. package/dist/src/lib/git.js +30 -0
  50. package/dist/src/lib/repo-guidance.d.ts +40 -0
  51. package/dist/src/lib/repo-guidance.d.ts.map +1 -0
  52. package/dist/src/lib/repo-guidance.js +123 -0
  53. package/dist/src/lib/validators.d.ts +69 -0
  54. package/dist/src/lib/validators.d.ts.map +1 -1
  55. package/dist/src/lib/validators.js +69 -0
  56. package/package.json +7 -5
@@ -0,0 +1,231 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { maskToken, resolveAgentCredentials } from "../../lib/agent-config.js";
3
+ import { clearStartState, listStartStates, readStartState, recordLastRun, writeStartState, } from "../../lib/agent-hook-state.js";
4
+ /**
5
+ * Claude Code SessionStart / SessionEnd hook → Clocktopus.
6
+ *
7
+ * Claude Code pipes its hook JSON to stdin. That JSON is the only place
8
+ * `cwd` appears anywhere in the telemetry pipeline — the OTLP metric export
9
+ * carries no path, repository or branch data at all — so without this hook
10
+ * every agent session is unattributable and the ledger can only show
11
+ * totals.
12
+ *
13
+ * Previously a standalone script under `scripts/agent-telemetry/`, which
14
+ * meant `settings.json` had to reference an absolute path inside a checkout
15
+ * of this repository. Shipping it as a subcommand is what makes the feature
16
+ * installable by anyone: the hook is wherever the CLI is.
17
+ *
18
+ * Contract with the host process, all three parts load-bearing: never write
19
+ * to stdout (Claude Code parses it), never throw, always exit 0. Telemetry
20
+ * must not be able to break the session it measures.
21
+ */
22
+ const TIMEOUT_MS = 4000;
23
+ /**
24
+ * How long a state file must sit untouched before its session is presumed
25
+ * dead. A file only survives SessionEnd if the session never got one —
26
+ * SIGKILL, a crash, or the machine going down — but a long-running session
27
+ * is perfectly normal, so this sits well past a working day rather than at
28
+ * the point a session merely looks idle.
29
+ */
30
+ const ABANDONED_AFTER_MS = 12 * 60 * 60 * 1000;
31
+ /** Bound on one sweep, so a directory left full of state cannot stall a start. */
32
+ const MAX_SWEEP_PER_RUN = 10;
33
+ function git(cwd, args) {
34
+ try {
35
+ return execFileSync("git", args, {
36
+ cwd,
37
+ encoding: "utf8",
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ timeout: 2000,
40
+ }).trim();
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ async function post(endpoint, token, body) {
47
+ const controller = new AbortController();
48
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
49
+ try {
50
+ const response = await fetch(`${endpoint.replace(/\/$/, "")}/v1/agent-session`, {
51
+ method: "POST",
52
+ headers: {
53
+ "content-type": "application/json",
54
+ authorization: `Bearer ${token}`,
55
+ },
56
+ body: JSON.stringify(body),
57
+ signal: controller.signal,
58
+ });
59
+ return { status: response.status, error: null };
60
+ }
61
+ catch (error) {
62
+ // Telemetry must never break the session it measures. The failure is
63
+ // recorded for `agent doctor` instead of surfaced here.
64
+ return {
65
+ status: null,
66
+ error: error instanceof Error ? error.message : "request failed",
67
+ };
68
+ }
69
+ finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
73
+ /**
74
+ * Closes sessions that were never given a SessionEnd.
75
+ *
76
+ * A state file outlives its session only when the process died without
77
+ * running the hook, and until something closes it the session keeps a null
78
+ * `ended_at` and a null `git_head_after` forever.
79
+ *
80
+ * It deliberately does **not** send `commit_shas`. The honest range for a
81
+ * dead session is unknowable after the fact: `before..HEAD` also contains
82
+ * everything committed in the hours since it died, and the receiver records
83
+ * hook-supplied commits as `declared`, which outranks a `time_window` link
84
+ * and would make a bad guess permanent. Closing the session is enough —
85
+ * with a real `ended_at` the webhook's time-window pass can then link its
86
+ * commits from the commit timestamps, which are bounded by data rather than
87
+ * by assumption.
88
+ *
89
+ * For the same reason it sends its own `ended_at` rather than letting the
90
+ * receiver stamp one: this runs when the *next* session starts, potentially
91
+ * a day later.
92
+ */
93
+ async function sweepAbandonedSessions(endpoint, token, currentSessionId) {
94
+ let swept = 0;
95
+ for (const { sessionId, ageMs, lastActivityAt } of listStartStates()) {
96
+ if (swept >= MAX_SWEEP_PER_RUN)
97
+ return;
98
+ if (sessionId === currentSessionId)
99
+ continue;
100
+ if (ageMs < ABANDONED_AFTER_MS)
101
+ continue;
102
+ swept++;
103
+ const state = readStartState(sessionId);
104
+ // Nothing recoverable without the originating checkout — drop the file
105
+ // rather than resolve its SHA against an unrelated repository.
106
+ if (!state?.cwd) {
107
+ clearStartState(sessionId);
108
+ continue;
109
+ }
110
+ const head = git(state.cwd, ["rev-parse", "HEAD"]);
111
+ if (head) {
112
+ await post(endpoint, token, {
113
+ session_id: sessionId,
114
+ hook_event_name: "SessionEnd",
115
+ cwd: state.cwd,
116
+ reason: "abandoned",
117
+ // Never "now": this session died hours ago, and the receiver would
118
+ // otherwise record a window spanning everything since. The receiver
119
+ // treats this as a fallback and keeps a telemetry-stamped end if it
120
+ // has one; this value only fills the gap when it has none.
121
+ ended_at: lastActivityAt.toISOString(),
122
+ repository_url: git(state.cwd, ["remote", "get-url", "origin"]),
123
+ git_branch: git(state.cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
124
+ git_head_before: state.sha,
125
+ git_head_after: head,
126
+ });
127
+ }
128
+ clearStartState(sessionId);
129
+ }
130
+ }
131
+ async function readStdin() {
132
+ const chunks = [];
133
+ for await (const chunk of process.stdin)
134
+ chunks.push(chunk);
135
+ return Buffer.concat(chunks).toString("utf8");
136
+ }
137
+ async function run() {
138
+ const { token, endpoint } = resolveAgentCredentials();
139
+ if (!token || !endpoint) {
140
+ // Not configured. Silence is correct: agent telemetry is opt-in, and a
141
+ // machine that never ran `clocktopus agent setup` should notice nothing.
142
+ return;
143
+ }
144
+ const raw = await readStdin();
145
+ if (!raw)
146
+ return;
147
+ let payload;
148
+ try {
149
+ payload = JSON.parse(raw);
150
+ }
151
+ catch {
152
+ return;
153
+ }
154
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : null;
155
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : null;
156
+ if (!sessionId || !cwd)
157
+ return;
158
+ const eventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
159
+ const isSessionStart = eventName === "SessionStart";
160
+ // HEAD at SessionStart is the *before* SHA; HEAD at SessionEnd is the
161
+ // *after*. Sending both under one field would let SessionEnd overwrite the
162
+ // start SHA, collapsing the range to a single point and making
163
+ // `git rev-list before..after` — the authoritative commit↔session link —
164
+ // impossible to compute.
165
+ const head = git(cwd, ["rev-parse", "HEAD"]);
166
+ const repositoryUrl = git(cwd, ["remote", "get-url", "origin"]);
167
+ const body = {
168
+ session_id: sessionId,
169
+ hook_event_name: eventName,
170
+ cwd,
171
+ source: typeof payload.source === "string" ? payload.source : undefined,
172
+ reason: typeof payload.reason === "string" ? payload.reason : undefined,
173
+ repository_url: repositoryUrl,
174
+ git_branch: git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
175
+ ...(isSessionStart ? { git_head_before: head } : { git_head_after: head }),
176
+ };
177
+ // Resolve the SHA range into an actual commit list, here on the machine
178
+ // that has the git object graph. The receiver cannot do this — it has no
179
+ // checkout — so a range alone would be unusable.
180
+ //
181
+ // SessionStart and SessionEnd are separate processes, so the starting SHA
182
+ // is stashed on disk under the session id rather than passed in the
183
+ // environment.
184
+ if (isSessionStart) {
185
+ if (head)
186
+ writeStartState(sessionId, head, cwd);
187
+ }
188
+ else {
189
+ const before = readStartState(sessionId)?.sha;
190
+ if (before && head && before !== head) {
191
+ const shas = git(cwd, [
192
+ "rev-list",
193
+ "--max-count=200",
194
+ `${before}..${head}`,
195
+ ]);
196
+ if (shas)
197
+ body.commit_shas = shas.split("\n").filter(Boolean);
198
+ }
199
+ if (before)
200
+ body.git_head_before = before;
201
+ clearStartState(sessionId);
202
+ }
203
+ // transcript_path is intentionally NOT forwarded. Claude Code provides it,
204
+ // and it points at the full conversation on disk — exactly the content
205
+ // BLA-421 says we must never collect.
206
+ const result = await post(endpoint, token, body);
207
+ recordLastRun({
208
+ at: new Date().toISOString(),
209
+ event: eventName,
210
+ sessionId,
211
+ endpoint,
212
+ tokenPrefix: maskToken(token),
213
+ status: result.status,
214
+ error: result.error,
215
+ repository: repositoryUrl ?? null,
216
+ });
217
+ // After this session's own event, never before it: the sweep talks to the
218
+ // network once per abandoned session and must not delay the event it is
219
+ // piggybacking on.
220
+ if (isSessionStart) {
221
+ await sweepAbandonedSessions(endpoint, token, sessionId);
222
+ }
223
+ }
224
+ export async function hookCommand() {
225
+ try {
226
+ await run();
227
+ }
228
+ catch {
229
+ // Every failure path is silent by design.
230
+ }
231
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Configures this machine to report agent spend to Clocktopus.
3
+ *
4
+ * Everything lands in one file — `~/.claude/settings.json` — holding both
5
+ * the exporter's environment and the session hooks. A single source of
6
+ * truth is not a tidiness preference: it is what makes `agent doctor` able
7
+ * to say which value is in force. Configuration spread over `.envrc`, a
8
+ * shell profile and a CLI config file can be reported on but never
9
+ * resolved, and this project already lost real spend to exactly that (one
10
+ * session split across two accounts, silently, because two files disagreed
11
+ * about the token).
12
+ *
13
+ * Idempotent by default. Re-running repairs the hooks and refreshes the
14
+ * endpoint while keeping the existing token, so the common case — "did my
15
+ * setup drift?" — costs nothing. `--force` mints a replacement instead.
16
+ */
17
+ export declare function setupCommand(options: {
18
+ name?: string;
19
+ force?: boolean;
20
+ }): Promise<void>;
21
+ //# sourceMappingURL=setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../../../src/commands/agent/setup.ts"],"names":[],"mappings":"AA4BA;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+IhB"}
@@ -0,0 +1,194 @@
1
+ import { existsSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { join } from "node:path";
4
+ import { findShadowedExports, maskToken, resolveHookCommand, } from "../../lib/agent-config.js";
5
+ import { verifyReceiver } from "../../lib/agent-receiver.js";
6
+ import { ApiError, get, post } from "../../lib/api.js";
7
+ import { applyTelemetrySettings, claudeConfigDir, readInstalledTelemetry, readSettings, SettingsParseError, writeSettings, } from "../../lib/claude-settings.js";
8
+ import { isLoggedIn, setAgentConfig } from "../../lib/config.js";
9
+ import { labelled } from "../../lib/format.js";
10
+ import { fetchRepoStatus, renderRepoBlock } from "../../lib/repo-guidance.js";
11
+ 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
+ */
28
+ export async function setupCommand(options) {
29
+ if (!isLoggedIn()) {
30
+ console.error("Not logged in. Run 'clocktopus login' first.");
31
+ process.exit(1);
32
+ }
33
+ let installed;
34
+ try {
35
+ installed = readInstalledTelemetry();
36
+ }
37
+ catch (error) {
38
+ if (error instanceof SettingsParseError) {
39
+ console.error(`✗ ${error.message}`);
40
+ process.exit(1);
41
+ }
42
+ throw error;
43
+ }
44
+ let email = null;
45
+ let endpoint;
46
+ try {
47
+ const [user, endpointResponse] = await Promise.all([
48
+ get("/api/auth/me").then((data) => UserSchema.parse(data)),
49
+ get("/api/agent/ingest-token").then((data) => AgentEndpointSchema.parse(data)),
50
+ ]);
51
+ email = user.user.email;
52
+ endpoint = endpointResponse.endpoint;
53
+ }
54
+ catch (error) {
55
+ reportApiFailure(error, "Failed to reach Clocktopus");
56
+ return;
57
+ }
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;
63
+ let tokenId = null;
64
+ let minted = false;
65
+ if (token && !options.force) {
66
+ const check = await verifyReceiver(endpoint, token);
67
+ if (!check.ok) {
68
+ console.log(check.reason === "invalid_token"
69
+ ? "Existing token was rejected by the receiver — minting a replacement."
70
+ : "Could not confirm the existing token — minting a replacement.");
71
+ token = null;
72
+ }
73
+ }
74
+ else if (options.force) {
75
+ token = null;
76
+ }
77
+ if (!token) {
78
+ try {
79
+ const response = await post("/api/agent/ingest-token", {
80
+ name: options.name ?? `${hostname()} (claude code)`,
81
+ }).then((data) => IngestTokenSchema.parse(data));
82
+ token = response.token;
83
+ tokenId = response.tokenId;
84
+ endpoint = response.endpoint;
85
+ minted = true;
86
+ }
87
+ catch (error) {
88
+ reportApiFailure(error, "Failed to mint an ingest token");
89
+ return;
90
+ }
91
+ }
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));
102
+ }
103
+ catch (error) {
104
+ console.error(`✗ Could not write ${installed.path}: ${error instanceof Error ? error.message : "unknown error"}`);
105
+ process.exit(1);
106
+ }
107
+ setAgentConfig({
108
+ tokenId: tokenId ?? undefined,
109
+ tokenPrefix: maskToken(token),
110
+ endpoint,
111
+ configuredAt: new Date().toISOString(),
112
+ });
113
+ const check = await verifyReceiver(endpoint, token);
114
+ console.log("\nClocktopus agent telemetry\n");
115
+ if (email)
116
+ console.log(labelled("Account", email));
117
+ console.log(labelled("Receiver", endpoint));
118
+ 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));
123
+ console.log("");
124
+ console.log(check.ok
125
+ ? "✓ The receiver accepted this token."
126
+ : `✗ 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.
131
+ const repoStatus = await fetchRepoStatus();
132
+ if (repoStatus) {
133
+ for (const line of renderRepoBlock(repoStatus))
134
+ console.log(line);
135
+ }
136
+ for (const warning of collectWarnings({
137
+ hookUsesAbsolutePath: hook.usesAbsolutePath,
138
+ })) {
139
+ console.log(`\n${warning}`);
140
+ }
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.");
145
+ console.log("\nThen confirm it end to end: clocktopus agent status");
146
+ }
147
+ function collectWarnings(input) {
148
+ 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" +
164
+ " 'clocktopus agent disable --revoke' if it gets committed.");
165
+ }
166
+ if (input.hookUsesAbsolutePath) {
167
+ warnings.push("⚠ 'clocktopus' is not on PATH as this executable, so the hook was\n" +
168
+ " installed with an absolute path. Reinstalling the CLI elsewhere will\n" +
169
+ " break it — re-run 'clocktopus agent setup' if that happens.");
170
+ }
171
+ return warnings;
172
+ }
173
+ function describeCheck(check) {
174
+ switch (check.reason) {
175
+ case "invalid_token":
176
+ return "401 — token unknown or revoked";
177
+ case "unexpected_status":
178
+ return `HTTP ${check.status}`;
179
+ case "unreachable":
180
+ return check.message;
181
+ }
182
+ }
183
+ function reportApiFailure(error, prefix) {
184
+ if (error instanceof ApiError && error.status === 401) {
185
+ console.error("Session expired. Run 'clocktopus login' again.");
186
+ }
187
+ else if (error instanceof Error) {
188
+ console.error(`${prefix}: ${error.message}`);
189
+ }
190
+ else {
191
+ console.error(`${prefix}: unknown error`);
192
+ }
193
+ process.exit(1);
194
+ }
@@ -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":"AA8BA,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAoFnD"}
@@ -0,0 +1,160 @@
1
+ import { differenceInMilliseconds, parseISO } from "date-fns";
2
+ import { readLastRun } from "../../lib/agent-hook-state.js";
3
+ import { ApiError, get } from "../../lib/api.js";
4
+ import { readInstalledTelemetry, SettingsParseError, } from "../../lib/claude-settings.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 the exporter reads its
13
+ * environment once at process start, Claude Code swallows 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 metrics
18
+ * do not produces sessions with a repository, a branch and no spend, 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("Metrics 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 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'.",
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 the metric stream carries no 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
+ 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;
134
+ }
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;
141
+ }
142
+ throw error;
143
+ }
144
+ // The hook's own receipt of its last run. It cannot print or warn during a
145
+ // session, so this is the only place a 401 or a connection failure from
146
+ // the machine's side becomes visible.
147
+ const lastRun = readLastRun();
148
+ if (lastRun) {
149
+ const outcome = lastRun.status === null || lastRun.status === undefined
150
+ ? `failed — ${lastRun.error ?? "no response"}`
151
+ : lastRun.status >= 200 && lastRun.status < 300
152
+ ? `HTTP ${lastRun.status}`
153
+ : `HTTP ${lastRun.status} — rejected`;
154
+ lines.push(labelled("Last hook", `${formatAgo(lastRun.at)} (${lastRun.event ?? "?"}) ${outcome}`));
155
+ }
156
+ else {
157
+ lines.push(labelled("Last hook", "has not run since setup"));
158
+ }
159
+ return lines;
160
+ }
@@ -1,11 +1,33 @@
1
1
  /**
2
- * Record a clock-in signal for today
2
+ * Record a clock-in signal for today.
3
+ *
4
+ * Accepts the same backdate flags as `clock out`:
5
+ * --ago <duration> e.g. "45m", "1h", "1h30m"
6
+ * --at <HH:mm> absolute wall-clock time in the user's timezone
7
+ *
8
+ * The server silently clamps the requested time if it would fall outside
9
+ * the allowed window (before start-of-today, before the day's last
10
+ * clock-out, or after the first existing time entry on the day).
3
11
  */
4
- export declare function clockInCommand(): Promise<void>;
12
+ export declare function clockInCommand(options?: {
13
+ ago?: string;
14
+ at?: string;
15
+ }): Promise<void>;
5
16
  /**
6
- * Record a clock-out signal for today
17
+ * Record a clock-out signal for today.
18
+ *
19
+ * Accepts optional backdate flags:
20
+ * --ago <duration> e.g. "45m", "1h", "1h30m"
21
+ * --at <HH:mm> absolute wall-clock time in the user's timezone
22
+ *
23
+ * The two flags are mutually exclusive. The server silently clamps the
24
+ * requested time if it would predate the session's clock-in or the
25
+ * session's last time entry.
7
26
  */
8
- export declare function clockOutCommand(): Promise<void>;
27
+ export declare function clockOutCommand(options?: {
28
+ ago?: string;
29
+ at?: string;
30
+ }): Promise<void>;
9
31
  /**
10
32
  * Show clock signals status for a given date (default: today)
11
33
  */
@@ -1 +1 @@
1
- {"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../../../src/commands/clock.ts"],"names":[],"mappings":"AAuDA;;GAEG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAqCpD;AAED;;GAEG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAuCrD;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,IAAI,CAAC,CAiDhB"}
1
+ {"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../../../src/commands/clock.ts"],"names":[],"mappings":"AAgFA;;;;;;;;;;GAUG;AACH,wBAAsB,cAAc,CAClC,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1C,OAAO,CAAC,IAAI,CAAC,CA0Ef;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CACnC,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAC1C,OAAO,CAAC,IAAI,CAAC,CAsEf;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,IAAI,CAAC,CAiDhB"}