@blastin-dev/clocktopus-cli 0.1.4 → 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 (49) hide show
  1. package/README.md +108 -5
  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/index.d.ts.map +1 -1
  18. package/dist/src/index.js +37 -2
  19. package/dist/src/lib/agent-config.d.ts +41 -0
  20. package/dist/src/lib/agent-config.d.ts.map +1 -0
  21. package/dist/src/lib/agent-config.js +143 -0
  22. package/dist/src/lib/agent-hook-state.d.ts +36 -0
  23. package/dist/src/lib/agent-hook-state.d.ts.map +1 -0
  24. package/dist/src/lib/agent-hook-state.js +136 -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/claude-settings.d.ts +82 -0
  29. package/dist/src/lib/claude-settings.d.ts.map +1 -0
  30. package/dist/src/lib/claude-settings.js +271 -0
  31. package/dist/src/lib/claude-settings.test.d.ts +2 -0
  32. package/dist/src/lib/claude-settings.test.d.ts.map +1 -0
  33. package/dist/src/lib/claude-settings.test.js +193 -0
  34. package/dist/src/lib/config.d.ts +23 -0
  35. package/dist/src/lib/config.d.ts.map +1 -1
  36. package/dist/src/lib/config.js +14 -0
  37. package/dist/src/lib/format.d.ts +6 -0
  38. package/dist/src/lib/format.d.ts.map +1 -0
  39. package/dist/src/lib/format.js +19 -0
  40. package/dist/src/lib/git.d.ts +3 -0
  41. package/dist/src/lib/git.d.ts.map +1 -0
  42. package/dist/src/lib/git.js +30 -0
  43. package/dist/src/lib/repo-guidance.d.ts +40 -0
  44. package/dist/src/lib/repo-guidance.d.ts.map +1 -0
  45. package/dist/src/lib/repo-guidance.js +123 -0
  46. package/dist/src/lib/validators.d.ts +69 -0
  47. package/dist/src/lib/validators.d.ts.map +1 -1
  48. package/dist/src/lib/validators.js +69 -0
  49. package/package.json +6 -4
@@ -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
+ });
@@ -1,8 +1,25 @@
1
1
  export declare const ENVIRONMENTS: {
2
2
  readonly prod: "https://clocktopus.app";
3
+ readonly staging: "https://staging.clocktopus.app";
3
4
  readonly dev: "http://localhost:3000";
4
5
  };
5
6
  export type Environment = keyof typeof ENVIRONMENTS;
7
+ /**
8
+ * Bookkeeping for the agent telemetry setup.
9
+ *
10
+ * Deliberately does **not** hold the ingest token. The token has to live in
11
+ * `~/.claude/settings.json` for Claude Code's exporter to read it, and a
12
+ * second copy here would be a second thing to leak and a second thing to
13
+ * fall out of date. What is kept is only what setup cannot recover later:
14
+ * the row id needed to revoke, and the prefix needed to display.
15
+ */
16
+ type AgentConfig = {
17
+ tokenId?: string;
18
+ tokenPrefix?: string;
19
+ endpoint?: string;
20
+ /** ISO timestamp — `agent doctor` compares it against settings.json. */
21
+ configuredAt?: string;
22
+ };
6
23
  export declare function setRuntimeEnvironment(env: Environment): void;
7
24
  export declare function getEnvironment(): Environment;
8
25
  export declare function getApiUrl(): string;
@@ -10,4 +27,10 @@ export declare function getToken(): string | undefined;
10
27
  export declare function setToken(token: string): void;
11
28
  export declare function clearToken(): void;
12
29
  export declare function isLoggedIn(): boolean;
30
+ export declare function getAgentConfig(): AgentConfig;
31
+ export declare function setAgentConfig(next: AgentConfig): void;
32
+ export declare function clearAgentConfig(): void;
33
+ /** Where the config lives, so `agent doctor` can point at a real file. */
34
+ export declare function getConfigPath(): string;
35
+ export {};
13
36
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/lib/config.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;CAGf,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,YAAY,CAAC;AAapD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAE5D;AAED,wBAAgB,cAAc,IAAI,WAAW,CAO5C;AAED,wBAAgB,SAAS,IAAI,MAAM,CASlC;AAED,wBAAgB,QAAQ,IAAI,MAAM,GAAG,SAAS,CAE7C;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAE5C;AAED,wBAAgB,UAAU,IAAI,IAAI,CAEjC;AAED,wBAAgB,UAAU,IAAI,OAAO,CAEpC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/lib/config.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;;CAIf,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,YAAY,CAAC;AAEpD;;;;;;;;GAQG;AACH,KAAK,WAAW,GAAG;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAcF,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAE5D;AAED,wBAAgB,cAAc,IAAI,WAAW,CAO5C;AAED,wBAAgB,SAAS,IAAI,MAAM,CASlC;AAED,wBAAgB,QAAQ,IAAI,MAAM,GAAG,SAAS,CAE7C;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAE5C;AAED,wBAAgB,UAAU,IAAI,IAAI,CAEjC;AAED,wBAAgB,UAAU,IAAI,OAAO,CAEpC;AAED,wBAAgB,cAAc,IAAI,WAAW,CAE5C;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAEtD;AAED,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,IAAI,MAAM,CAEtC"}
@@ -1,6 +1,7 @@
1
1
  import Conf from "conf";
2
2
  export const ENVIRONMENTS = {
3
3
  prod: "https://clocktopus.app",
4
+ staging: "https://staging.clocktopus.app",
4
5
  dev: "http://localhost:3000",
5
6
  };
6
7
  const config = new Conf({
@@ -40,3 +41,16 @@ export function clearToken() {
40
41
  export function isLoggedIn() {
41
42
  return !!getToken();
42
43
  }
44
+ export function getAgentConfig() {
45
+ return config.get("agent") ?? {};
46
+ }
47
+ export function setAgentConfig(next) {
48
+ config.set("agent", { ...getAgentConfig(), ...next });
49
+ }
50
+ export function clearAgentConfig() {
51
+ config.delete("agent");
52
+ }
53
+ /** Where the config lives, so `agent doctor` can point at a real file. */
54
+ export function getConfigPath() {
55
+ return config.path;
56
+ }
@@ -0,0 +1,6 @@
1
+ /** "3 minutes ago" / "never". Null-safe because most of these fields are. */
2
+ export declare function formatAgo(value: string | null | undefined): string;
3
+ export declare function microUsdToDisplay(microUsd: number): string;
4
+ /** Pads a label so the value column lines up in the status blocks. */
5
+ export declare function labelled(label: string, value: string, width?: number): string;
6
+ //# sourceMappingURL=format.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../../src/lib/format.ts"],"names":[],"mappings":"AAEA,6EAA6E;AAC7E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAOlE;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,sEAAsE;AACtE,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,SAAK,GAAG,MAAM,CAEzE"}
@@ -0,0 +1,19 @@
1
+ import { formatDistanceToNowStrict, parseISO } from "date-fns";
2
+ /** "3 minutes ago" / "never". Null-safe because most of these fields are. */
3
+ export function formatAgo(value) {
4
+ if (!value)
5
+ return "never";
6
+ try {
7
+ return formatDistanceToNowStrict(parseISO(value), { addSuffix: true });
8
+ }
9
+ catch {
10
+ return value;
11
+ }
12
+ }
13
+ export function microUsdToDisplay(microUsd) {
14
+ return `$${(microUsd / 1_000_000).toFixed(2)}`;
15
+ }
16
+ /** Pads a label so the value column lines up in the status blocks. */
17
+ export function labelled(label, value, width = 14) {
18
+ return ` ${label.padEnd(width)}${value}`;
19
+ }
@@ -0,0 +1,3 @@
1
+ export declare function getRepositoryRemote(cwd?: string): string | null;
2
+ export declare function isGitRepository(cwd?: string): boolean;
3
+ //# sourceMappingURL=git.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git.d.ts","sourceRoot":"","sources":["../../../src/lib/git.ts"],"names":[],"mappings":"AA0BA,wBAAgB,mBAAmB,CAAC,GAAG,SAAgB,GAAG,MAAM,GAAG,IAAI,CAEtE;AAED,wBAAgB,eAAe,CAAC,GAAG,SAAgB,GAAG,OAAO,CAE5D"}
@@ -0,0 +1,30 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /**
3
+ * Just enough git to work out which repository the user is standing in.
4
+ *
5
+ * The CLI deliberately does not parse the remote — `apps/cli` is published
6
+ * standalone and cannot import `@repo/core`, so a parser here would be a
7
+ * second implementation destined to drift from the receiver's. The raw
8
+ * remote goes to the server, which owns the one definition of a supported
9
+ * remote.
10
+ */
11
+ function git(cwd, args) {
12
+ try {
13
+ const output = execFileSync("git", args, {
14
+ cwd,
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "ignore"],
17
+ timeout: 2000,
18
+ }).trim();
19
+ return output || undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ export function getRepositoryRemote(cwd = process.cwd()) {
26
+ return git(cwd, ["remote", "get-url", "origin"]) ?? null;
27
+ }
28
+ export function isGitRepository(cwd = process.cwd()) {
29
+ return git(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
30
+ }
@@ -0,0 +1,40 @@
1
+ import type { RepoStatusResponse } from "./validators.js";
2
+ /**
3
+ * The half of the setup that is not on this machine.
4
+ *
5
+ * Configuring the exporter and the hook makes agent sessions *arrive*. It
6
+ * does not make them *mean* anything, and the two ways that fails are
7
+ * unrelated to each other and to everything `agent setup` writes locally:
8
+ *
9
+ * - The repository is not attached to a project, so spend lands in the
10
+ * unattributed row. Purely a Clocktopus-side mapping; no webhook affects
11
+ * it either way.
12
+ * - The push webhook is not delivering, so there are no human time entries
13
+ * to compare the spend against. Note this does *not* stop agent→commit
14
+ * links: the SessionEnd hook resolves the commit range locally and posts
15
+ * it. What is missing is the other side of the comparison, and a cost
16
+ * without the hours it bought is not a smaller answer — it is not one.
17
+ *
18
+ * Reported as two separate verdicts, because they send you to two
19
+ * different places, and a single "not set up" would send you to neither.
20
+ */
21
+ export type RepoCheck = {
22
+ label: string;
23
+ ok: boolean | "warn";
24
+ detail: string;
25
+ fix?: string;
26
+ };
27
+ /**
28
+ * Fetches readiness for the repository the CLI is standing in.
29
+ *
30
+ * Returns null when there is nothing to ask about — outside a git tree, or
31
+ * when the server is unreachable. A failure here must never fail `setup`:
32
+ * the local configuration it just wrote is valid regardless.
33
+ */
34
+ export declare function fetchRepoStatus(cwd?: string): Promise<RepoStatusResponse | null>;
35
+ export declare function attributionCheck(status: RepoStatusResponse): RepoCheck;
36
+ export declare function commitLaneCheck(status: RepoStatusResponse): RepoCheck;
37
+ export declare function repoChecks(status: RepoStatusResponse): RepoCheck[];
38
+ /** The block `agent setup` prints after writing the local configuration. */
39
+ export declare function renderRepoBlock(status: RepoStatusResponse): string[];
40
+ //# sourceMappingURL=repo-guidance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repo-guidance.d.ts","sourceRoot":"","sources":["../../../src/lib/repo-guidance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAO1D;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,MAAM,SAAS,GAAG;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,OAAO,GAAG,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,GAAG,SAAgB,GAClB,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAapC;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,kBAAkB,GAAG,SAAS,CAqDtE;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,kBAAkB,GAAG,SAAS,CAqCrE;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,kBAAkB,GAAG,SAAS,EAAE,CAElE;AAED,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,EAAE,CAepE"}