@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
package/dist/src/index.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { Command } from "commander";
3
3
  import { z } from "zod";
4
+ import { disableCommand } from "./commands/agent/disable.js";
5
+ import { doctorCommand } from "./commands/agent/doctor.js";
6
+ import { hookCommand } from "./commands/agent/hook.js";
7
+ import { setupCommand } from "./commands/agent/setup.js";
8
+ import { statusCommand } from "./commands/agent/status.js";
4
9
  import { clockInCommand, clockOutCommand, clockStatusCommand, } from "./commands/clock.js";
5
10
  import { loginCommand } from "./commands/login.js";
6
11
  import { logoutCommand } from "./commands/logout.js";
@@ -16,12 +21,12 @@ program
16
21
  .name("clocktopus")
17
22
  .description("CLI for Clocktopus time tracking")
18
23
  .version(version)
19
- .option("-e, --env <environment>", "Use environment (dev or prod)")
24
+ .option("-e, --env <environment>", "Use environment (dev, staging or prod)")
20
25
  .hook("preAction", (thisCommand) => {
21
26
  const opts = thisCommand.opts();
22
27
  if (opts.env) {
23
28
  if (!(opts.env in ENVIRONMENTS)) {
24
- console.error(`Invalid environment: ${opts.env}. Use 'dev' or 'prod'.`);
29
+ console.error(`Invalid environment: ${opts.env}. Use 'dev', 'staging' or 'prod'.`);
25
30
  process.exit(1);
26
31
  }
27
32
  setRuntimeEnvironment(opts.env);
@@ -60,6 +65,36 @@ clock
60
65
  .description("Show clock signals for a specific date")
61
66
  .option("-d, --date <date>", "Date in YYYY-MM-DD format (default: today)")
62
67
  .action(clockStatusCommand);
68
+ // Agent telemetry — tracks what AI agents cost, alongside human time.
69
+ const agent = program
70
+ .command("agent")
71
+ .description("Track AI agent spend from this machine");
72
+ agent
73
+ .command("setup")
74
+ .description("Point Claude Code at Clocktopus and install the session hooks")
75
+ .option("--name <name>", "Label for this machine's ingest token")
76
+ .option("--force", "Mint a replacement token instead of reusing the existing one")
77
+ .action((options) => setupCommand(options));
78
+ agent
79
+ .command("status")
80
+ .description("Show what the receiver has actually received")
81
+ .action(statusCommand);
82
+ agent
83
+ .command("doctor")
84
+ .description("Check every link in the telemetry chain and report failures")
85
+ .action(doctorCommand);
86
+ agent
87
+ .command("disable")
88
+ .description("Remove this machine's telemetry configuration")
89
+ .option("--revoke", "Also revoke the ingest token, everywhere")
90
+ .action((options) => disableCommand(options));
91
+ // Invoked by Claude Code, never by a person: it reads the hook payload from
92
+ // stdin and must not write to stdout. Hidden so it does not read as
93
+ // something to run by hand.
94
+ agent
95
+ .command("hook", { hidden: true })
96
+ .description("Internal: Claude Code SessionStart/SessionEnd hook")
97
+ .action(hookCommand);
63
98
  export function run() {
64
99
  program.parse();
65
100
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Where the ingest credentials come from, and what shadows what.
3
+ *
4
+ * The order matters and is not arbitrary. Inside a Claude Code session the
5
+ * process environment is what the exporter and the hook actually read, and
6
+ * `settings.json` `env` is applied *over* the inherited shell environment —
7
+ * so a value in settings.json wins over one exported from `.envrc` or a
8
+ * shell profile, silently. That is the failure this resolver is built to
9
+ * make visible rather than to paper over: it reports the source alongside
10
+ * the value so `doctor` can name the file that is winning.
11
+ */
12
+ export type CredentialSource = "environment" | "settings" | "none";
13
+ export type ResolvedAgentCredentials = {
14
+ token: string | null;
15
+ endpoint: string | null;
16
+ tokenSource: CredentialSource;
17
+ endpointSource: CredentialSource;
18
+ };
19
+ export declare function resolveAgentCredentials(): ResolvedAgentCredentials;
20
+ /** Masks a token for display: never print more than the stored prefix. */
21
+ export declare function maskToken(token: string): string;
22
+ export declare function findShadowedExports(cwd?: string): Array<{
23
+ path: string;
24
+ keys: string[];
25
+ }>;
26
+ /**
27
+ * The command to write into settings.json for the hook.
28
+ *
29
+ * Prefers the bare name, but only after confirming that `clocktopus` on
30
+ * PATH resolves to *this* executable. Hooks run through a shell whose PATH
31
+ * may differ from the interactive one, and a bare name that does not
32
+ * resolve there fails silently — the session runs fine and every bit of
33
+ * repository context is lost. An absolute path is uglier and survives that;
34
+ * `doctor` checks it still exists.
35
+ */
36
+ export declare function resolveHookCommand(): {
37
+ command: string;
38
+ usesAbsolutePath: boolean;
39
+ binaryPath: string | null;
40
+ };
41
+ //# sourceMappingURL=agent-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-config.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-config.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,UAAU,GAAG,MAAM,CAAC;AAEnE,MAAM,MAAM,wBAAwB,GAAG;IACrC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,gBAAgB,CAAC;IAC9B,cAAc,EAAE,gBAAgB,CAAC;CAClC,CAAC;AAEF,wBAAgB,uBAAuB,IAAI,wBAAwB,CA2BlE;AAED,0EAA0E;AAC1E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/C;AAmBD,wBAAgB,mBAAmB,CAAC,GAAG,SAAgB,GAAG,KAAK,CAAC;IAC9D,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB,CAAC,CA+BD;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,IAAI;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CA8BA"}
@@ -0,0 +1,143 @@
1
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { delimiter, join, resolve } from "node:path";
4
+ import { readInstalledTelemetry } from "./claude-settings.js";
5
+ export function resolveAgentCredentials() {
6
+ const envToken = process.env.CLOCKTOPUS_INGEST_TOKEN?.trim();
7
+ const envEndpoint = process.env.CLOCKTOPUS_OTEL_ENDPOINT?.trim();
8
+ let settingsToken;
9
+ let settingsEndpoint;
10
+ try {
11
+ const installed = readInstalledTelemetry();
12
+ settingsToken = installed.env.CLOCKTOPUS_INGEST_TOKEN;
13
+ settingsEndpoint = installed.env.CLOCKTOPUS_OTEL_ENDPOINT;
14
+ }
15
+ catch {
16
+ // A malformed settings.json is reported properly by `setup` and
17
+ // `doctor`. Credential resolution must not throw — the hook calls it on
18
+ // every session start.
19
+ }
20
+ return {
21
+ token: envToken || settingsToken || null,
22
+ endpoint: (envEndpoint || settingsEndpoint || null)?.replace(/\/$/, "") ?? null,
23
+ tokenSource: envToken ? "environment" : settingsToken ? "settings" : "none",
24
+ endpointSource: envEndpoint
25
+ ? "environment"
26
+ : settingsEndpoint
27
+ ? "settings"
28
+ : "none",
29
+ };
30
+ }
31
+ /** Masks a token for display: never print more than the stored prefix. */
32
+ export function maskToken(token) {
33
+ return token.length <= 15 ? "…" : `${token.slice(0, 15)}…`;
34
+ }
35
+ /**
36
+ * Files that commonly export the same variables, and would be shadowed.
37
+ *
38
+ * Scanned rather than guessed at, because "your `.envrc` is being ignored"
39
+ * is only useful advice when it names the file. The repo-local `.envrc` is
40
+ * included because that is exactly how this project was dogfooded before
41
+ * the CLI existed.
42
+ */
43
+ const SHELL_FILES = [
44
+ ".bashrc",
45
+ ".bash_profile",
46
+ ".zshrc",
47
+ ".zshenv",
48
+ ".profile",
49
+ ".config/fish/config.fish",
50
+ ];
51
+ export function findShadowedExports(cwd = process.cwd()) {
52
+ const candidates = [
53
+ ...SHELL_FILES.map((file) => join(homedir(), file)),
54
+ join(cwd, ".envrc"),
55
+ ];
56
+ const found = [];
57
+ for (const path of candidates) {
58
+ let contents;
59
+ try {
60
+ if (!existsSync(path))
61
+ continue;
62
+ contents = readFileSync(path, "utf8");
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ const keys = [
68
+ "CLOCKTOPUS_INGEST_TOKEN",
69
+ "CLOCKTOPUS_OTEL_ENDPOINT",
70
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
71
+ "OTEL_EXPORTER_OTLP_HEADERS",
72
+ "CLAUDE_CODE_ENABLE_TELEMETRY",
73
+ ].filter((key) => new RegExp(`^\\s*(export\\s+)?${key}=`, "m").test(contents));
74
+ if (keys.length > 0)
75
+ found.push({ path, keys });
76
+ }
77
+ return found;
78
+ }
79
+ /**
80
+ * The command to write into settings.json for the hook.
81
+ *
82
+ * Prefers the bare name, but only after confirming that `clocktopus` on
83
+ * PATH resolves to *this* executable. Hooks run through a shell whose PATH
84
+ * may differ from the interactive one, and a bare name that does not
85
+ * resolve there fails silently — the session runs fine and every bit of
86
+ * repository context is lost. An absolute path is uglier and survives that;
87
+ * `doctor` checks it still exists.
88
+ */
89
+ export function resolveHookCommand() {
90
+ const script = process.argv[1] ? resolve(process.argv[1]) : null;
91
+ const quote = (value) => /[\s"']/.test(value) ? `"${value}"` : value;
92
+ if (script && resolvesOnPath("clocktopus", script)) {
93
+ return {
94
+ command: "clocktopus agent hook",
95
+ usesAbsolutePath: false,
96
+ binaryPath: script,
97
+ };
98
+ }
99
+ if (script) {
100
+ // `node <script>` rather than executing the script directly: the file in
101
+ // a published package keeps its shebang, but a checkout may not have the
102
+ // executable bit, and the hook must not depend on that.
103
+ return {
104
+ command: `${quote(process.execPath)} ${quote(script)} agent hook`,
105
+ usesAbsolutePath: true,
106
+ binaryPath: script,
107
+ };
108
+ }
109
+ return {
110
+ command: "clocktopus agent hook",
111
+ usesAbsolutePath: false,
112
+ binaryPath: null,
113
+ };
114
+ }
115
+ function resolvesOnPath(name, expected) {
116
+ const searchPath = process.env.PATH;
117
+ if (!searchPath)
118
+ return false;
119
+ let expectedReal;
120
+ try {
121
+ expectedReal = realpathSync(expected);
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ for (const dir of searchPath.split(delimiter)) {
127
+ if (!dir)
128
+ continue;
129
+ const candidate = join(dir, name);
130
+ try {
131
+ accessSync(candidate, constants.X_OK);
132
+ if (realpathSync(candidate) === expectedReal)
133
+ return true;
134
+ // A different `clocktopus` earlier on PATH would shadow this one, so
135
+ // stop rather than keep looking for a match further down.
136
+ return false;
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ }
142
+ return false;
143
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ declare const LastRunSchema: z.ZodObject<{
3
+ at: z.ZodString;
4
+ event: z.ZodOptional<z.ZodString>;
5
+ sessionId: z.ZodOptional<z.ZodString>;
6
+ endpoint: z.ZodOptional<z.ZodString>;
7
+ tokenPrefix: z.ZodOptional<z.ZodString>;
8
+ status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
9
+ error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
+ repository: z.ZodOptional<z.ZodNullable<z.ZodString>>;
11
+ }, z.core.$strip>;
12
+ export type HookLastRun = z.infer<typeof LastRunSchema>;
13
+ declare const StartStateSchema: z.ZodObject<{
14
+ sha: z.ZodString;
15
+ cwd: z.ZodOptional<z.ZodString>;
16
+ }, z.core.$strip>;
17
+ export type HookStartState = z.infer<typeof StartStateSchema>;
18
+ export declare function writeStartState(sessionId: string, sha: string, cwd: string): void;
19
+ export declare function readStartState(sessionId: string): HookStartState | undefined;
20
+ export declare function clearStartState(sessionId: string): void;
21
+ export declare function listStartStates(): Array<{
22
+ sessionId: string;
23
+ ageMs: number;
24
+ /**
25
+ * When the state file was last touched — the newest moment this machine
26
+ * has evidence the session existed. The sweep sends it as the abandoned
27
+ * session's `ended_at` so the receiver does not stamp its own clock,
28
+ * hours later, as the end of a window used to attribute commits.
29
+ */
30
+ lastActivityAt: Date;
31
+ }>;
32
+ export declare function recordLastRun(run: HookLastRun): void;
33
+ export declare function readLastRun(): HookLastRun | null;
34
+ export declare function clearHookState(): void;
35
+ export {};
36
+ //# sourceMappingURL=agent-hook-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-hook-state.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-hook-state.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA6BxB,QAAA,MAAM,aAAa;;;;;;;;;iBAUjB,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAExD,QAAA,MAAM,gBAAgB;;;iBAGpB,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAK9D,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,GACV,IAAI,CAWN;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAgB5E;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAMvD;AAED,wBAAgB,eAAe,IAAI,KAAK,CAAC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,cAAc,EAAE,IAAI,CAAC;CACtB,CAAC,CAsBD;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAOpD;AAED,wBAAgB,WAAW,IAAI,WAAW,GAAG,IAAI,CAShD;AAED,wBAAgB,cAAc,IAAI,IAAI,CAOrC"}
@@ -0,0 +1,136 @@
1
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ /**
6
+ * On-disk state the SessionStart/SessionEnd hook keeps between its two
7
+ * invocations, plus the record of what the last invocation achieved.
8
+ *
9
+ * Under the cache directory rather than the repository so it never shows up
10
+ * in `git status`. The path is unchanged from the standalone script this
11
+ * command replaced (`scripts/agent-telemetry/claude-hook.mjs`, since
12
+ * deleted) on purpose: a session that started under the old script must
13
+ * still be able to close under the new one, and a sweep must still find
14
+ * what the old one abandoned.
15
+ */
16
+ const CACHE_DIR = join(homedir(), ".cache", "clocktopus");
17
+ const STATE_DIR = join(CACHE_DIR, "agent-sessions");
18
+ /**
19
+ * The hook's last outcome, written every run.
20
+ *
21
+ * This is the only place the pipeline reports on itself from the machine's
22
+ * own side. The hook must stay silent — it cannot warn, prompt or print —
23
+ * so instead it leaves a receipt, and `agent status` / `agent doctor` read
24
+ * it. Without this, a token that started returning 401 mid-week is
25
+ * invisible locally: the session runs normally and nothing is written
26
+ * anywhere the user looks.
27
+ */
28
+ const LAST_RUN_FILE = join(CACHE_DIR, "agent-hook-last.json");
29
+ const LastRunSchema = z.object({
30
+ at: z.string(),
31
+ event: z.string().optional(),
32
+ sessionId: z.string().optional(),
33
+ endpoint: z.string().optional(),
34
+ tokenPrefix: z.string().optional(),
35
+ /** HTTP status from the receiver, or null when the request never landed. */
36
+ status: z.number().nullable().optional(),
37
+ error: z.string().nullable().optional(),
38
+ repository: z.string().nullable().optional(),
39
+ });
40
+ const StartStateSchema = z.object({
41
+ sha: z.string(),
42
+ cwd: z.string().optional(),
43
+ });
44
+ const stateFile = (sessionId) => join(STATE_DIR, `${sessionId.replace(/[^\w-]/g, "")}.sha`);
45
+ export function writeStartState(sessionId, sha, cwd) {
46
+ try {
47
+ mkdirSync(STATE_DIR, { recursive: true });
48
+ // `cwd` is stored alongside the SHA because the sweep runs from
49
+ // whatever repository the *next* session happens to start in.
50
+ // Resolving an abandoned session's SHA against the wrong checkout would
51
+ // either fail or, worse, succeed against an unrelated history.
52
+ writeFileSync(stateFile(sessionId), JSON.stringify({ sha, cwd }), "utf8");
53
+ }
54
+ catch {
55
+ // Losing the start SHA costs the exact commit list, not the session.
56
+ }
57
+ }
58
+ export function readStartState(sessionId) {
59
+ try {
60
+ const raw = readFileSync(stateFile(sessionId), "utf8").trim();
61
+ if (!raw)
62
+ return undefined;
63
+ try {
64
+ const parsed = StartStateSchema.safeParse(JSON.parse(raw));
65
+ return parsed.success ? parsed.data : undefined;
66
+ }
67
+ catch {
68
+ // Files written by earlier versions hold a bare SHA. A session that
69
+ // started under the old format must still close correctly.
70
+ return { sha: raw };
71
+ }
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
77
+ export function clearStartState(sessionId) {
78
+ try {
79
+ rmSync(stateFile(sessionId), { force: true });
80
+ }
81
+ catch {
82
+ // Best effort; a stale file is overwritten by the next session anyway.
83
+ }
84
+ }
85
+ export function listStartStates() {
86
+ try {
87
+ const now = Date.now();
88
+ return readdirSync(STATE_DIR)
89
+ .filter((name) => name.endsWith(".sha"))
90
+ .flatMap((name) => {
91
+ try {
92
+ const { mtimeMs } = statSync(join(STATE_DIR, name));
93
+ return [
94
+ {
95
+ sessionId: name.replace(/\.sha$/, ""),
96
+ ageMs: now - mtimeMs,
97
+ lastActivityAt: new Date(mtimeMs),
98
+ },
99
+ ];
100
+ }
101
+ catch {
102
+ return [];
103
+ }
104
+ });
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ }
110
+ export function recordLastRun(run) {
111
+ try {
112
+ mkdirSync(CACHE_DIR, { recursive: true });
113
+ writeFileSync(LAST_RUN_FILE, JSON.stringify(run, null, 2), "utf8");
114
+ }
115
+ catch {
116
+ // A missing receipt degrades diagnostics; it must never fail a session.
117
+ }
118
+ }
119
+ export function readLastRun() {
120
+ try {
121
+ const parsed = LastRunSchema.safeParse(JSON.parse(readFileSync(LAST_RUN_FILE, "utf8")));
122
+ return parsed.success ? parsed.data : null;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ export function clearHookState() {
129
+ try {
130
+ rmSync(STATE_DIR, { recursive: true, force: true });
131
+ rmSync(LAST_RUN_FILE, { force: true });
132
+ }
133
+ catch {
134
+ // Nothing to clean up, or not ours to clean up.
135
+ }
136
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Talks to the OTLP receiver directly, which is the whole point.
3
+ *
4
+ * The receiver is a different host from the web app — its own Router, its
5
+ * own Lambda, its own domain — so a token that works against the dashboard
6
+ * proves nothing about whether telemetry can reach ingest. This check is
7
+ * the only local way to tell "nothing has happened yet" apart from "nothing
8
+ * can happen": a wrong endpoint, a revoked token and an idle afternoon all
9
+ * look identical otherwise.
10
+ */
11
+ export type ReceiverCheck = {
12
+ ok: true;
13
+ } | {
14
+ ok: false;
15
+ reason: "invalid_token";
16
+ } | {
17
+ ok: false;
18
+ reason: "unexpected_status";
19
+ status: number;
20
+ } | {
21
+ ok: false;
22
+ reason: "unreachable";
23
+ message: string;
24
+ };
25
+ export declare function verifyReceiver(endpoint: string, token: string): Promise<ReceiverCheck>;
26
+ //# sourceMappingURL=agent-receiver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-receiver.d.ts","sourceRoot":"","sources":["../../../src/lib/agent-receiver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,MAAM,MAAM,aAAa,GACrB;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,GACtC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1D,wBAAsB,cAAc,CAClC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,CAAC,CA+BxB"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Talks to the OTLP receiver directly, which is the whole point.
3
+ *
4
+ * The receiver is a different host from the web app — its own Router, its
5
+ * own Lambda, its own domain — so a token that works against the dashboard
6
+ * proves nothing about whether telemetry can reach ingest. This check is
7
+ * the only local way to tell "nothing has happened yet" apart from "nothing
8
+ * can happen": a wrong endpoint, a revoked token and an idle afternoon all
9
+ * look identical otherwise.
10
+ */
11
+ const TIMEOUT_MS = 8000;
12
+ export async function verifyReceiver(endpoint, token) {
13
+ const controller = new AbortController();
14
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
15
+ try {
16
+ const response = await fetch(`${endpoint.replace(/\/$/, "")}/v1/verify`, {
17
+ method: "POST",
18
+ headers: {
19
+ "content-type": "application/json",
20
+ authorization: `Bearer ${token}`,
21
+ },
22
+ body: "{}",
23
+ signal: controller.signal,
24
+ });
25
+ if (response.ok)
26
+ return { ok: true };
27
+ if (response.status === 401)
28
+ return { ok: false, reason: "invalid_token" };
29
+ // A 404 here is worth distinguishing in the caller's advice: it means the
30
+ // URL resolved to something that is not this receiver, or to a receiver
31
+ // deployed before /v1/verify existed.
32
+ return { ok: false, reason: "unexpected_status", status: response.status };
33
+ }
34
+ catch (error) {
35
+ return {
36
+ ok: false,
37
+ reason: "unreachable",
38
+ message: error instanceof Error ? error.message : "request failed",
39
+ };
40
+ }
41
+ finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
@@ -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"}