@blastin-dev/clocktopus-cli 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +163 -5
  2. package/dist/src/commands/agent/disable.d.ts +21 -0
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -0
  4. package/dist/src/commands/agent/disable.js +106 -0
  5. package/dist/src/commands/agent/doctor.d.ts +2 -0
  6. package/dist/src/commands/agent/doctor.d.ts.map +1 -0
  7. package/dist/src/commands/agent/doctor.js +306 -0
  8. package/dist/src/commands/agent/hook.d.ts +5 -0
  9. package/dist/src/commands/agent/hook.d.ts.map +1 -0
  10. package/dist/src/commands/agent/hook.js +368 -0
  11. package/dist/src/commands/agent/setup.d.ts +28 -0
  12. package/dist/src/commands/agent/setup.d.ts.map +1 -0
  13. package/dist/src/commands/agent/setup.js +333 -0
  14. package/dist/src/commands/agent/status.d.ts +2 -0
  15. package/dist/src/commands/agent/status.d.ts.map +1 -0
  16. package/dist/src/commands/agent/status.js +182 -0
  17. package/dist/src/index.d.ts.map +1 -1
  18. package/dist/src/index.js +51 -2
  19. package/dist/src/lib/agent-config.d.ts +56 -0
  20. package/dist/src/lib/agent-config.d.ts.map +1 -0
  21. package/dist/src/lib/agent-config.js +168 -0
  22. package/dist/src/lib/agent-hook-state.d.ts +44 -0
  23. package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
  24. package/dist/src/lib/agent-hook-state.js +155 -0
  25. package/dist/src/lib/agent-receiver.d.ts +26 -0
  26. package/dist/src/lib/agent-receiver.d.ts.map +1 -0
  27. package/dist/src/lib/agent-receiver.js +44 -0
  28. package/dist/src/lib/agents.d.ts +115 -0
  29. package/dist/src/lib/agents.d.ts.map +1 -0
  30. package/dist/src/lib/agents.js +245 -0
  31. package/dist/src/lib/claude-settings.d.ts +82 -0
  32. package/dist/src/lib/claude-settings.d.ts.map +1 -0
  33. package/dist/src/lib/claude-settings.js +271 -0
  34. package/dist/src/lib/claude-settings.test.d.ts +2 -0
  35. package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
  36. package/dist/src/lib/claude-settings.test.js +193 -0
  37. package/dist/src/lib/codex-config.d.ts +166 -0
  38. package/dist/src/lib/codex-config.d.ts.map +1 -0
  39. package/dist/src/lib/codex-config.js +441 -0
  40. package/dist/src/lib/codex-config.test.d.ts +2 -0
  41. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  42. package/dist/src/lib/codex-config.test.js +359 -0
  43. package/dist/src/lib/config.d.ts +23 -0
  44. package/dist/src/lib/config.d.ts.map +1 -1
  45. package/dist/src/lib/config.js +14 -0
  46. package/dist/src/lib/format.d.ts +6 -0
  47. package/dist/src/lib/format.d.ts.map +1 -0
  48. package/dist/src/lib/format.js +19 -0
  49. package/dist/src/lib/git.d.ts +3 -0
  50. package/dist/src/lib/git.d.ts.map +1 -0
  51. package/dist/src/lib/git.js +30 -0
  52. package/dist/src/lib/opencode-config.d.ts +108 -0
  53. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  54. package/dist/src/lib/opencode-config.js +330 -0
  55. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  56. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  57. package/dist/src/lib/opencode-config.test.js +140 -0
  58. package/dist/src/lib/repo-guidance.d.ts +40 -0
  59. package/dist/src/lib/repo-guidance.d.ts.map +1 -0
  60. package/dist/src/lib/repo-guidance.js +123 -0
  61. package/dist/src/lib/validators.d.ts +69 -0
  62. package/dist/src/lib/validators.d.ts.map +1 -1
  63. package/dist/src/lib/validators.js +69 -0
  64. package/package.json +7 -4
@@ -0,0 +1,245 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { applyTelemetrySettings, readInstalledTelemetry, readSettings, removeTelemetrySettings, SettingsParseError, settingsPath, writeSettings, } from "./claude-settings.js";
3
+ import { applyCodexHooks, applyCodexOtel, CodexConfigParseError, codexConfigPath, codexHooksPath, HOOK_EVENT_COUNT, readCodexConfig, readCodexHooks, readCodexHookTrust, readCodexTelemetry, removeCodexHooks, removeCodexOtel, writeCodexConfig, writeCodexHooks, } from "./codex-config.js";
4
+ import { buildOpencodePlugin, OPENCODE_PLUGIN_VERSION, opencodePluginPath, readOpencodePlugin, removeOpencodePlugin, splitCommandLine, writeOpencodePlugin, } from "./opencode-config.js";
5
+ /** `true` when the parse error is one of ours, whichever agent raised it. */
6
+ export function isConfigParseError(error) {
7
+ return (error instanceof SettingsParseError ||
8
+ error instanceof CodexConfigParseError);
9
+ }
10
+ const claudeAdapter = {
11
+ id: "claude",
12
+ label: "Claude Code",
13
+ provider: "claude_code",
14
+ binary: "claude",
15
+ hostRunsHooksAsync: true,
16
+ hostRepeatsSessionEnd: false,
17
+ paths: () => [settingsPath()],
18
+ read() {
19
+ const installed = readInstalledTelemetry();
20
+ return {
21
+ token: installed.env.CLOCKTOPUS_INGEST_TOKEN ?? null,
22
+ endpoint: installed.env.CLOCKTOPUS_OTEL_ENDPOINT ?? null,
23
+ hookCommands: installed.hookCommands,
24
+ modifiedAt: installed.modifiedAt,
25
+ paths: [installed.path],
26
+ };
27
+ },
28
+ apply(input) {
29
+ const current = readSettings();
30
+ const next = applyTelemetrySettings(current.settings, input);
31
+ const { backupPath } = writeSettings(next, current.path);
32
+ return { backupPaths: backupPath ? [backupPath] : [] };
33
+ },
34
+ remove() {
35
+ const current = readSettings();
36
+ if (!current.exists)
37
+ return { removed: [] };
38
+ const result = removeTelemetrySettings(current.settings);
39
+ if (result.removedEnvKeys.length === 0 && !result.removedHooks)
40
+ return { removed: [] };
41
+ writeSettings(result.settings, current.path);
42
+ return {
43
+ removed: [
44
+ ...result.removedEnvKeys,
45
+ ...(result.removedHooks ? ["SessionStart/SessionEnd hooks"] : []),
46
+ ],
47
+ };
48
+ },
49
+ pendingActions: () => [],
50
+ };
51
+ const codexAdapter = {
52
+ id: "codex",
53
+ label: "Codex CLI",
54
+ provider: "codex_cli",
55
+ binary: "codex",
56
+ hostRunsHooksAsync: false,
57
+ hostRepeatsSessionEnd: false,
58
+ paths: () => [codexConfigPath(), codexHooksPath()],
59
+ read() {
60
+ const telemetry = readCodexTelemetry();
61
+ const hooks = readCodexHooks();
62
+ return {
63
+ token: telemetry.token,
64
+ endpoint: telemetry.endpoint,
65
+ hookCommands: hooks.commands,
66
+ modifiedAt: newest([telemetry.modifiedAt, hooks.modifiedAt]),
67
+ paths: [telemetry.path, hooks.path],
68
+ };
69
+ },
70
+ apply(input) {
71
+ const backupPaths = [];
72
+ const config = readCodexConfig();
73
+ const nextText = applyCodexOtel(config, input);
74
+ const written = writeCodexConfig({
75
+ path: config.path,
76
+ previousText: config.text,
77
+ previousConfig: config.config,
78
+ nextText,
79
+ });
80
+ if (written.backupPath)
81
+ backupPaths.push(written.backupPath);
82
+ const hooks = readCodexHooks();
83
+ const nextHooks = applyCodexHooks(hooks.file, input);
84
+ const hooksWritten = writeCodexHooks(nextHooks, hooks.path);
85
+ if (hooksWritten.backupPath)
86
+ backupPaths.push(hooksWritten.backupPath);
87
+ return { backupPaths };
88
+ },
89
+ remove() {
90
+ const removed = [];
91
+ const config = readCodexConfig();
92
+ if (config.exists) {
93
+ const { nextText, removedKeys } = removeCodexOtel(config);
94
+ if (removedKeys.length > 0) {
95
+ writeCodexConfig({
96
+ path: config.path,
97
+ previousText: config.text,
98
+ previousConfig: config.config,
99
+ nextText,
100
+ });
101
+ removed.push(...removedKeys.map((key) => `otel.${key}`));
102
+ }
103
+ }
104
+ const hooks = readCodexHooks();
105
+ if (hooks.exists) {
106
+ const result = removeCodexHooks(hooks.file);
107
+ if (result.removed) {
108
+ writeCodexHooks(result.file, hooks.path);
109
+ removed.push("SessionStart/SessionEnd hooks");
110
+ }
111
+ }
112
+ return { removed };
113
+ },
114
+ pendingActions() {
115
+ // Codex refuses to run a hooks.json it has not been shown, and there is
116
+ // no way to approve it from here — the prompt is part of the TUI. Left
117
+ // unsaid, this looks exactly like a broken install: telemetry arrives,
118
+ // repository context never does.
119
+ if (!readCodexHooks().exists)
120
+ return [];
121
+ const { untrusted } = readCodexHookTrust();
122
+ if (untrusted.length === 0)
123
+ return [];
124
+ // Says what is missing, never what is already fine. Trust is keyed on a
125
+ // hash of the hook entry, so rewriting hooks.json invalidates every
126
+ // approval it already had — `trusted` can be stale the moment setup
127
+ // runs, and promising the user that half of it is done would be a lie
128
+ // as often as not.
129
+ const missing = untrusted.length === HOOK_EVENT_COUNT
130
+ ? "its hooks"
131
+ : `its ${untrusted.join(" and ")} hook`;
132
+ return [
133
+ "Start Codex once and answer 'Trust all and continue' when it asks\n" +
134
+ ` about ${missing}. Until then Codex records your spend but not the\n` +
135
+ " repository it was spent on — it will not run an unapproved hook.",
136
+ ];
137
+ },
138
+ };
139
+ const opencodeAdapter = {
140
+ id: "opencode",
141
+ label: "OpenCode",
142
+ provider: "opencode",
143
+ binary: "opencode",
144
+ // The plugin runs inside OpenCode's own event loop and does not await the
145
+ // work it starts, so the hook is already off the critical path by the
146
+ // time it reaches the CLI. Re-spawning would only add a process.
147
+ hostRunsHooksAsync: true,
148
+ // `session.idle` is the closest thing OpenCode has to a session end, and
149
+ // it fires after every turn rather than once.
150
+ hostRepeatsSessionEnd: true,
151
+ paths: () => [opencodePluginPath()],
152
+ read() {
153
+ const plugin = readOpencodePlugin();
154
+ return {
155
+ token: plugin.config?.token ?? null,
156
+ endpoint: plugin.config?.endpoint ?? null,
157
+ // Both events come from the one plugin file, so they are installed
158
+ // together or not at all — unlike the hook agents, where each event is
159
+ // a separate entry that can go missing on its own.
160
+ hookCommands: plugin.config
161
+ ? {
162
+ SessionStart: plugin.config.hookArgv.join(" "),
163
+ SessionEnd: plugin.config.hookArgv.join(" "),
164
+ }
165
+ : {},
166
+ modifiedAt: plugin.modifiedAt,
167
+ paths: [plugin.path],
168
+ };
169
+ },
170
+ apply(input) {
171
+ const { backupPath } = writeOpencodePlugin(buildOpencodePlugin({
172
+ endpoint: input.endpoint,
173
+ token: input.token,
174
+ hookArgv: splitCommandLine(input.hookCommand),
175
+ }));
176
+ return { backupPaths: backupPath ? [backupPath] : [] };
177
+ },
178
+ remove() {
179
+ return { removed: removeOpencodePlugin() ? ["the Clocktopus plugin"] : [] };
180
+ },
181
+ pendingActions: () => [],
182
+ staleReason() {
183
+ const plugin = readOpencodePlugin();
184
+ if (!plugin.exists || !plugin.config)
185
+ return null;
186
+ if (plugin.version === OPENCODE_PLUGIN_VERSION)
187
+ return null;
188
+ return (`the installed plugin was generated by an older CLI ` +
189
+ `(generation ${plugin.version ?? "unstamped"}, current is ` +
190
+ `${OPENCODE_PLUGIN_VERSION})`);
191
+ },
192
+ };
193
+ export const AGENTS = [
194
+ claudeAdapter,
195
+ codexAdapter,
196
+ opencodeAdapter,
197
+ ];
198
+ /**
199
+ * What is on this machine, and what is already wired up.
200
+ *
201
+ * Detection is by `--version` rather than by config directory: `~/.claude`
202
+ * and `~/.codex` both outlive an uninstall, so a stale directory would
203
+ * offer to configure an agent that is no longer there.
204
+ */
205
+ export function surveyAgents() {
206
+ return AGENTS.map((agent) => {
207
+ const version = detectVersion(agent.binary);
208
+ let configured = false;
209
+ let unreadable = null;
210
+ try {
211
+ configured = agent.read().token !== null;
212
+ }
213
+ catch (error) {
214
+ if (isConfigParseError(error))
215
+ unreadable = error.message;
216
+ else
217
+ throw error;
218
+ }
219
+ return {
220
+ agent,
221
+ version,
222
+ installed: version !== null,
223
+ configured,
224
+ unreadable,
225
+ };
226
+ });
227
+ }
228
+ function detectVersion(binary) {
229
+ try {
230
+ const output = execFileSync(binary, ["--version"], {
231
+ encoding: "utf8",
232
+ stdio: ["ignore", "pipe", "ignore"],
233
+ timeout: 5000,
234
+ });
235
+ // Both CLIs print a line with the version somewhere in it; the number is
236
+ // the only part worth showing, and neither format is worth parsing hard.
237
+ return (/\d+\.\d+\.\d+/.exec(output)?.[0] ?? output.trim().split("\n")[0] ?? null);
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ }
243
+ function newest(dates) {
244
+ return dates.reduce((latest, date) => (date && (!latest || date > latest) ? date : latest), null);
245
+ }
@@ -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":""}