@blastin-dev/clocktopus-cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +89 -34
  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 +146 -75
  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 +152 -15
  10. package/dist/src/commands/agent/setup.d.ts +17 -10
  11. package/dist/src/commands/agent/setup.d.ts.map +1 -1
  12. package/dist/src/commands/agent/setup.js +208 -69
  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 +18 -3
  18. package/dist/src/lib/agent-config.d.ts.map +1 -1
  19. package/dist/src/lib/agent-config.js +44 -19
  20. package/dist/src/lib/agent-hook-state.d.ts +9 -1
  21. package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
  22. package/dist/src/lib/agent-hook-state.js +21 -2
  23. package/dist/src/lib/agents.d.ts +115 -0
  24. package/dist/src/lib/agents.d.ts.map +1 -0
  25. package/dist/src/lib/agents.js +245 -0
  26. package/dist/src/lib/codex-config.d.ts +166 -0
  27. package/dist/src/lib/codex-config.d.ts.map +1 -0
  28. package/dist/src/lib/codex-config.js +441 -0
  29. package/dist/src/lib/codex-config.test.d.ts +2 -0
  30. package/dist/src/lib/codex-config.test.d.ts.map +1 -0
  31. package/dist/src/lib/codex-config.test.js +359 -0
  32. package/dist/src/lib/opencode-config.d.ts +108 -0
  33. package/dist/src/lib/opencode-config.d.ts.map +1 -0
  34. package/dist/src/lib/opencode-config.js +330 -0
  35. package/dist/src/lib/opencode-config.test.d.ts +2 -0
  36. package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
  37. package/dist/src/lib/opencode-config.test.js +140 -0
  38. package/package.json +2 -1
@@ -1,31 +1,50 @@
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`.
31
+ // Credential resolution must not throw — the hook calls it on every
32
+ // session start.
19
33
  }
20
34
  return {
21
- token: envToken || settingsToken || null,
22
- endpoint: (envEndpoint || settingsEndpoint || null)?.replace(/\/$/, "") ?? null,
23
- tokenSource: envToken ? "environment" : settingsToken ? "settings" : "none",
35
+ token: envToken || fileToken || null,
36
+ endpoint: (envEndpoint || fileEndpoint || null)?.replace(/\/$/, "") ?? null,
37
+ tokenSource: envToken ? "environment" : fileToken ? "settings" : "none",
24
38
  endpointSource: envEndpoint
25
39
  ? "environment"
26
- : settingsEndpoint
40
+ : fileEndpoint
27
41
  ? "settings"
28
42
  : "none",
43
+ sourcePath: agent === "codex"
44
+ ? codexConfigPath()
45
+ : agent === "opencode"
46
+ ? opencodePluginPath()
47
+ : settingsPath(),
29
48
  };
30
49
  }
31
50
  /** Masks a token for display: never print more than the stored prefix. */
@@ -77,7 +96,7 @@ export function findShadowedExports(cwd = process.cwd()) {
77
96
  return found;
78
97
  }
79
98
  /**
80
- * The command to write into settings.json for the hook.
99
+ * The command to write into an agent's hook configuration.
81
100
  *
82
101
  * Prefers the bare name, but only after confirming that `clocktopus` on
83
102
  * PATH resolves to *this* executable. Hooks run through a shell whose PATH
@@ -85,13 +104,19 @@ export function findShadowedExports(cwd = process.cwd()) {
85
104
  * resolve there fails silently — the session runs fine and every bit of
86
105
  * repository context is lost. An absolute path is uglier and survives that;
87
106
  * `doctor` checks it still exists.
107
+ *
108
+ * The provider is baked into the command because the two agents send
109
+ * *identical* hook payloads — same field names, same event spellings, no
110
+ * marker of any kind. Which agent is calling can only be known from how the
111
+ * hook was installed.
88
112
  */
89
- export function resolveHookCommand() {
113
+ export function resolveHookCommand(provider) {
90
114
  const script = process.argv[1] ? resolve(process.argv[1]) : null;
91
115
  const quote = (value) => /[\s"']/.test(value) ? `"${value}"` : value;
116
+ const args = `agent hook --provider ${provider}`;
92
117
  if (script && resolvesOnPath("clocktopus", script)) {
93
118
  return {
94
- command: "clocktopus agent hook",
119
+ command: `clocktopus ${args}`,
95
120
  usesAbsolutePath: false,
96
121
  binaryPath: script,
97
122
  };
@@ -101,13 +126,13 @@ export function resolveHookCommand() {
101
126
  // a published package keeps its shebang, but a checkout may not have the
102
127
  // executable bit, and the hook must not depend on that.
103
128
  return {
104
- command: `${quote(process.execPath)} ${quote(script)} agent hook`,
129
+ command: `${quote(process.execPath)} ${quote(script)} ${args}`,
105
130
  usesAbsolutePath: true,
106
131
  binaryPath: script,
107
132
  };
108
133
  }
109
134
  return {
110
- command: "clocktopus agent hook",
135
+ command: `clocktopus ${args}`,
111
136
  usesAbsolutePath: false,
112
137
  binaryPath: null,
113
138
  };
@@ -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,9 +14,16 @@ 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>;
16
19
  }, z.core.$strip>;
17
20
  export type HookStartState = z.infer<typeof StartStateSchema>;
18
- export declare function writeStartState(sessionId: string, sha: string, cwd: string): void;
21
+ export declare function writeStartState(sessionId: string, state: {
22
+ sha: string;
23
+ cwd: string;
24
+ provider: string;
25
+ closed?: boolean;
26
+ }): void;
19
27
  export declare function readStartState(sessionId: string): HookStartState | undefined;
20
28
  export declare function clearStartState(sessionId: string): void;
21
29
  export declare function listStartStates(): Array<{
@@ -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;AA6BxB,QAAA,MAAM,aAAa;;;;;;;;;;iBAYjB,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAExD,QAAA,MAAM,gBAAgB;;;;;iBAoBpB,CAAC;AAEH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAK9D,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GACtE,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"}
@@ -29,6 +29,8 @@ const LAST_RUN_FILE = join(CACHE_DIR, "agent-hook-last.json");
29
29
  const LastRunSchema = z.object({
30
30
  at: z.string(),
31
31
  event: z.string().optional(),
32
+ /** Which agent's hook ran — one receipt file is shared by all of them. */
33
+ provider: z.string().optional(),
32
34
  sessionId: z.string().optional(),
33
35
  endpoint: z.string().optional(),
34
36
  tokenPrefix: z.string().optional(),
@@ -40,16 +42,33 @@ const LastRunSchema = z.object({
40
42
  const StartStateSchema = z.object({
41
43
  sha: z.string(),
42
44
  cwd: z.string().optional(),
45
+ /**
46
+ * The agent that opened this session.
47
+ *
48
+ * Recorded because the sweep runs from whichever agent starts *next*, and
49
+ * closing a Codex session as `claude_code` would not close it at all — it
50
+ * would open a second, empty session row under the wrong provider and
51
+ * leave the real one hanging forever.
52
+ */
53
+ provider: z.string().optional(),
54
+ /**
55
+ * Set once the session has had a real SessionEnd, and kept only because
56
+ * its host can send another one — see `hostRepeatsSessionEnd`. The file
57
+ * still holds the starting SHA the next SessionEnd needs to diff against,
58
+ * but the session is already closed in the database, so the sweep must
59
+ * delete it rather than close it a second time with a stale HEAD.
60
+ */
61
+ closed: z.boolean().optional(),
43
62
  });
44
63
  const stateFile = (sessionId) => join(STATE_DIR, `${sessionId.replace(/[^\w-]/g, "")}.sha`);
45
- export function writeStartState(sessionId, sha, cwd) {
64
+ export function writeStartState(sessionId, state) {
46
65
  try {
47
66
  mkdirSync(STATE_DIR, { recursive: true });
48
67
  // `cwd` is stored alongside the SHA because the sweep runs from
49
68
  // whatever repository the *next* session happens to start in.
50
69
  // Resolving an abandoned session's SHA against the wrong checkout would
51
70
  // either fail or, worse, succeed against an unrelated history.
52
- writeFileSync(stateFile(sessionId), JSON.stringify({ sha, cwd }), "utf8");
71
+ writeFileSync(stateFile(sessionId), JSON.stringify(state), "utf8");
53
72
  }
54
73
  catch {
55
74
  // Losing the start SHA costs the exact commit list, not the session.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The agents `clocktopus agent` can wire up, behind one interface.
3
+ *
4
+ * Everything agent-specific lives here: where the config is, how to detect
5
+ * an install, what "configured" means, and how to add or remove our half of
6
+ * it. `setup`, `status`, `doctor` and `disable` iterate this list, so
7
+ * adding a third agent is a new entry rather than a new branch in five
8
+ * commands.
9
+ *
10
+ * The two supported agents look similar and are not:
11
+ *
12
+ * | | Claude Code | Codex CLI |
13
+ * | ------------- | -------------------------- | -------------------------------- |
14
+ * | telemetry | OTLP metrics, real dollars | OTLP **logs**, tokens only |
15
+ * | config | `settings.json` (one file) | `config.toml` + `hooks.json` |
16
+ * | credentials | env vars it injects | read back out of `config.toml` |
17
+ * | takes effect | restart | restart **and** a trust prompt |
18
+ *
19
+ * The credentials row is the subtle one. Claude Code applies `settings.json`
20
+ * `env` to the session, so our hook inherits the token from the process
21
+ * environment. Codex injects nothing, so the Codex hook has to read the
22
+ * token back out of `config.toml` — which is why `resolveAgentCredentials`
23
+ * takes a provider.
24
+ */
25
+ export type AgentId = "claude" | "codex" | "opencode";
26
+ /** How the ingested session is labelled — must match `AgentProvider` in core. */
27
+ export type AgentProviderId = "claude_code" | "codex_cli" | "opencode";
28
+ export type AgentTelemetryState = {
29
+ token: string | null;
30
+ endpoint: string | null;
31
+ /** Per-event, because a half-installed hook pair degrades silently. */
32
+ hookCommands: Partial<Record<"SessionStart" | "SessionEnd", string>>;
33
+ /** Newest mtime across the agent's config files, for the restart check. */
34
+ modifiedAt: Date | null;
35
+ /** Files we would write, whether or not they exist yet. */
36
+ paths: string[];
37
+ };
38
+ export type AgentAdapter = {
39
+ id: AgentId;
40
+ label: string;
41
+ provider: AgentProviderId;
42
+ /** Executable name, used both to detect an install and to name it in help. */
43
+ binary: string;
44
+ /**
45
+ * Whether the agent runs hooks off the session's critical path itself.
46
+ *
47
+ * Claude Code does, given `"async": true`. Codex does not — 0.147 skips
48
+ * an async SessionStart outright and 0.148 still forces SessionEnd
49
+ * synchronous — so its hooks omit the key and `agent hook` backgrounds
50
+ * itself instead. See the header of `commands/agent/hook.ts`.
51
+ */
52
+ hostRunsHooksAsync: boolean;
53
+ /**
54
+ * Whether the host can fire SessionEnd more than once for one session.
55
+ *
56
+ * Claude Code and Codex each end a session exactly once. OpenCode has no
57
+ * session-ended event at all — the plugin maps `session.idle` to
58
+ * SessionEnd, and that fires every time the agent stops and waits for a
59
+ * human, so a five-turn conversation sends five of them.
60
+ *
61
+ * The hook keeps its starting SHA in a state file keyed by session id and
62
+ * deletes it on SessionEnd. Deleting it after the first idle would leave
63
+ * every later turn with no range to diff, so nothing committed after the
64
+ * agent's first pause could ever be declared — see `commands/agent/hook.ts`.
65
+ */
66
+ hostRepeatsSessionEnd: boolean;
67
+ /** Files this agent's telemetry configuration lives in. */
68
+ paths(): string[];
69
+ read(): AgentTelemetryState;
70
+ apply(input: {
71
+ token: string;
72
+ endpoint: string;
73
+ hookCommand: string;
74
+ }): {
75
+ backupPaths: string[];
76
+ };
77
+ remove(): {
78
+ removed: string[];
79
+ };
80
+ /**
81
+ * Anything the user must still do by hand before telemetry flows.
82
+ * Empty when the agent is ready apart from a restart.
83
+ */
84
+ pendingActions(): string[];
85
+ /**
86
+ * Why the installed configuration is behind this CLI, or null when it is
87
+ * current. Only defined for agents whose integration is *generated
88
+ * source*, which is OpenCode alone: the others store a command string
89
+ * that means whatever the installed CLI means, so upgrading the CLI
90
+ * upgrades them and there is nothing to be behind.
91
+ */
92
+ staleReason?(): string | null;
93
+ };
94
+ /** `true` when the parse error is one of ours, whichever agent raised it. */
95
+ export declare function isConfigParseError(error: unknown): error is Error;
96
+ export declare const AGENTS: readonly AgentAdapter[];
97
+ export type AgentSurvey = {
98
+ agent: AgentAdapter;
99
+ /** Version string when the binary is on PATH, `null` when it is not. */
100
+ version: string | null;
101
+ installed: boolean;
102
+ /** True once our token is written into this agent's config. */
103
+ configured: boolean;
104
+ /** Set when the agent's config exists but could not be parsed. */
105
+ unreadable: string | null;
106
+ };
107
+ /**
108
+ * What is on this machine, and what is already wired up.
109
+ *
110
+ * Detection is by `--version` rather than by config directory: `~/.claude`
111
+ * and `~/.codex` both outlive an uninstall, so a stale directory would
112
+ * offer to configure an agent that is no longer there.
113
+ */
114
+ export declare function surveyAgents(): AgentSurvey[];
115
+ //# sourceMappingURL=agents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../../src/lib/agents.ts"],"names":[],"mappings":"AAqCA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,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;IACf;;;;;;;OAOG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;;;OAYG;IACH,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;IAChC;;;OAGG;IACH,cAAc,IAAI,MAAM,EAAE,CAAC;IAC3B;;;;;;OAMG;IACH,WAAW,CAAC,IAAI,MAAM,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAKjE;AAuND,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;AAEF;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,WAAW,EAAE,CAqB5C"}
@@ -0,0 +1,245 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { applyTelemetrySettings, readInstalledTelemetry, readSettings, removeTelemetrySettings, SettingsParseError, settingsPath, writeSettings, } from "./claude-settings.js";
3
+ import { applyCodexHooks, applyCodexOtel, CodexConfigParseError, codexConfigPath, codexHooksPath, HOOK_EVENT_COUNT, readCodexConfig, readCodexHooks, readCodexHookTrust, readCodexTelemetry, removeCodexHooks, removeCodexOtel, writeCodexConfig, writeCodexHooks, } from "./codex-config.js";
4
+ import { buildOpencodePlugin, OPENCODE_PLUGIN_VERSION, opencodePluginPath, readOpencodePlugin, removeOpencodePlugin, splitCommandLine, writeOpencodePlugin, } from "./opencode-config.js";
5
+ /** `true` when the parse error is one of ours, whichever agent raised it. */
6
+ export function isConfigParseError(error) {
7
+ return (error instanceof SettingsParseError ||
8
+ error instanceof CodexConfigParseError);
9
+ }
10
+ const claudeAdapter = {
11
+ id: "claude",
12
+ label: "Claude Code",
13
+ provider: "claude_code",
14
+ binary: "claude",
15
+ hostRunsHooksAsync: true,
16
+ hostRepeatsSessionEnd: false,
17
+ paths: () => [settingsPath()],
18
+ read() {
19
+ const installed = readInstalledTelemetry();
20
+ return {
21
+ token: installed.env.CLOCKTOPUS_INGEST_TOKEN ?? null,
22
+ endpoint: installed.env.CLOCKTOPUS_OTEL_ENDPOINT ?? null,
23
+ hookCommands: installed.hookCommands,
24
+ modifiedAt: installed.modifiedAt,
25
+ paths: [installed.path],
26
+ };
27
+ },
28
+ apply(input) {
29
+ const current = readSettings();
30
+ const next = applyTelemetrySettings(current.settings, input);
31
+ const { backupPath } = writeSettings(next, current.path);
32
+ return { backupPaths: backupPath ? [backupPath] : [] };
33
+ },
34
+ remove() {
35
+ const current = readSettings();
36
+ if (!current.exists)
37
+ return { removed: [] };
38
+ const result = removeTelemetrySettings(current.settings);
39
+ if (result.removedEnvKeys.length === 0 && !result.removedHooks)
40
+ return { removed: [] };
41
+ writeSettings(result.settings, current.path);
42
+ return {
43
+ removed: [
44
+ ...result.removedEnvKeys,
45
+ ...(result.removedHooks ? ["SessionStart/SessionEnd hooks"] : []),
46
+ ],
47
+ };
48
+ },
49
+ pendingActions: () => [],
50
+ };
51
+ const codexAdapter = {
52
+ id: "codex",
53
+ label: "Codex CLI",
54
+ provider: "codex_cli",
55
+ binary: "codex",
56
+ hostRunsHooksAsync: false,
57
+ hostRepeatsSessionEnd: false,
58
+ paths: () => [codexConfigPath(), codexHooksPath()],
59
+ read() {
60
+ const telemetry = readCodexTelemetry();
61
+ const hooks = readCodexHooks();
62
+ return {
63
+ token: telemetry.token,
64
+ endpoint: telemetry.endpoint,
65
+ hookCommands: hooks.commands,
66
+ modifiedAt: newest([telemetry.modifiedAt, hooks.modifiedAt]),
67
+ paths: [telemetry.path, hooks.path],
68
+ };
69
+ },
70
+ apply(input) {
71
+ const backupPaths = [];
72
+ const config = readCodexConfig();
73
+ const nextText = applyCodexOtel(config, input);
74
+ const written = writeCodexConfig({
75
+ path: config.path,
76
+ previousText: config.text,
77
+ previousConfig: config.config,
78
+ nextText,
79
+ });
80
+ if (written.backupPath)
81
+ backupPaths.push(written.backupPath);
82
+ const hooks = readCodexHooks();
83
+ const nextHooks = applyCodexHooks(hooks.file, input);
84
+ const hooksWritten = writeCodexHooks(nextHooks, hooks.path);
85
+ if (hooksWritten.backupPath)
86
+ backupPaths.push(hooksWritten.backupPath);
87
+ return { backupPaths };
88
+ },
89
+ remove() {
90
+ const removed = [];
91
+ const config = readCodexConfig();
92
+ if (config.exists) {
93
+ const { nextText, removedKeys } = removeCodexOtel(config);
94
+ if (removedKeys.length > 0) {
95
+ writeCodexConfig({
96
+ path: config.path,
97
+ previousText: config.text,
98
+ previousConfig: config.config,
99
+ nextText,
100
+ });
101
+ removed.push(...removedKeys.map((key) => `otel.${key}`));
102
+ }
103
+ }
104
+ const hooks = readCodexHooks();
105
+ if (hooks.exists) {
106
+ const result = removeCodexHooks(hooks.file);
107
+ if (result.removed) {
108
+ writeCodexHooks(result.file, hooks.path);
109
+ removed.push("SessionStart/SessionEnd hooks");
110
+ }
111
+ }
112
+ return { removed };
113
+ },
114
+ pendingActions() {
115
+ // Codex refuses to run a hooks.json it has not been shown, and there is
116
+ // no way to approve it from here — the prompt is part of the TUI. Left
117
+ // unsaid, this looks exactly like a broken install: telemetry arrives,
118
+ // repository context never does.
119
+ if (!readCodexHooks().exists)
120
+ return [];
121
+ const { untrusted } = readCodexHookTrust();
122
+ if (untrusted.length === 0)
123
+ return [];
124
+ // Says what is missing, never what is already fine. Trust is keyed on a
125
+ // hash of the hook entry, so rewriting hooks.json invalidates every
126
+ // approval it already had — `trusted` can be stale the moment setup
127
+ // runs, and promising the user that half of it is done would be a lie
128
+ // as often as not.
129
+ const missing = untrusted.length === HOOK_EVENT_COUNT
130
+ ? "its hooks"
131
+ : `its ${untrusted.join(" and ")} hook`;
132
+ return [
133
+ "Start Codex once and answer 'Trust all and continue' when it asks\n" +
134
+ ` about ${missing}. Until then Codex records your spend but not the\n` +
135
+ " repository it was spent on — it will not run an unapproved hook.",
136
+ ];
137
+ },
138
+ };
139
+ const opencodeAdapter = {
140
+ id: "opencode",
141
+ label: "OpenCode",
142
+ provider: "opencode",
143
+ binary: "opencode",
144
+ // The plugin runs inside OpenCode's own event loop and does not await the
145
+ // work it starts, so the hook is already off the critical path by the
146
+ // time it reaches the CLI. Re-spawning would only add a process.
147
+ hostRunsHooksAsync: true,
148
+ // `session.idle` is the closest thing OpenCode has to a session end, and
149
+ // it fires after every turn rather than once.
150
+ hostRepeatsSessionEnd: true,
151
+ paths: () => [opencodePluginPath()],
152
+ read() {
153
+ const plugin = readOpencodePlugin();
154
+ return {
155
+ token: plugin.config?.token ?? null,
156
+ endpoint: plugin.config?.endpoint ?? null,
157
+ // Both events come from the one plugin file, so they are installed
158
+ // together or not at all — unlike the hook agents, where each event is
159
+ // a separate entry that can go missing on its own.
160
+ hookCommands: plugin.config
161
+ ? {
162
+ SessionStart: plugin.config.hookArgv.join(" "),
163
+ SessionEnd: plugin.config.hookArgv.join(" "),
164
+ }
165
+ : {},
166
+ modifiedAt: plugin.modifiedAt,
167
+ paths: [plugin.path],
168
+ };
169
+ },
170
+ apply(input) {
171
+ const { backupPath } = writeOpencodePlugin(buildOpencodePlugin({
172
+ endpoint: input.endpoint,
173
+ token: input.token,
174
+ hookArgv: splitCommandLine(input.hookCommand),
175
+ }));
176
+ return { backupPaths: backupPath ? [backupPath] : [] };
177
+ },
178
+ remove() {
179
+ return { removed: removeOpencodePlugin() ? ["the Clocktopus plugin"] : [] };
180
+ },
181
+ pendingActions: () => [],
182
+ staleReason() {
183
+ const plugin = readOpencodePlugin();
184
+ if (!plugin.exists || !plugin.config)
185
+ return null;
186
+ if (plugin.version === OPENCODE_PLUGIN_VERSION)
187
+ return null;
188
+ return (`the installed plugin was generated by an older CLI ` +
189
+ `(generation ${plugin.version ?? "unstamped"}, current is ` +
190
+ `${OPENCODE_PLUGIN_VERSION})`);
191
+ },
192
+ };
193
+ export const AGENTS = [
194
+ claudeAdapter,
195
+ codexAdapter,
196
+ opencodeAdapter,
197
+ ];
198
+ /**
199
+ * What is on this machine, and what is already wired up.
200
+ *
201
+ * Detection is by `--version` rather than by config directory: `~/.claude`
202
+ * and `~/.codex` both outlive an uninstall, so a stale directory would
203
+ * offer to configure an agent that is no longer there.
204
+ */
205
+ export function surveyAgents() {
206
+ return AGENTS.map((agent) => {
207
+ const version = detectVersion(agent.binary);
208
+ let configured = false;
209
+ let unreadable = null;
210
+ try {
211
+ configured = agent.read().token !== null;
212
+ }
213
+ catch (error) {
214
+ if (isConfigParseError(error))
215
+ unreadable = error.message;
216
+ else
217
+ throw error;
218
+ }
219
+ return {
220
+ agent,
221
+ version,
222
+ installed: version !== null,
223
+ configured,
224
+ unreadable,
225
+ };
226
+ });
227
+ }
228
+ function detectVersion(binary) {
229
+ try {
230
+ const output = execFileSync(binary, ["--version"], {
231
+ encoding: "utf8",
232
+ stdio: ["ignore", "pipe", "ignore"],
233
+ timeout: 5000,
234
+ });
235
+ // Both CLIs print a line with the version somewhere in it; the number is
236
+ // the only part worth showing, and neither format is worth parsing hard.
237
+ return (/\d+\.\d+\.\d+/.exec(output)?.[0] ?? output.trim().split("\n")[0] ?? null);
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ }
243
+ function newest(dates) {
244
+ return dates.reduce((latest, date) => (date && (!latest || date > latest) ? date : latest), null);
245
+ }