@blastin-dev/clocktopus-cli 0.2.0 → 0.3.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 (53) hide show
  1. package/README.md +91 -36
  2. package/dist/src/commands/agent/disable.d.ts +8 -1
  3. package/dist/src/commands/agent/disable.d.ts.map +1 -1
  4. package/dist/src/commands/agent/disable.js +79 -45
  5. package/dist/src/commands/agent/doctor.d.ts.map +1 -1
  6. package/dist/src/commands/agent/doctor.js +157 -95
  7. package/dist/src/commands/agent/hook.d.ts +4 -1
  8. package/dist/src/commands/agent/hook.d.ts.map +1 -1
  9. package/dist/src/commands/agent/hook.js +239 -98
  10. package/dist/src/commands/agent/setup.d.ts +1 -16
  11. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  12. package/dist/src/commands/agent/setup.js +198 -81
  13. package/dist/src/commands/agent/status.d.ts.map +1 -1
  14. package/dist/src/commands/agent/status.js +46 -24
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +18 -4
  17. package/dist/src/lib/agent-config.d.ts +5 -23
  18. package/dist/src/lib/agent-config.d.ts.map +1 -1
  19. package/dist/src/lib/agent-config.js +50 -36
  20. package/dist/src/lib/agent-hook-state.d.ts +11 -7
  21. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  22. package/dist/src/lib/agent-hook-state.js +34 -29
  23. package/dist/src/lib/agents.d.ts +52 -0
  24. package/dist/src/lib/agents.d.ts.map +1 -0
  25. package/dist/src/lib/agents.js +238 -0
  26. package/dist/src/lib/claude-settings.d.ts +0 -36
  27. package/dist/src/lib/claude-settings.d.ts.map +1 -1
  28. package/dist/src/lib/claude-settings.js +37 -62
  29. package/dist/src/lib/codex-config.d.ts +87 -0
  30. package/dist/src/lib/codex-config.d.ts.map +1 -0
  31. package/dist/src/lib/codex-config.js +399 -0
  32. package/dist/src/lib/codex-config.test.d.ts +2 -0
  33. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  34. package/dist/src/lib/codex-config.test.js +359 -0
  35. package/dist/src/lib/declared-commits.d.ts +43 -0
  36. package/dist/src/lib/declared-commits.d.ts.map +1 -0
  37. package/dist/src/lib/declared-commits.js +114 -0
  38. package/dist/src/lib/declared-commits.test.d.ts +2 -0
  39. package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
  40. package/dist/src/lib/declared-commits.test.js +129 -0
  41. package/dist/src/lib/git-remotes.d.ts +9 -0
  42. package/dist/src/lib/git-remotes.d.ts.map +1 -0
  43. package/dist/src/lib/git-remotes.js +50 -0
  44. package/dist/src/lib/git-remotes.test.d.ts +2 -0
  45. package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
  46. package/dist/src/lib/git-remotes.test.js +52 -0
  47. package/dist/src/lib/opencode-config.d.ts +23 -0
  48. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  49. package/dist/src/lib/opencode-config.js +290 -0
  50. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  51. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  52. package/dist/src/lib/opencode-config.test.js +140 -0
  53. package/package.json +2 -1
@@ -1,45 +1,58 @@
1
1
  import { accessSync, constants, existsSync, readFileSync, realpathSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { delimiter, join, resolve } from "node:path";
4
- import { readInstalledTelemetry } from "./claude-settings.js";
5
- export function resolveAgentCredentials() {
4
+ import { readInstalledTelemetry, settingsPath } from "./claude-settings.js";
5
+ import { codexConfigPath, readCodexTelemetry } from "./codex-config.js";
6
+ import { opencodePluginPath, readOpencodePlugin } from "./opencode-config.js";
7
+ export function resolveAgentCredentials(agent = "claude") {
6
8
  const envToken = process.env.CLOCKTOPUS_INGEST_TOKEN?.trim();
7
9
  const envEndpoint = process.env.CLOCKTOPUS_OTEL_ENDPOINT?.trim();
8
- let settingsToken;
9
- let settingsEndpoint;
10
+ let fileToken;
11
+ let fileEndpoint;
10
12
  try {
11
- const installed = readInstalledTelemetry();
12
- settingsToken = installed.env.CLOCKTOPUS_INGEST_TOKEN;
13
- settingsEndpoint = installed.env.CLOCKTOPUS_OTEL_ENDPOINT;
13
+ if (agent === "codex") {
14
+ const codex = readCodexTelemetry();
15
+ fileToken = codex.token ?? undefined;
16
+ fileEndpoint = codex.endpoint ?? undefined;
17
+ }
18
+ else if (agent === "opencode") {
19
+ const plugin = readOpencodePlugin();
20
+ fileToken = plugin.config?.token ?? undefined;
21
+ fileEndpoint = plugin.config?.endpoint ?? undefined;
22
+ }
23
+ else {
24
+ const installed = readInstalledTelemetry();
25
+ fileToken = installed.env.CLOCKTOPUS_INGEST_TOKEN;
26
+ fileEndpoint = installed.env.CLOCKTOPUS_OTEL_ENDPOINT;
27
+ }
14
28
  }
15
29
  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.
30
+ // A malformed config file is reported properly by `setup` and `doctor`. Credential
31
+ // resolution must not throw — the hook calls it on every session start.
19
32
  }
20
33
  return {
21
- token: envToken || settingsToken || null,
22
- endpoint: (envEndpoint || settingsEndpoint || null)?.replace(/\/$/, "") ?? null,
23
- tokenSource: envToken ? "environment" : settingsToken ? "settings" : "none",
34
+ token: envToken || fileToken || null,
35
+ endpoint: (envEndpoint || fileEndpoint || null)?.replace(/\/$/, "") ?? null,
36
+ tokenSource: envToken ? "environment" : fileToken ? "settings" : "none",
24
37
  endpointSource: envEndpoint
25
38
  ? "environment"
26
- : settingsEndpoint
39
+ : fileEndpoint
27
40
  ? "settings"
28
41
  : "none",
42
+ sourcePath: agent === "codex"
43
+ ? codexConfigPath()
44
+ : agent === "opencode"
45
+ ? opencodePluginPath()
46
+ : settingsPath(),
29
47
  };
30
48
  }
31
49
  /** Masks a token for display: never print more than the stored prefix. */
32
50
  export function maskToken(token) {
33
51
  return token.length <= 15 ? "…" : `${token.slice(0, 15)}…`;
34
52
  }
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
- */
53
+ // Files that commonly export the same variables, and would be shadowed. Scanned rather
54
+ // than guessed at, because "your `.envrc` is being ignored" is only useful advice when it
55
+ // names the file.
43
56
  const SHELL_FILES = [
44
57
  ".bashrc",
45
58
  ".bash_profile",
@@ -76,22 +89,23 @@ export function findShadowedExports(cwd = process.cwd()) {
76
89
  }
77
90
  return found;
78
91
  }
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() {
92
+ // The command to write into an agent's hook configuration.
93
+ //
94
+ // Prefers the bare name, but only after confirming `clocktopus` on PATH resolves to *this*
95
+ // executable. Hooks run through a shell whose PATH may differ from the interactive one, and
96
+ // a bare name that does not resolve there fails silently the session runs fine and every
97
+ // bit of repository context is lost. An absolute path is uglier and survives that.
98
+ //
99
+ // The provider is baked into the command because the two agents send identical hook
100
+ // payloads, with no marker of any kind: which agent is calling can only be known from how
101
+ // the hook was installed.
102
+ export function resolveHookCommand(provider) {
90
103
  const script = process.argv[1] ? resolve(process.argv[1]) : null;
91
104
  const quote = (value) => /[\s"']/.test(value) ? `"${value}"` : value;
105
+ const args = `agent hook --provider ${provider}`;
92
106
  if (script && resolvesOnPath("clocktopus", script)) {
93
107
  return {
94
- command: "clocktopus agent hook",
108
+ command: `clocktopus ${args}`,
95
109
  usesAbsolutePath: false,
96
110
  binaryPath: script,
97
111
  };
@@ -101,13 +115,13 @@ export function resolveHookCommand() {
101
115
  // a published package keeps its shebang, but a checkout may not have the
102
116
  // executable bit, and the hook must not depend on that.
103
117
  return {
104
- command: `${quote(process.execPath)} ${quote(script)} agent hook`,
118
+ command: `${quote(process.execPath)} ${quote(script)} ${args}`,
105
119
  usesAbsolutePath: true,
106
120
  binaryPath: script,
107
121
  };
108
122
  }
109
123
  return {
110
- command: "clocktopus agent hook",
124
+ command: `clocktopus ${args}`,
111
125
  usesAbsolutePath: false,
112
126
  binaryPath: null,
113
127
  };
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  declare const LastRunSchema: z.ZodObject<{
3
3
  at: z.ZodString;
4
4
  event: z.ZodOptional<z.ZodString>;
5
+ provider: z.ZodOptional<z.ZodString>;
5
6
  sessionId: z.ZodOptional<z.ZodString>;
6
7
  endpoint: z.ZodOptional<z.ZodString>;
7
8
  tokenPrefix: z.ZodOptional<z.ZodString>;
@@ -13,20 +14,23 @@ export type HookLastRun = z.infer<typeof LastRunSchema>;
13
14
  declare const StartStateSchema: z.ZodObject<{
14
15
  sha: z.ZodString;
15
16
  cwd: z.ZodOptional<z.ZodString>;
17
+ provider: z.ZodOptional<z.ZodString>;
18
+ closed: z.ZodOptional<z.ZodBoolean>;
19
+ startedAt: z.ZodOptional<z.ZodString>;
16
20
  }, z.core.$strip>;
17
21
  export type HookStartState = z.infer<typeof StartStateSchema>;
18
- export declare function writeStartState(sessionId: string, sha: string, cwd: string): void;
22
+ export declare function writeStartState(sessionId: string, state: {
23
+ sha: string;
24
+ cwd: string;
25
+ provider: string;
26
+ closed?: boolean;
27
+ startedAt?: string;
28
+ }): void;
19
29
  export declare function readStartState(sessionId: string): HookStartState | undefined;
20
30
  export declare function clearStartState(sessionId: string): void;
21
31
  export declare function listStartStates(): Array<{
22
32
  sessionId: string;
23
33
  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
34
  lastActivityAt: Date;
31
35
  }>;
32
36
  export declare function recordLastRun(run: HookLastRun): void;
@@ -1 +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"}
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;AAqBxB,QAAA,MAAM,aAAa;;;;;;;;;;iBAYjB,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAExD,QAAA,MAAM,gBAAgB;;;;;;iBAgBpB,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAK9D,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE;IACL,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,GACA,IAAI,CAUN;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAe5E;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;IAId,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"}
@@ -2,33 +2,27 @@ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync,
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
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
- */
5
+ // On-disk state the SessionStart/SessionEnd hook keeps between its two invocations, plus
6
+ // the record of what the last invocation achieved.
7
+ //
8
+ // Under the cache directory rather than the repository so it never shows up in
9
+ // `git status`. The path is unchanged from the standalone script this command replaced,
10
+ // on purpose: a session that started under the old script must still close under the new
11
+ // one, and a sweep must still find what the old one abandoned.
16
12
  const CACHE_DIR = join(homedir(), ".cache", "clocktopus");
17
13
  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
- */
14
+ // The hook's last outcome, written every run.
15
+ //
16
+ // The only place the pipeline reports on itself from the machine's own side. The hook
17
+ // must stay silent it cannot warn, prompt or print so it leaves a receipt, and
18
+ // `agent status` / `agent doctor` read it. Without this, a token that started returning
19
+ // 401 mid-week is invisible locally.
28
20
  const LAST_RUN_FILE = join(CACHE_DIR, "agent-hook-last.json");
29
21
  const LastRunSchema = z.object({
30
22
  at: z.string(),
31
23
  event: z.string().optional(),
24
+ // One receipt file is shared by all of them.
25
+ provider: z.string().optional(),
32
26
  sessionId: z.string().optional(),
33
27
  endpoint: z.string().optional(),
34
28
  tokenPrefix: z.string().optional(),
@@ -40,16 +34,28 @@ const LastRunSchema = z.object({
40
34
  const StartStateSchema = z.object({
41
35
  sha: z.string(),
42
36
  cwd: z.string().optional(),
37
+ // The agent that opened this session. Recorded because the sweep runs from whichever
38
+ // agent starts *next*, and closing a Codex session as `claude_code` would open a second,
39
+ // empty session row under the wrong provider and leave the real one hanging.
40
+ provider: z.string().optional(),
41
+ // Set once the session has had a real SessionEnd, and kept only because its host can send
42
+ // another one — see `hostRepeatsSessionEnd`. The file still holds the starting SHA the
43
+ // next SessionEnd needs, but the session is already closed, so the sweep must delete it
44
+ // rather than close it again with a stale HEAD.
45
+ closed: z.boolean().optional(),
46
+ // When SessionStart fired, ISO. Bounds the reflog read SessionEnd declares from —
47
+ // see `declared-commits.ts`. Optional because sessions opened by an older CLI have
48
+ // no such stamp, and those declare nothing rather than read an unbounded reflog.
49
+ startedAt: z.string().optional(),
43
50
  });
44
51
  const stateFile = (sessionId) => join(STATE_DIR, `${sessionId.replace(/[^\w-]/g, "")}.sha`);
45
- export function writeStartState(sessionId, sha, cwd) {
52
+ export function writeStartState(sessionId, state) {
46
53
  try {
47
54
  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");
55
+ // `cwd` is stored alongside the SHA because the sweep runs from whatever repository the
56
+ // *next* session happens to start in. Resolving an abandoned session's SHA against the
57
+ // wrong checkout would either fail or, worse, succeed against an unrelated history.
58
+ writeFileSync(stateFile(sessionId), JSON.stringify(state), "utf8");
53
59
  }
54
60
  catch {
55
61
  // Losing the start SHA costs the exact commit list, not the session.
@@ -65,8 +71,7 @@ export function readStartState(sessionId) {
65
71
  return parsed.success ? parsed.data : undefined;
66
72
  }
67
73
  catch {
68
- // Files written by earlier versions hold a bare SHA. A session that
69
- // started under the old format must still close correctly.
74
+ // Files written by earlier versions hold a bare SHA.
70
75
  return { sha: raw };
71
76
  }
72
77
  }
@@ -0,0 +1,52 @@
1
+ export type AgentId = "claude" | "codex" | "opencode";
2
+ /** How the ingested session is labelled — must match `AgentProvider` in core. */
3
+ export type AgentProviderId = "claude_code" | "codex_cli" | "opencode";
4
+ export type AgentTelemetryState = {
5
+ token: string | null;
6
+ endpoint: string | null;
7
+ /** Per-event, because a half-installed hook pair degrades silently. */
8
+ hookCommands: Partial<Record<"SessionStart" | "SessionEnd", string>>;
9
+ /** Newest mtime across the agent's config files, for the restart check. */
10
+ modifiedAt: Date | null;
11
+ /** Files we would write, whether or not they exist yet. */
12
+ paths: string[];
13
+ };
14
+ export type AgentAdapter = {
15
+ id: AgentId;
16
+ label: string;
17
+ provider: AgentProviderId;
18
+ /** Executable name, used both to detect an install and to name it in help. */
19
+ binary: string;
20
+ hostRunsHooksAsync: boolean;
21
+ hostRepeatsSessionEnd: boolean;
22
+ /** Files this agent's telemetry configuration lives in. */
23
+ paths(): string[];
24
+ read(): AgentTelemetryState;
25
+ apply(input: {
26
+ token: string;
27
+ endpoint: string;
28
+ hookCommand: string;
29
+ }): {
30
+ backupPaths: string[];
31
+ };
32
+ remove(): {
33
+ removed: string[];
34
+ };
35
+ pendingActions(): string[];
36
+ staleReason?(): string | null;
37
+ };
38
+ /** `true` when the parse error is one of ours, whichever agent raised it. */
39
+ export declare function isConfigParseError(error: unknown): error is Error;
40
+ export declare const AGENTS: readonly AgentAdapter[];
41
+ export type AgentSurvey = {
42
+ agent: AgentAdapter;
43
+ /** Version string when the binary is on PATH, `null` when it is not. */
44
+ version: string | null;
45
+ installed: boolean;
46
+ /** True once our token is written into this agent's config. */
47
+ configured: boolean;
48
+ /** Set when the agent's config exists but could not be parsed. */
49
+ unreadable: string | null;
50
+ };
51
+ export declare function surveyAgents(): AgentSurvey[];
52
+ //# sourceMappingURL=agents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../../src/lib/agents.ts"],"names":[],"mappings":"AAyDA,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,CAAC;AAEtD,iFAAiF;AACjF,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,WAAW,GAAG,UAAU,CAAC;AAEvE,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,uEAAuE;IACvE,YAAY,EAAE,OAAO,CAAC,MAAM,CAAC,cAAc,GAAG,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC;IACrE,2EAA2E;IAC3E,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,eAAe,CAAC;IAC1B,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC;IAKf,kBAAkB,EAAE,OAAO,CAAC;IAU5B,qBAAqB,EAAE,OAAO,CAAC;IAC/B,2DAA2D;IAC3D,KAAK,IAAI,MAAM,EAAE,CAAC;IAClB,IAAI,IAAI,mBAAmB,CAAC;IAC5B,KAAK,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG;QACtE,WAAW,EAAE,MAAM,EAAE,CAAC;KACvB,CAAC;IACF,MAAM,IAAI;QAAE,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAGhC,cAAc,IAAI,MAAM,EAAE,CAAC;IAI3B,WAAW,CAAC,IAAI,MAAM,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAKjE;AAkND,eAAO,MAAM,MAAM,EAAE,SAAS,YAAY,EAIzC,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,wEAAwE;IACxE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,+DAA+D;IAC/D,UAAU,EAAE,OAAO,CAAC;IACpB,kEAAkE;IAClE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CAAC;AAOF,wBAAgB,YAAY,IAAI,WAAW,EAAE,CAqB5C"}
@@ -0,0 +1,238 @@
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 no way to
116
+ // approve it from here — the prompt is part of the TUI. Left unsaid, this looks
117
+ // exactly like a broken install: telemetry arrives, repository context never does.
118
+ if (!readCodexHooks().exists)
119
+ return [];
120
+ const { untrusted } = readCodexHookTrust();
121
+ if (untrusted.length === 0)
122
+ return [];
123
+ // Says what is missing, never what is already fine. Trust is keyed on a hash of the
124
+ // hook entry, so rewriting hooks.json invalidates every approval it had — `trusted`
125
+ // can be stale the moment setup runs.
126
+ const missing = untrusted.length === HOOK_EVENT_COUNT
127
+ ? "its hooks"
128
+ : `its ${untrusted.join(" and ")} hook`;
129
+ return [
130
+ "Start Codex once and answer 'Trust all and continue' when it asks\n" +
131
+ ` about ${missing}. Until then Codex records your spend but not the\n` +
132
+ " repository it was spent on — it will not run an unapproved hook.",
133
+ ];
134
+ },
135
+ };
136
+ const opencodeAdapter = {
137
+ id: "opencode",
138
+ label: "OpenCode",
139
+ provider: "opencode",
140
+ binary: "opencode",
141
+ // The plugin runs inside OpenCode's own event loop and does not await the work it
142
+ // starts, so the hook is already off the critical path by the time it reaches the CLI.
143
+ hostRunsHooksAsync: true,
144
+ // `session.idle` is the closest thing OpenCode has to a session end, and it fires
145
+ // after every turn rather than once.
146
+ hostRepeatsSessionEnd: true,
147
+ paths: () => [opencodePluginPath()],
148
+ read() {
149
+ const plugin = readOpencodePlugin();
150
+ return {
151
+ token: plugin.config?.token ?? null,
152
+ endpoint: plugin.config?.endpoint ?? null,
153
+ // Both events come from the one plugin file, so they are installed together or not at
154
+ // all — unlike the hook agents, where each event can go missing on its own.
155
+ hookCommands: plugin.config
156
+ ? {
157
+ SessionStart: plugin.config.hookArgv.join(" "),
158
+ SessionEnd: plugin.config.hookArgv.join(" "),
159
+ }
160
+ : {},
161
+ modifiedAt: plugin.modifiedAt,
162
+ paths: [plugin.path],
163
+ };
164
+ },
165
+ apply(input) {
166
+ const { backupPath } = writeOpencodePlugin(buildOpencodePlugin({
167
+ endpoint: input.endpoint,
168
+ token: input.token,
169
+ hookArgv: splitCommandLine(input.hookCommand),
170
+ }));
171
+ return { backupPaths: backupPath ? [backupPath] : [] };
172
+ },
173
+ remove() {
174
+ return { removed: removeOpencodePlugin() ? ["the Clocktopus plugin"] : [] };
175
+ },
176
+ pendingActions: () => [],
177
+ staleReason() {
178
+ const plugin = readOpencodePlugin();
179
+ if (!plugin.exists || !plugin.config)
180
+ return null;
181
+ if (plugin.version === OPENCODE_PLUGIN_VERSION)
182
+ return null;
183
+ return (`the installed plugin was generated by an older CLI ` +
184
+ `(generation ${plugin.version ?? "unstamped"}, current is ` +
185
+ `${OPENCODE_PLUGIN_VERSION})`);
186
+ },
187
+ };
188
+ export const AGENTS = [
189
+ claudeAdapter,
190
+ codexAdapter,
191
+ opencodeAdapter,
192
+ ];
193
+ // What is on this machine, and what is already wired up.
194
+ //
195
+ // Detection is by `--version` rather than by config directory: `~/.claude` and
196
+ // `~/.codex` both outlive an uninstall, so a stale directory would offer to configure
197
+ // an agent that is no longer there.
198
+ export function surveyAgents() {
199
+ return AGENTS.map((agent) => {
200
+ const version = detectVersion(agent.binary);
201
+ let configured = false;
202
+ let unreadable = null;
203
+ try {
204
+ configured = agent.read().token !== null;
205
+ }
206
+ catch (error) {
207
+ if (isConfigParseError(error))
208
+ unreadable = error.message;
209
+ else
210
+ throw error;
211
+ }
212
+ return {
213
+ agent,
214
+ version,
215
+ installed: version !== null,
216
+ configured,
217
+ unreadable,
218
+ };
219
+ });
220
+ }
221
+ function detectVersion(binary) {
222
+ try {
223
+ const output = execFileSync(binary, ["--version"], {
224
+ encoding: "utf8",
225
+ stdio: ["ignore", "pipe", "ignore"],
226
+ timeout: 5000,
227
+ });
228
+ // Both CLIs print a line with the version somewhere in it; the number is the only
229
+ // part worth showing.
230
+ return (/\d+\.\d+\.\d+/.exec(output)?.[0] ?? output.trim().split("\n")[0] ?? null);
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ }
236
+ function newest(dates) {
237
+ return dates.reduce((latest, date) => (date && (!latest || date > latest) ? date : latest), null);
238
+ }
@@ -1,36 +1,7 @@
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
1
  export declare const SETTINGS_FILENAME = "settings.json";
16
2
  /** Env keys `clocktopus agent setup` owns — and the only ones it removes. */
17
3
  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
4
  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
5
  export declare const HOOK_TIMEOUT_SECONDS = 10;
35
6
  export type ClaudeSettings = Record<string, unknown>;
36
7
  export declare class SettingsParseError extends Error {
@@ -45,13 +16,6 @@ export declare function readSettings(path?: string): {
45
16
  modifiedAt: Date | null;
46
17
  settings: ClaudeSettings;
47
18
  };
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
19
  export declare function writeSettings(settings: ClaudeSettings, path?: string): {
56
20
  backupPath: string | null;
57
21
  };
@@ -1 +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"}
1
+ {"version":3,"file":"claude-settings.d.ts","sourceRoot":"","sources":["../../../src/lib/claude-settings.ts"],"names":[],"mappings":"AAuBA,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAEjD,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,yRAUrB,CAAC;AAKX,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAMhD,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;AAKD,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,CAkBzB;AAED,QAAA,MAAM,WAAW,yCAA0C,CAAC;AAgC5D,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,CA0ChB;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"}