@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.
- package/README.md +163 -5
- package/dist/src/commands/agent/disable.d.ts +21 -0
- package/dist/src/commands/agent/disable.d.ts.map +1 -0
- package/dist/src/commands/agent/disable.js +106 -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 +306 -0
- package/dist/src/commands/agent/hook.d.ts +5 -0
- package/dist/src/commands/agent/hook.d.ts.map +1 -0
- package/dist/src/commands/agent/hook.js +368 -0
- package/dist/src/commands/agent/setup.d.ts +28 -0
- package/dist/src/commands/agent/setup.d.ts.map +1 -0
- package/dist/src/commands/agent/setup.js +333 -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 +182 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +51 -2
- package/dist/src/lib/agent-config.d.ts +56 -0
- package/dist/src/lib/agent-config.d.ts.map +1 -0
- package/dist/src/lib/agent-config.js +168 -0
- package/dist/src/lib/agent-hook-state.d.ts +44 -0
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
- package/dist/src/lib/agent-hook-state.js +155 -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/agents.d.ts +115 -0
- package/dist/src/lib/agents.d.ts.map +1 -0
- package/dist/src/lib/agents.js +245 -0
- 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/codex-config.d.ts +166 -0
- package/dist/src/lib/codex-config.d.ts.map +1 -0
- package/dist/src/lib/codex-config.js +441 -0
- package/dist/src/lib/codex-config.test.d.ts +2 -0
- package/dist/src/lib/codex-config.test.d.ts.map +1 -0
- package/dist/src/lib/codex-config.test.js +359 -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/opencode-config.d.ts +108 -0
- package/dist/src/lib/opencode-config.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.js +330 -0
- package/dist/src/lib/opencode-config.test.d.ts +2 -0
- package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.test.js +140 -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 -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 @@
|
|
|
1
|
+
{"version":3,"file":"claude-settings.test.d.ts","sourceRoot":"","sources":["../../../src/lib/claude-settings.test.ts"],"names":[],"mappings":""}
|