@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.
- package/README.md +124 -6
- package/dist/src/commands/agent/disable.d.ts +14 -0
- package/dist/src/commands/agent/disable.d.ts.map +1 -0
- package/dist/src/commands/agent/disable.js +72 -0
- package/dist/src/commands/agent/doctor.d.ts +2 -0
- package/dist/src/commands/agent/doctor.d.ts.map +1 -0
- package/dist/src/commands/agent/doctor.js +235 -0
- package/dist/src/commands/agent/hook.d.ts +2 -0
- package/dist/src/commands/agent/hook.d.ts.map +1 -0
- package/dist/src/commands/agent/hook.js +231 -0
- package/dist/src/commands/agent/setup.d.ts +21 -0
- package/dist/src/commands/agent/setup.d.ts.map +1 -0
- package/dist/src/commands/agent/setup.js +194 -0
- package/dist/src/commands/agent/status.d.ts +2 -0
- package/dist/src/commands/agent/status.d.ts.map +1 -0
- package/dist/src/commands/agent/status.js +160 -0
- package/dist/src/commands/clock.d.ts +26 -4
- package/dist/src/commands/clock.d.ts.map +1 -1
- package/dist/src/commands/clock.js +99 -6
- package/dist/src/commands/login.d.ts.map +1 -1
- package/dist/src/commands/login.js +5 -5
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +45 -6
- package/dist/src/lib/agent-config.d.ts +41 -0
- package/dist/src/lib/agent-config.d.ts.map +1 -0
- package/dist/src/lib/agent-config.js +143 -0
- package/dist/src/lib/agent-hook-state.d.ts +36 -0
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
- package/dist/src/lib/agent-hook-state.js +136 -0
- package/dist/src/lib/agent-receiver.d.ts +26 -0
- package/dist/src/lib/agent-receiver.d.ts.map +1 -0
- package/dist/src/lib/agent-receiver.js +44 -0
- package/dist/src/lib/api.d.ts.map +1 -1
- package/dist/src/lib/api.js +28 -1
- package/dist/src/lib/claude-settings.d.ts +82 -0
- package/dist/src/lib/claude-settings.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.js +271 -0
- package/dist/src/lib/claude-settings.test.d.ts +2 -0
- package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
- package/dist/src/lib/claude-settings.test.js +193 -0
- package/dist/src/lib/config.d.ts +23 -0
- package/dist/src/lib/config.d.ts.map +1 -1
- package/dist/src/lib/config.js +14 -0
- package/dist/src/lib/format.d.ts +6 -0
- package/dist/src/lib/format.d.ts.map +1 -0
- package/dist/src/lib/format.js +19 -0
- package/dist/src/lib/git.d.ts +3 -0
- package/dist/src/lib/git.d.ts.map +1 -0
- package/dist/src/lib/git.js +30 -0
- package/dist/src/lib/repo-guidance.d.ts +40 -0
- package/dist/src/lib/repo-guidance.d.ts.map +1 -0
- package/dist/src/lib/repo-guidance.js +123 -0
- package/dist/src/lib/validators.d.ts +69 -0
- package/dist/src/lib/validators.d.ts.map +1 -1
- package/dist/src/lib/validators.js +69 -0
- 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 @@
|
|
|
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(
|
|
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(
|
|
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":"
|
|
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"}
|