@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,44 @@
1
+ /**
2
+ * Talks to the OTLP receiver directly, which is the whole point.
3
+ *
4
+ * The receiver is a different host from the web app — its own Router, its
5
+ * own Lambda, its own domain — so a token that works against the dashboard
6
+ * proves nothing about whether telemetry can reach ingest. This check is
7
+ * the only local way to tell "nothing has happened yet" apart from "nothing
8
+ * can happen": a wrong endpoint, a revoked token and an idle afternoon all
9
+ * look identical otherwise.
10
+ */
11
+ const TIMEOUT_MS = 8000;
12
+ export async function verifyReceiver(endpoint, token) {
13
+ const controller = new AbortController();
14
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
15
+ try {
16
+ const response = await fetch(`${endpoint.replace(/\/$/, "")}/v1/verify`, {
17
+ method: "POST",
18
+ headers: {
19
+ "content-type": "application/json",
20
+ authorization: `Bearer ${token}`,
21
+ },
22
+ body: "{}",
23
+ signal: controller.signal,
24
+ });
25
+ if (response.ok)
26
+ return { ok: true };
27
+ if (response.status === 401)
28
+ return { ok: false, reason: "invalid_token" };
29
+ // A 404 here is worth distinguishing in the caller's advice: it means the
30
+ // URL resolved to something that is not this receiver, or to a receiver
31
+ // deployed before /v1/verify existed.
32
+ return { ok: false, reason: "unexpected_status", status: response.status };
33
+ }
34
+ catch (error) {
35
+ return {
36
+ ok: false,
37
+ reason: "unreachable",
38
+ message: error instanceof Error ? error.message : "request failed",
39
+ };
40
+ }
41
+ finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/lib/api.ts"],"names":[],"mappings":"AAEA,KAAK,cAAc,GAAG;IACpB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,qBAAa,QAAS,SAAQ,KAAK;IAExB,MAAM,EAAE,MAAM;IACd,UAAU,EAAE,MAAM;gBADlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EACzB,OAAO,CAAC,EAAE,MAAM;CAKnB;AAED,wBAAsB,OAAO,CAAC,CAAC,EAC7B,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,CAAC,CAAC,CA6BZ;AAED,wBAAsB,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,UAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAE3E;AAED,wBAAsB,IAAI,CAAC,CAAC,EAC1B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,aAAa,UAAO,GACnB,OAAO,CAAC,CAAC,CAAC,CAEZ"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/lib/api.ts"],"names":[],"mappings":"AAEA,KAAK,cAAc,GAAG;IACpB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,qBAAa,QAAS,SAAQ,KAAK;IAExB,MAAM,EAAE,MAAM;IACd,UAAU,EAAE,MAAM;gBADlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EACzB,OAAO,CAAC,EAAE,MAAM;CAKnB;AAED,wBAAsB,OAAO,CAAC,CAAC,EAC7B,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,CAAC,CAAC,CAqCZ;AAuBD,wBAAsB,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,UAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAE3E;AAED,wBAAsB,IAAI,CAAC,CAAC,EAC1B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,aAAa,UAAO,GACnB,OAAO,CAAC,CAAC,CAAC,CAEZ"}
@@ -29,10 +29,37 @@ export async function request(path, options = {}) {
29
29
  });
30
30
  if (!response.ok) {
31
31
  const errorText = await response.text();
32
- throw new ApiError(response.status, response.statusText, errorText);
32
+ // The frontend's `handleApiError` serializes errors as
33
+ // `{ error: { message, stack? }, timestamp, path }`. Pull the
34
+ // `message` out so the user sees the actual reason (e.g. "Signal
35
+ // time cannot be in the future…") rather than a raw JSON blob.
36
+ throw new ApiError(response.status, response.statusText, extractErrorMessage(errorText) ?? errorText);
33
37
  }
34
38
  return response.json();
35
39
  }
40
+ function extractErrorMessage(body) {
41
+ if (!body)
42
+ return null;
43
+ try {
44
+ const parsed = JSON.parse(body);
45
+ if (parsed && typeof parsed === "object") {
46
+ const record = parsed;
47
+ const err = record.error;
48
+ if (err && typeof err === "object") {
49
+ const msg = err.message;
50
+ if (typeof msg === "string" && msg.length > 0)
51
+ return msg;
52
+ }
53
+ if (typeof record.message === "string" && record.message.length > 0) {
54
+ return record.message;
55
+ }
56
+ }
57
+ }
58
+ catch {
59
+ // Not JSON — fall through and let the caller show the raw text.
60
+ }
61
+ return null;
62
+ }
36
63
  export async function get(path, authenticated = true) {
37
64
  return request(path, { method: "GET", authenticated });
38
65
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Reads and edits `~/.claude/settings.json` on the user's behalf.
3
+ *
4
+ * Two rules govern everything here, both of them about not destroying a file
5
+ * we do not own:
6
+ *
7
+ * 1. **Never write over JSON we could not parse.** A malformed settings file
8
+ * is far more likely to be a half-finished edit than something to
9
+ * overwrite, and overwriting it would lose the user's own hooks,
10
+ * permissions and MCP servers. Parse failures raise instead.
11
+ * 2. **Only ever touch keys we put there.** Merging into `env` and appending
12
+ * to `hooks` leaves everything else untouched, and removal matches our
13
+ * own hook command rather than clearing the arrays.
14
+ */
15
+ export declare const SETTINGS_FILENAME = "settings.json";
16
+ /** Env keys `clocktopus agent setup` owns — and the only ones it removes. */
17
+ export declare const TELEMETRY_ENV_KEYS: readonly ["CLAUDE_CODE_ENABLE_TELEMETRY", "OTEL_METRICS_EXPORTER", "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_METRICS_INCLUDE_SESSION_ID", "OTEL_METRIC_EXPORT_INTERVAL", "CLOCKTOPUS_INGEST_TOKEN", "CLOCKTOPUS_OTEL_ENDPOINT"];
18
+ /**
19
+ * How often Claude Code's exporter ships metrics.
20
+ *
21
+ * Also the resolution of every "last export received" answer the status
22
+ * command can give — a session that started 30s ago has genuinely not
23
+ * exported yet, which is why status treats silence under one interval as
24
+ * "waiting" rather than "broken".
25
+ */
26
+ export declare const METRIC_EXPORT_INTERVAL_MS = 60000;
27
+ /**
28
+ * Seconds Claude Code will wait for the hook before giving up on it.
29
+ *
30
+ * Comfortably above the hook's own 4s request timeout, so a slow network
31
+ * produces the hook's own recorded failure — which `agent doctor` can read
32
+ * back — rather than a kill from the host, which leaves no trace anywhere.
33
+ */
34
+ export declare const HOOK_TIMEOUT_SECONDS = 10;
35
+ export type ClaudeSettings = Record<string, unknown>;
36
+ export declare class SettingsParseError extends Error {
37
+ readonly path: string;
38
+ constructor(path: string);
39
+ }
40
+ export declare function claudeConfigDir(): string;
41
+ export declare function settingsPath(): string;
42
+ export declare function readSettings(path?: string): {
43
+ path: string;
44
+ exists: boolean;
45
+ modifiedAt: Date | null;
46
+ settings: ClaudeSettings;
47
+ };
48
+ /**
49
+ * Writes settings, keeping a one-deep backup of what was there before.
50
+ *
51
+ * The rename is what makes it atomic: a crash midway leaves either the old
52
+ * file or the new one, never a truncated file that Claude Code would refuse
53
+ * to start with.
54
+ */
55
+ export declare function writeSettings(settings: ClaudeSettings, path?: string): {
56
+ backupPath: string | null;
57
+ };
58
+ export declare function buildTelemetryEnv(input: {
59
+ token: string;
60
+ endpoint: string;
61
+ }): Record<string, string>;
62
+ declare const HOOK_EVENTS: readonly ["SessionStart", "SessionEnd"];
63
+ export declare function applyTelemetrySettings(settings: ClaudeSettings, input: {
64
+ token: string;
65
+ endpoint: string;
66
+ hookCommand: string;
67
+ }): ClaudeSettings;
68
+ export declare function removeTelemetrySettings(settings: ClaudeSettings): {
69
+ settings: ClaudeSettings;
70
+ removedEnvKeys: string[];
71
+ removedHooks: boolean;
72
+ };
73
+ /** What settings.json currently declares, for `status` and `doctor`. */
74
+ export declare function readInstalledTelemetry(path?: string): {
75
+ path: string;
76
+ exists: boolean;
77
+ modifiedAt: Date | null;
78
+ env: Record<string, string>;
79
+ hookCommands: Partial<Record<(typeof HOOK_EVENTS)[number], string>>;
80
+ };
81
+ export {};
82
+ //# sourceMappingURL=claude-settings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-settings.d.ts","sourceRoot":"","sources":["../../../src/lib/claude-settings.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;;;;GAaG;AAEH,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAEjD,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,yRAUrB,CAAC;AAEX;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAEhD;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AASrD,qBAAa,kBAAmB,SAAQ,KAAK;aACf,IAAI,EAAE,MAAM;gBAAZ,IAAI,EAAE,MAAM;CAMzC;AAED,wBAAgB,eAAe,IAAI,MAAM,CAIxC;AAED,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,wBAAgB,YAAY,CAAC,IAAI,SAAiB,GAAG;IACnD,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,cAAc,CAAC;CAC1B,CA6BA;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,cAAc,EACxB,IAAI,SAAiB,GACpB;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAiB/B;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmBzB;AAED,QAAA,MAAM,WAAW,yCAA0C,CAAC;AAqC5D,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,cAAc,EACxB,KAAK,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC9D,cAAc,CA6ChB;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,cAAc,GAAG;IACjE,QAAQ,EAAE,cAAc,CAAC;IACzB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,YAAY,EAAE,OAAO,CAAC;CACvB,CAmCA;AAED,wEAAwE;AACxE,wBAAgB,sBAAsB,CAAC,IAAI,SAAiB,GAAG;IAC7D,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;CACrE,CAmCA"}
@@ -0,0 +1,271 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * Reads and edits `~/.claude/settings.json` on the user's behalf.
6
+ *
7
+ * Two rules govern everything here, both of them about not destroying a file
8
+ * we do not own:
9
+ *
10
+ * 1. **Never write over JSON we could not parse.** A malformed settings file
11
+ * is far more likely to be a half-finished edit than something to
12
+ * overwrite, and overwriting it would lose the user's own hooks,
13
+ * permissions and MCP servers. Parse failures raise instead.
14
+ * 2. **Only ever touch keys we put there.** Merging into `env` and appending
15
+ * to `hooks` leaves everything else untouched, and removal matches our
16
+ * own hook command rather than clearing the arrays.
17
+ */
18
+ export const SETTINGS_FILENAME = "settings.json";
19
+ /** Env keys `clocktopus agent setup` owns — and the only ones it removes. */
20
+ export const TELEMETRY_ENV_KEYS = [
21
+ "CLAUDE_CODE_ENABLE_TELEMETRY",
22
+ "OTEL_METRICS_EXPORTER",
23
+ "OTEL_EXPORTER_OTLP_PROTOCOL",
24
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
25
+ "OTEL_EXPORTER_OTLP_HEADERS",
26
+ "OTEL_METRICS_INCLUDE_SESSION_ID",
27
+ "OTEL_METRIC_EXPORT_INTERVAL",
28
+ "CLOCKTOPUS_INGEST_TOKEN",
29
+ "CLOCKTOPUS_OTEL_ENDPOINT",
30
+ ];
31
+ /**
32
+ * How often Claude Code's exporter ships metrics.
33
+ *
34
+ * Also the resolution of every "last export received" answer the status
35
+ * command can give — a session that started 30s ago has genuinely not
36
+ * exported yet, which is why status treats silence under one interval as
37
+ * "waiting" rather than "broken".
38
+ */
39
+ export const METRIC_EXPORT_INTERVAL_MS = 60_000;
40
+ /**
41
+ * Seconds Claude Code will wait for the hook before giving up on it.
42
+ *
43
+ * Comfortably above the hook's own 4s request timeout, so a slow network
44
+ * produces the hook's own recorded failure — which `agent doctor` can read
45
+ * back — rather than a kill from the host, which leaves no trace anywhere.
46
+ */
47
+ export const HOOK_TIMEOUT_SECONDS = 10;
48
+ export class SettingsParseError extends Error {
49
+ path;
50
+ constructor(path) {
51
+ super(`${path} is not valid JSON. Fix or move it, then run 'clocktopus agent setup' again.`);
52
+ this.path = path;
53
+ this.name = "SettingsParseError";
54
+ }
55
+ }
56
+ export function claudeConfigDir() {
57
+ // Claude Code honours CLAUDE_CONFIG_DIR; following it means setup writes
58
+ // where that installation actually reads.
59
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
60
+ }
61
+ export function settingsPath() {
62
+ return join(claudeConfigDir(), SETTINGS_FILENAME);
63
+ }
64
+ export function readSettings(path = settingsPath()) {
65
+ if (!existsSync(path)) {
66
+ return { path, exists: false, modifiedAt: null, settings: {} };
67
+ }
68
+ const raw = readFileSync(path, "utf8");
69
+ const modifiedAt = statSync(path).mtime;
70
+ // An empty file is a normal state (some installers touch it) and is safe
71
+ // to treat as an empty object; anything else that fails to parse is not.
72
+ if (raw.trim() === "") {
73
+ return { path, exists: true, modifiedAt, settings: {} };
74
+ }
75
+ try {
76
+ const parsed = JSON.parse(raw);
77
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
78
+ throw new SettingsParseError(path);
79
+ }
80
+ return {
81
+ path,
82
+ exists: true,
83
+ modifiedAt,
84
+ settings: parsed,
85
+ };
86
+ }
87
+ catch (error) {
88
+ if (error instanceof SettingsParseError)
89
+ throw error;
90
+ throw new SettingsParseError(path);
91
+ }
92
+ }
93
+ /**
94
+ * Writes settings, keeping a one-deep backup of what was there before.
95
+ *
96
+ * The rename is what makes it atomic: a crash midway leaves either the old
97
+ * file or the new one, never a truncated file that Claude Code would refuse
98
+ * to start with.
99
+ */
100
+ export function writeSettings(settings, path = settingsPath()) {
101
+ mkdirSync(dirname(path), { recursive: true });
102
+ let backupPath = null;
103
+ if (existsSync(path)) {
104
+ backupPath = `${path}.clocktopus-backup`;
105
+ copyFileSync(path, backupPath);
106
+ }
107
+ const temporaryPath = `${path}.clocktopus-tmp`;
108
+ writeFileSync(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, {
109
+ encoding: "utf8",
110
+ mode: 0o600,
111
+ });
112
+ renameSync(temporaryPath, path);
113
+ return { backupPath };
114
+ }
115
+ export function buildTelemetryEnv(input) {
116
+ const endpoint = input.endpoint.replace(/\/$/, "");
117
+ return {
118
+ CLAUDE_CODE_ENABLE_TELEMETRY: "1",
119
+ OTEL_METRICS_EXPORTER: "otlp",
120
+ // The receiver answers protobuf with an explicit 415 rather than a
121
+ // parse error, but only JSON actually works.
122
+ OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
123
+ OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
124
+ OTEL_EXPORTER_OTLP_HEADERS: `Authorization=Bearer ${input.token}`,
125
+ // Without the session id the metric stream cannot be joined to the hook
126
+ // stream, and every session loses its repository and branch.
127
+ OTEL_METRICS_INCLUDE_SESSION_ID: "true",
128
+ OTEL_METRIC_EXPORT_INTERVAL: String(METRIC_EXPORT_INTERVAL_MS),
129
+ // The hook's own channel. Same token, different transport.
130
+ CLOCKTOPUS_INGEST_TOKEN: input.token,
131
+ CLOCKTOPUS_OTEL_ENDPOINT: endpoint,
132
+ };
133
+ }
134
+ const HOOK_EVENTS = ["SessionStart", "SessionEnd"];
135
+ /**
136
+ * Recognises a hook entry as ours, across every spelling we have shipped.
137
+ *
138
+ * The `claude-hook.mjs` clause matters for upgrades: before this command
139
+ * existed the hook was a script inside a checkout of the Clocktopus repo,
140
+ * and anyone who set that up by hand still has it. Failing to recognise it
141
+ * would leave it installed next to the new one and POST every SessionStart
142
+ * twice.
143
+ */
144
+ function isClocktopusHook(entry) {
145
+ const command = typeof entry.command === "string" ? entry.command : "";
146
+ if (/claude-hook\.mjs/.test(command))
147
+ return true;
148
+ return /clocktopus/i.test(command) && /agent\s+hook/.test(command);
149
+ }
150
+ function asMatchers(value) {
151
+ return Array.isArray(value) ? value : [];
152
+ }
153
+ /** Strips our hook from one event's matcher list, leaving the user's alone. */
154
+ function withoutOurHooks(matchers) {
155
+ return (matchers
156
+ .map((matcher) => ({
157
+ ...matcher,
158
+ hooks: (matcher.hooks ?? []).filter((entry) => !isClocktopusHook(entry)),
159
+ }))
160
+ // A matcher group that only ever held our hook is ours to remove; one
161
+ // that still has entries belongs to the user and stays.
162
+ .filter((matcher) => (matcher.hooks ?? []).length > 0));
163
+ }
164
+ export function applyTelemetrySettings(settings, input) {
165
+ const existingEnv = settings.env &&
166
+ typeof settings.env === "object" &&
167
+ !Array.isArray(settings.env)
168
+ ? settings.env
169
+ : {};
170
+ const existingHooks = settings.hooks &&
171
+ typeof settings.hooks === "object" &&
172
+ !Array.isArray(settings.hooks)
173
+ ? settings.hooks
174
+ : {};
175
+ const hooks = { ...existingHooks };
176
+ for (const event of HOOK_EVENTS) {
177
+ // Remove-then-append rather than append: re-running setup after a
178
+ // reinstall must not leave two hooks firing per session, which would
179
+ // double every SessionStart POST.
180
+ hooks[event] = [
181
+ ...withoutOurHooks(asMatchers(existingHooks[event])),
182
+ {
183
+ hooks: [
184
+ {
185
+ type: "command",
186
+ command: input.hookCommand,
187
+ // Both fields are about not making the user wait on telemetry.
188
+ // The hook does network I/O — its own POST, plus a sweep of
189
+ // abandoned sessions at SessionStart — and running it inline
190
+ // would add that latency to the start of every session.
191
+ timeout: HOOK_TIMEOUT_SECONDS,
192
+ async: true,
193
+ },
194
+ ],
195
+ },
196
+ ];
197
+ }
198
+ return {
199
+ ...settings,
200
+ env: { ...existingEnv, ...buildTelemetryEnv(input) },
201
+ hooks,
202
+ };
203
+ }
204
+ export function removeTelemetrySettings(settings) {
205
+ const next = { ...settings };
206
+ const removedEnvKeys = [];
207
+ if (next.env && typeof next.env === "object" && !Array.isArray(next.env)) {
208
+ const env = { ...next.env };
209
+ for (const key of TELEMETRY_ENV_KEYS) {
210
+ if (key in env) {
211
+ delete env[key];
212
+ removedEnvKeys.push(key);
213
+ }
214
+ }
215
+ if (Object.keys(env).length > 0)
216
+ next.env = env;
217
+ else
218
+ delete next.env;
219
+ }
220
+ let removedHooks = false;
221
+ if (next.hooks &&
222
+ typeof next.hooks === "object" &&
223
+ !Array.isArray(next.hooks)) {
224
+ const hooks = { ...next.hooks };
225
+ for (const event of HOOK_EVENTS) {
226
+ const before = asMatchers(hooks[event]);
227
+ const after = withoutOurHooks(before);
228
+ if (JSON.stringify(before) !== JSON.stringify(after))
229
+ removedHooks = true;
230
+ if (after.length > 0)
231
+ hooks[event] = after;
232
+ else
233
+ delete hooks[event];
234
+ }
235
+ if (Object.keys(hooks).length > 0)
236
+ next.hooks = hooks;
237
+ else
238
+ delete next.hooks;
239
+ }
240
+ return { settings: next, removedEnvKeys, removedHooks };
241
+ }
242
+ /** What settings.json currently declares, for `status` and `doctor`. */
243
+ export function readInstalledTelemetry(path = settingsPath()) {
244
+ const { settings, exists, modifiedAt } = readSettings(path);
245
+ const rawEnv = settings.env &&
246
+ typeof settings.env === "object" &&
247
+ !Array.isArray(settings.env)
248
+ ? settings.env
249
+ : {};
250
+ const env = {};
251
+ for (const key of TELEMETRY_ENV_KEYS) {
252
+ if (typeof rawEnv[key] === "string")
253
+ env[key] = rawEnv[key];
254
+ }
255
+ const rawHooks = settings.hooks &&
256
+ typeof settings.hooks === "object" &&
257
+ !Array.isArray(settings.hooks)
258
+ ? settings.hooks
259
+ : {};
260
+ const hookCommands = {};
261
+ for (const event of HOOK_EVENTS) {
262
+ for (const matcher of asMatchers(rawHooks[event])) {
263
+ const ours = (matcher.hooks ?? []).find(isClocktopusHook);
264
+ if (ours?.command) {
265
+ hookCommands[event] = ours.command;
266
+ break;
267
+ }
268
+ }
269
+ }
270
+ return { path, exists, modifiedAt, env, hookCommands };
271
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=claude-settings.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-settings.test.d.ts","sourceRoot":"","sources":["../../../src/lib/claude-settings.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,193 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { applyTelemetrySettings, buildTelemetryEnv, readInstalledTelemetry, readSettings, removeTelemetrySettings, SettingsParseError, writeSettings, } from "./claude-settings";
6
+ /**
7
+ * These tests exist because this module edits a file we do not own.
8
+ * `~/.claude/settings.json` holds the user's model choice, their own hooks,
9
+ * their permissions and their MCP servers, and a merge bug here destroys
10
+ * work that has nothing to do with us.
11
+ */
12
+ const INSTALL = {
13
+ token: "ctop_agt_testtoken",
14
+ endpoint: "https://otel.example.com",
15
+ hookCommand: "clocktopus agent hook",
16
+ };
17
+ /** A settings file with the user's own configuration already in it. */
18
+ function userSettings() {
19
+ return {
20
+ model: "opus",
21
+ env: { MY_OWN_VAR: "keep-me" },
22
+ hooks: {
23
+ SessionStart: [
24
+ { hooks: [{ type: "command", command: "echo user-session-start" }] },
25
+ ],
26
+ PreToolUse: [
27
+ {
28
+ matcher: "Bash",
29
+ hooks: [{ type: "command", command: "echo bash-guard" }],
30
+ },
31
+ ],
32
+ },
33
+ };
34
+ }
35
+ describe("applyTelemetrySettings", () => {
36
+ it("leaves everything it did not put there alone", () => {
37
+ const next = applyTelemetrySettings(userSettings(), INSTALL);
38
+ expect(next.model).toBe("opus");
39
+ expect(next.env.MY_OWN_VAR).toBe("keep-me");
40
+ expect(next.hooks.PreToolUse).toEqual(userSettings().hooks.PreToolUse);
41
+ });
42
+ it("keeps the user's own SessionStart hook alongside ours", () => {
43
+ const next = applyTelemetrySettings(userSettings(), INSTALL);
44
+ const commands = (next.hooks.SessionStart ?? []).flatMap((matcher) => matcher.hooks.map((entry) => entry.command));
45
+ expect(commands).toContain("echo user-session-start");
46
+ expect(commands).toContain("clocktopus agent hook");
47
+ });
48
+ it("installs both events — one without the other loses attribution", () => {
49
+ // SessionStart is the only source of `cwd` and the *before* SHA;
50
+ // SessionEnd is the only source of the exact commit list.
51
+ const next = applyTelemetrySettings({}, INSTALL);
52
+ const hooks = next.hooks;
53
+ expect(hooks.SessionStart).toBeDefined();
54
+ expect(hooks.SessionEnd).toBeDefined();
55
+ });
56
+ it("is idempotent — re-running setup does not fire the hook twice", () => {
57
+ const once = applyTelemetrySettings(userSettings(), INSTALL);
58
+ const twice = applyTelemetrySettings(once, INSTALL);
59
+ const ours = (twice.hooks.SessionStart ?? [])
60
+ .flatMap((matcher) => matcher.hooks)
61
+ .filter((entry) => entry.command === "clocktopus agent hook");
62
+ expect(ours).toHaveLength(1);
63
+ });
64
+ it("runs the hook out of band, so telemetry never delays session start", () => {
65
+ const next = applyTelemetrySettings({}, INSTALL);
66
+ const entry = next.hooks.SessionStart[0].hooks[0];
67
+ expect(entry.async).toBe(true);
68
+ // Above the hook's own 4s request timeout, so a slow network leaves the
69
+ // hook's recorded failure behind instead of being killed without trace.
70
+ expect(entry.timeout).toBe(10);
71
+ });
72
+ it("replaces the pre-CLI script hook instead of firing both", () => {
73
+ // Anyone who wired this up before the CLI existed points at a script
74
+ // inside a checkout of this repo. Leaving it installed alongside the new
75
+ // hook would POST every SessionStart twice.
76
+ const legacy = {
77
+ hooks: {
78
+ SessionStart: [
79
+ {
80
+ hooks: [
81
+ {
82
+ type: "command",
83
+ command: "node /home/me/Projects/clocktopus/scripts/agent-telemetry/claude-hook.mjs",
84
+ timeout: 10,
85
+ async: true,
86
+ },
87
+ ],
88
+ },
89
+ ],
90
+ },
91
+ };
92
+ const next = applyTelemetrySettings(legacy, INSTALL);
93
+ const commands = next.hooks.SessionStart.flatMap((matcher) => matcher.hooks.map((entry) => entry.command));
94
+ expect(commands).toEqual(["clocktopus agent hook"]);
95
+ });
96
+ it("replaces our hook when the command changes, rather than adding one", () => {
97
+ const first = applyTelemetrySettings({}, INSTALL);
98
+ const moved = applyTelemetrySettings(first, {
99
+ ...INSTALL,
100
+ hookCommand: "/usr/local/bin/node /opt/clocktopus/cli.js agent hook",
101
+ });
102
+ const commands = (moved.hooks.SessionEnd ?? []).flatMap((matcher) => matcher.hooks.map((entry) => entry.command));
103
+ expect(commands).toEqual([
104
+ "/usr/local/bin/node /opt/clocktopus/cli.js agent hook",
105
+ ]);
106
+ });
107
+ });
108
+ describe("buildTelemetryEnv", () => {
109
+ it("configures the exporter the only way the receiver accepts", () => {
110
+ const env = buildTelemetryEnv(INSTALL);
111
+ // Protobuf gets an explicit 415 from the receiver.
112
+ expect(env.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/json");
113
+ expect(env.OTEL_EXPORTER_OTLP_HEADERS).toBe("Authorization=Bearer ctop_agt_testtoken");
114
+ // Without the session id the metric stream cannot be joined to the hook
115
+ // stream, and every session loses its repository and branch.
116
+ expect(env.OTEL_METRICS_INCLUDE_SESSION_ID).toBe("true");
117
+ });
118
+ it("normalises a trailing slash so paths do not double up", () => {
119
+ const env = buildTelemetryEnv({
120
+ ...INSTALL,
121
+ endpoint: "https://otel.example.com/",
122
+ });
123
+ expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.example.com");
124
+ expect(env.CLOCKTOPUS_OTEL_ENDPOINT).toBe("https://otel.example.com");
125
+ });
126
+ });
127
+ describe("removeTelemetrySettings", () => {
128
+ it("removes only what setup added", () => {
129
+ const installed = applyTelemetrySettings(userSettings(), INSTALL);
130
+ const { settings, removedEnvKeys, removedHooks } = removeTelemetrySettings(installed);
131
+ expect(removedHooks).toBe(true);
132
+ expect(removedEnvKeys).toContain("CLOCKTOPUS_INGEST_TOKEN");
133
+ expect(settings.model).toBe("opus");
134
+ expect(settings.env.MY_OWN_VAR).toBe("keep-me");
135
+ expect(settings.env.CLOCKTOPUS_INGEST_TOKEN).toBeUndefined();
136
+ const sessionStart = settings.hooks.SessionStart;
137
+ expect(sessionStart?.flatMap((m) => m.hooks.map((h) => h.command))).toEqual(["echo user-session-start"]);
138
+ expect(settings.hooks.PreToolUse).toBeDefined();
139
+ });
140
+ it("drops the env and hooks keys entirely when nothing else used them", () => {
141
+ const installed = applyTelemetrySettings({ model: "opus" }, INSTALL);
142
+ const { settings } = removeTelemetrySettings(installed);
143
+ expect(settings.env).toBeUndefined();
144
+ expect(settings.hooks).toBeUndefined();
145
+ expect(settings.model).toBe("opus");
146
+ });
147
+ it("reports nothing removed when telemetry was never installed", () => {
148
+ const { removedEnvKeys, removedHooks } = removeTelemetrySettings(userSettings());
149
+ expect(removedEnvKeys).toEqual([]);
150
+ expect(removedHooks).toBe(false);
151
+ });
152
+ });
153
+ describe("reading and writing the file", () => {
154
+ let dir;
155
+ let path;
156
+ beforeEach(() => {
157
+ dir = mkdtempSync(join(tmpdir(), "clocktopus-settings-"));
158
+ path = join(dir, "settings.json");
159
+ });
160
+ afterEach(() => {
161
+ rmSync(dir, { recursive: true, force: true });
162
+ });
163
+ it("refuses to read malformed JSON rather than overwrite it", () => {
164
+ // A broken settings file is far more likely to be a half-finished edit
165
+ // than something to clobber.
166
+ writeFileSync(path, '{ "model": "opus",,, }', "utf8");
167
+ expect(() => readSettings(path)).toThrow(SettingsParseError);
168
+ });
169
+ it("treats a missing file as empty settings", () => {
170
+ const result = readSettings(path);
171
+ expect(result.exists).toBe(false);
172
+ expect(result.settings).toEqual({});
173
+ });
174
+ it("backs up the previous file before replacing it", () => {
175
+ writeFileSync(path, JSON.stringify(userSettings()), "utf8");
176
+ const { backupPath } = writeSettings(applyTelemetrySettings(readSettings(path).settings, INSTALL), path);
177
+ expect(backupPath).toBe(`${path}.clocktopus-backup`);
178
+ expect(JSON.parse(readFileSync(backupPath, "utf8"))).toEqual(userSettings());
179
+ });
180
+ it("round-trips what setup installed", () => {
181
+ writeSettings(applyTelemetrySettings(userSettings(), INSTALL), path);
182
+ const installed = readInstalledTelemetry(path);
183
+ expect(installed.env.CLOCKTOPUS_INGEST_TOKEN).toBe("ctop_agt_testtoken");
184
+ expect(installed.hookCommands.SessionStart).toBe("clocktopus agent hook");
185
+ expect(installed.hookCommands.SessionEnd).toBe("clocktopus agent hook");
186
+ });
187
+ it("reports no telemetry for a settings file that only has the user's own hooks", () => {
188
+ writeFileSync(path, JSON.stringify(userSettings()), "utf8");
189
+ const installed = readInstalledTelemetry(path);
190
+ expect(installed.env).toEqual({});
191
+ expect(installed.hookCommands.SessionStart).toBeUndefined();
192
+ });
193
+ });