@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,20 +1,16 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- /**
5
- * Reads and edits `~/.claude/settings.json` on the user's behalf.
6
- *
7
- * Two rules govern everything here, both of them about not destroying a file
8
- * we do not own:
9
- *
10
- * 1. **Never write over JSON we could not parse.** A malformed settings file
11
- * is far more likely to be a half-finished edit than something to
12
- * overwrite, and overwriting it would lose the user's own hooks,
13
- * permissions and MCP servers. Parse failures raise instead.
14
- * 2. **Only ever touch keys we put there.** Merging into `env` and appending
15
- * to `hooks` leaves everything else untouched, and removal matches our
16
- * own hook command rather than clearing the arrays.
17
- */
4
+ // Reads and edits `~/.claude/settings.json` on the user's behalf.
5
+ //
6
+ // Two rules govern everything here, both about not destroying a file we do not own:
7
+ //
8
+ // 1. Never write over JSON we could not parse. A malformed settings file is far more
9
+ // likely to be a half-finished edit than something to overwrite, and overwriting
10
+ // would lose the user's own hooks, permissions and MCP servers.
11
+ // 2. Only ever touch keys we put there. Merging into `env` and appending to `hooks`
12
+ // leaves everything else alone, and removal matches our own hook command rather
13
+ // than clearing the arrays.
18
14
  export const SETTINGS_FILENAME = "settings.json";
19
15
  /** Env keys `clocktopus agent setup` owns — and the only ones it removes. */
20
16
  export const TELEMETRY_ENV_KEYS = [
@@ -28,22 +24,14 @@ export const TELEMETRY_ENV_KEYS = [
28
24
  "CLOCKTOPUS_INGEST_TOKEN",
29
25
  "CLOCKTOPUS_OTEL_ENDPOINT",
30
26
  ];
31
- /**
32
- * How often Claude Code's exporter ships metrics.
33
- *
34
- * Also the resolution of every "last export received" answer the status
35
- * command can give — a session that started 30s ago has genuinely not
36
- * exported yet, which is why status treats silence under one interval as
37
- * "waiting" rather than "broken".
38
- */
27
+ // How often Claude Code's exporter ships metrics — and the resolution of every "last
28
+ // export received" answer status can give, which is why silence under one interval
29
+ // reads as "waiting" rather than "broken".
39
30
  export const METRIC_EXPORT_INTERVAL_MS = 60_000;
40
- /**
41
- * Seconds Claude Code will wait for the hook before giving up on it.
42
- *
43
- * Comfortably above the hook's own 4s request timeout, so a slow network
44
- * produces the hook's own recorded failure — which `agent doctor` can read
45
- * back — rather than a kill from the host, which leaves no trace anywhere.
46
- */
31
+ // Seconds Claude Code will wait for the hook. Comfortably above the hook's own 4s
32
+ // request timeout, so a slow network produces the hook's own recorded failure which
33
+ // `agent doctor` can read back — rather than a kill from the host, which leaves no
34
+ // trace.
47
35
  export const HOOK_TIMEOUT_SECONDS = 10;
48
36
  export class SettingsParseError extends Error {
49
37
  path;
@@ -54,8 +42,8 @@ export class SettingsParseError extends Error {
54
42
  }
55
43
  }
56
44
  export function claudeConfigDir() {
57
- // Claude Code honours CLAUDE_CONFIG_DIR; following it means setup writes
58
- // where that installation actually reads.
45
+ // Claude Code honours CLAUDE_CONFIG_DIR; following it means setup writes where that
46
+ // installation actually reads.
59
47
  return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
60
48
  }
61
49
  export function settingsPath() {
@@ -67,8 +55,8 @@ export function readSettings(path = settingsPath()) {
67
55
  }
68
56
  const raw = readFileSync(path, "utf8");
69
57
  const modifiedAt = statSync(path).mtime;
70
- // An empty file is a normal state (some installers touch it) and is safe
71
- // to treat as an empty object; anything else that fails to parse is not.
58
+ // An empty file is a normal state (some installers touch it) and is safe to treat as
59
+ // an empty object; anything else that fails to parse is not.
72
60
  if (raw.trim() === "") {
73
61
  return { path, exists: true, modifiedAt, settings: {} };
74
62
  }
@@ -90,13 +78,9 @@ export function readSettings(path = settingsPath()) {
90
78
  throw new SettingsParseError(path);
91
79
  }
92
80
  }
93
- /**
94
- * Writes settings, keeping a one-deep backup of what was there before.
95
- *
96
- * The rename is what makes it atomic: a crash midway leaves either the old
97
- * file or the new one, never a truncated file that Claude Code would refuse
98
- * to start with.
99
- */
81
+ // Writes settings, keeping a one-deep backup. The rename is what makes it atomic: a
82
+ // crash midway leaves either the old file or the new one, never a truncated file
83
+ // Claude Code would refuse to start with.
100
84
  export function writeSettings(settings, path = settingsPath()) {
101
85
  mkdirSync(dirname(path), { recursive: true });
102
86
  let backupPath = null;
@@ -117,13 +101,12 @@ export function buildTelemetryEnv(input) {
117
101
  return {
118
102
  CLAUDE_CODE_ENABLE_TELEMETRY: "1",
119
103
  OTEL_METRICS_EXPORTER: "otlp",
120
- // The receiver answers protobuf with an explicit 415 rather than a
121
- // parse error, but only JSON actually works.
104
+ // The receiver answers protobuf with an explicit 415, but only JSON actually works.
122
105
  OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
123
106
  OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
124
107
  OTEL_EXPORTER_OTLP_HEADERS: `Authorization=Bearer ${input.token}`,
125
- // Without the session id the metric stream cannot be joined to the hook
126
- // stream, and every session loses its repository and branch.
108
+ // Without the session id the metric stream cannot be joined to the hook stream, and
109
+ // every session loses its repository and branch.
127
110
  OTEL_METRICS_INCLUDE_SESSION_ID: "true",
128
111
  OTEL_METRIC_EXPORT_INTERVAL: String(METRIC_EXPORT_INTERVAL_MS),
129
112
  // The hook's own channel. Same token, different transport.
@@ -132,15 +115,10 @@ export function buildTelemetryEnv(input) {
132
115
  };
133
116
  }
134
117
  const HOOK_EVENTS = ["SessionStart", "SessionEnd"];
135
- /**
136
- * Recognises a hook entry as ours, across every spelling we have shipped.
137
- *
138
- * The `claude-hook.mjs` clause matters for upgrades: before this command
139
- * existed the hook was a script inside a checkout of the Clocktopus repo,
140
- * and anyone who set that up by hand still has it. Failing to recognise it
141
- * would leave it installed next to the new one and POST every SessionStart
142
- * twice.
143
- */
118
+ // Recognises a hook entry as ours, across every spelling we have shipped. The
119
+ // `claude-hook.mjs` clause matters for upgrades: before this command existed the hook
120
+ // was a script inside a checkout of this repo, and anyone who set that up by hand
121
+ // still has it. Failing to recognise it would POST every SessionStart twice.
144
122
  function isClocktopusHook(entry) {
145
123
  const command = typeof entry.command === "string" ? entry.command : "";
146
124
  if (/claude-hook\.mjs/.test(command))
@@ -157,8 +135,8 @@ function withoutOurHooks(matchers) {
157
135
  ...matcher,
158
136
  hooks: (matcher.hooks ?? []).filter((entry) => !isClocktopusHook(entry)),
159
137
  }))
160
- // A matcher group that only ever held our hook is ours to remove; one
161
- // that still has entries belongs to the user and stays.
138
+ // A matcher group that only ever held our hook is ours to remove; one that still has
139
+ // entries belongs to the user and stays.
162
140
  .filter((matcher) => (matcher.hooks ?? []).length > 0));
163
141
  }
164
142
  export function applyTelemetrySettings(settings, input) {
@@ -174,9 +152,8 @@ export function applyTelemetrySettings(settings, input) {
174
152
  : {};
175
153
  const hooks = { ...existingHooks };
176
154
  for (const event of HOOK_EVENTS) {
177
- // Remove-then-append rather than append: re-running setup after a
178
- // reinstall must not leave two hooks firing per session, which would
179
- // double every SessionStart POST.
155
+ // Remove-then-append rather than append: re-running setup after a reinstall must not
156
+ // leave two hooks firing per session, which would double every SessionStart POST.
180
157
  hooks[event] = [
181
158
  ...withoutOurHooks(asMatchers(existingHooks[event])),
182
159
  {
@@ -184,10 +161,8 @@ export function applyTelemetrySettings(settings, input) {
184
161
  {
185
162
  type: "command",
186
163
  command: input.hookCommand,
187
- // Both fields are about not making the user wait on telemetry.
188
- // The hook does network I/O its own POST, plus a sweep of
189
- // abandoned sessions at SessionStart — and running it inline
190
- // would add that latency to the start of every session.
164
+ // Both fields are about not making the user wait on telemetry: the hook does network
165
+ // I/O, and running it inline would add that latency to the start of every session.
191
166
  timeout: HOOK_TIMEOUT_SECONDS,
192
167
  async: true,
193
168
  },
@@ -0,0 +1,87 @@
1
+ export declare const CODEX_CONFIG_FILENAME = "config.toml";
2
+ export declare const CODEX_HOOKS_FILENAME = "hooks.json";
3
+ export declare const CODEX_LOGS_PATH = "/v1/logs";
4
+ export declare const CODEX_HOOK_TIMEOUT_SECONDS: {
5
+ SessionStart: number;
6
+ SessionEnd: number;
7
+ };
8
+ declare const HOOK_EVENTS: readonly ["SessionStart", "SessionEnd"];
9
+ /** Exported so callers can tell "none approved" from "some approved". */
10
+ export declare const HOOK_EVENT_COUNT: 2;
11
+ type HookEvent = (typeof HOOK_EVENTS)[number];
12
+ type TomlTable = Record<string, unknown>;
13
+ export declare class CodexConfigParseError extends Error {
14
+ readonly path: string;
15
+ constructor(path: string, detail: string);
16
+ }
17
+ export declare function codexConfigDir(): string;
18
+ export declare function codexConfigPath(): string;
19
+ export declare function codexHooksPath(): string;
20
+ export declare function readCodexConfig(path?: string): {
21
+ path: string;
22
+ exists: boolean;
23
+ modifiedAt: Date | null;
24
+ text: string;
25
+ config: TomlTable;
26
+ };
27
+ export declare function buildCodexOtel(input: {
28
+ token: string;
29
+ endpoint: string;
30
+ }): TomlTable;
31
+ export declare function spliceOtelSection(text: string, otel: TomlTable | null): string;
32
+ export declare function writeCodexConfig(input: {
33
+ path?: string;
34
+ previousText: string;
35
+ previousConfig: TomlTable;
36
+ nextText: string;
37
+ }): {
38
+ backupPath: string | null;
39
+ };
40
+ /** What config.toml currently declares, for `status` and `doctor`. */
41
+ export declare function readCodexTelemetry(path?: string): {
42
+ path: string;
43
+ exists: boolean;
44
+ modifiedAt: Date | null;
45
+ token: string | null;
46
+ /** The receiver base, with `/v1/logs` stripped back off. */
47
+ endpoint: string | null;
48
+ protocol: string | null;
49
+ logUserPrompt: boolean | null;
50
+ };
51
+ export declare function applyCodexOtel(current: {
52
+ text: string;
53
+ config: TomlTable;
54
+ }, input: {
55
+ token: string;
56
+ endpoint: string;
57
+ }): string;
58
+ export declare function removeCodexOtel(current: {
59
+ text: string;
60
+ config: TomlTable;
61
+ }): {
62
+ nextText: string;
63
+ removedKeys: string[];
64
+ };
65
+ export declare function readCodexHooks(path?: string): {
66
+ path: string;
67
+ exists: boolean;
68
+ modifiedAt: Date | null;
69
+ file: Record<string, unknown>;
70
+ commands: Partial<Record<HookEvent, string>>;
71
+ };
72
+ export declare function applyCodexHooks(file: Record<string, unknown>, input: {
73
+ hookCommand: string;
74
+ }): Record<string, unknown>;
75
+ export declare function removeCodexHooks(file: Record<string, unknown>): {
76
+ file: Record<string, unknown>;
77
+ removed: boolean;
78
+ };
79
+ export declare function writeCodexHooks(file: Record<string, unknown>, path?: string): {
80
+ backupPath: string | null;
81
+ };
82
+ export declare function readCodexHookTrust(path?: string): {
83
+ trusted: HookEvent[];
84
+ untrusted: HookEvent[];
85
+ };
86
+ export {};
87
+ //# sourceMappingURL=codex-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codex-config.d.ts","sourceRoot":"","sources":["../../../src/lib/codex-config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,399 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
5
+ // Reads and edits Codex CLI's configuration on the user's behalf.
6
+ //
7
+ // Codex splits what Claude Code keeps in one file: telemetry in
8
+ // `~/.codex/config.toml` under `[otel]`, hooks in `~/.codex/hooks.json`. Both follow
9
+ // the same two rules as `claude-settings.ts` — never write over something we could
10
+ // not parse, only ever touch keys we put there — plus a third that TOML forces:
11
+ //
12
+ // Splice, don't re-serialise. A round trip through a TOML parser throws away every
13
+ // comment and blank line, and `config.toml` is hand-written and annotated. Only the
14
+ // `[otel]` region is replaced textually; `writeCodexConfig` re-parses the result and
15
+ // refuses to write if anything outside `[otel]` moved, which is what makes that safe.
16
+ //
17
+ // Two Codex behaviours matter here:
18
+ //
19
+ // - `otel.exporter` is the *log* exporter, and Codex POSTs to the URL verbatim — it
20
+ // does not append `/v1/logs` the way the OTel SDK does. The path is part of the
21
+ // configured endpoint, and `readCodexTelemetry` strips it back off.
22
+ // - Codex will not run a `hooks.json` it has not been shown: the first session after
23
+ // this file changes prompts "Hooks need review" and persists the answer under
24
+ // `[hooks.state]`. Setup cannot do that for the user, so it tells them — see
25
+ // `setup.ts`.
26
+ export const CODEX_CONFIG_FILENAME = "config.toml";
27
+ export const CODEX_HOOKS_FILENAME = "hooks.json";
28
+ // The receiver path Codex's log exporter is pointed at. Load-bearing both ways:
29
+ // written onto the endpoint because Codex sends to the literal URL, and stripped when
30
+ // reading because the hook and `/v1/verify` need the base.
31
+ export const CODEX_LOGS_PATH = "/v1/logs";
32
+ // Seconds Codex will wait for each hook. SessionEnd is 3 rather than 10 because Codex
33
+ // hard-clamps it to 3 and otherwise prints a warning on every session start — a
34
+ // permanent warning about a value we chose is worse than the shorter budget, and the
35
+ // hook's own request timeout is 4s.
36
+ export const CODEX_HOOK_TIMEOUT_SECONDS = { SessionStart: 10, SessionEnd: 3 };
37
+ const HOOK_EVENTS = ["SessionStart", "SessionEnd"];
38
+ /** Exported so callers can tell "none approved" from "some approved". */
39
+ export const HOOK_EVENT_COUNT = HOOK_EVENTS.length;
40
+ export class CodexConfigParseError extends Error {
41
+ path;
42
+ constructor(path, detail) {
43
+ super(`${path} is not valid ${path.endsWith(".json") ? "JSON" : "TOML"} (${detail}). Fix or move it, then run 'clocktopus agent setup' again.`);
44
+ this.path = path;
45
+ this.name = "CodexConfigParseError";
46
+ }
47
+ }
48
+ export function codexConfigDir() {
49
+ // Codex honours CODEX_HOME; following it means setup writes where that installation
50
+ // actually reads.
51
+ return process.env.CODEX_HOME || join(homedir(), ".codex");
52
+ }
53
+ export function codexConfigPath() {
54
+ return join(codexConfigDir(), CODEX_CONFIG_FILENAME);
55
+ }
56
+ export function codexHooksPath() {
57
+ return join(codexConfigDir(), CODEX_HOOKS_FILENAME);
58
+ }
59
+ export function readCodexConfig(path = codexConfigPath()) {
60
+ if (!existsSync(path)) {
61
+ return { path, exists: false, modifiedAt: null, text: "", config: {} };
62
+ }
63
+ const text = readFileSync(path, "utf8");
64
+ const modifiedAt = statSync(path).mtime;
65
+ try {
66
+ const parsed = parseToml(text);
67
+ return { path, exists: true, modifiedAt, text, config: parsed };
68
+ }
69
+ catch (error) {
70
+ throw new CodexConfigParseError(path, error instanceof Error ? error.message : "unknown error");
71
+ }
72
+ }
73
+ function asTable(value) {
74
+ return value && typeof value === "object" && !Array.isArray(value)
75
+ ? value
76
+ : {};
77
+ }
78
+ /** The `[otel]` keys `clocktopus agent setup` owns — and the only ones it removes. */
79
+ const OWNED_OTEL_KEYS = ["exporter", "log_user_prompt"];
80
+ export function buildCodexOtel(input) {
81
+ const endpoint = `${input.endpoint.replace(/\/$/, "")}${CODEX_LOGS_PATH}`;
82
+ return {
83
+ // The receiver answers protobuf with an explicit 415, and only JSON actually works.
84
+ // Codex's default for this exporter is `binary`.
85
+ exporter: {
86
+ "otlp-http": {
87
+ endpoint,
88
+ protocol: "json",
89
+ headers: { Authorization: `Bearer ${input.token}` },
90
+ },
91
+ },
92
+ // Already the default, set explicitly anyway. It does not cover tool output — Codex
93
+ // has a single log exporter and no switch for that, which is why the receiver's
94
+ // allowlist, not this line, is what actually holds.
95
+ log_user_prompt: false,
96
+ };
97
+ }
98
+ // Replaces the `[otel]` region of a TOML document, preserving everything else.
99
+ // Region detection is deliberately dumb — table headers only — and `writeCodexConfig`
100
+ // verifies by re-parsing, so a document this misreads is refused rather than mangled.
101
+ export function spliceOtelSection(text, otel) {
102
+ const rendered = otel && Object.keys(otel).length > 0
103
+ ? stringifyToml({ otel }).trimEnd()
104
+ : null;
105
+ const lines = text.split("\n");
106
+ const isOurHeader = (line) => /^\s*\[\s*otel\s*\]/.test(line) || /^\s*\[\s*otel\s*\./.test(line);
107
+ const isAnyHeader = (line) => /^\s*\[/.test(line);
108
+ let start = -1;
109
+ let end = lines.length;
110
+ for (let i = 0; i < lines.length; i++) {
111
+ const line = lines[i] ?? "";
112
+ if (start === -1) {
113
+ if (isOurHeader(line))
114
+ start = i;
115
+ continue;
116
+ }
117
+ if (isAnyHeader(line) && !isOurHeader(line)) {
118
+ end = i;
119
+ break;
120
+ }
121
+ }
122
+ if (start === -1) {
123
+ if (!rendered)
124
+ return text;
125
+ const body = text.trimEnd();
126
+ return body ? `${body}\n\n${rendered}\n` : `${rendered}\n`;
127
+ }
128
+ const before = lines.slice(0, start);
129
+ const after = lines.slice(end);
130
+ const middle = rendered ? rendered.split("\n") : [];
131
+ // Drop the blank line that separated a removed section from what follows, so repeated
132
+ // add/remove cycles cannot pile up empty lines.
133
+ if (!rendered) {
134
+ while (before.length > 0 && before[before.length - 1]?.trim() === "")
135
+ before.pop();
136
+ if (before.length > 0 && after.length > 0)
137
+ before.push("");
138
+ }
139
+ return `${[...before, ...middle, ...after].join("\n").trimEnd()}\n`;
140
+ }
141
+ // Writes config.toml, keeping a one-deep backup and verifying the splice.
142
+ //
143
+ // The verification is the point: it re-parses what is about to be written and compares
144
+ // every top-level key except `otel` against what was there before. If the splice
145
+ // disturbed anything else, the write is refused with the user's file still intact.
146
+ export function writeCodexConfig(input) {
147
+ const path = input.path ?? codexConfigPath();
148
+ let nextConfig;
149
+ try {
150
+ nextConfig = parseToml(input.nextText);
151
+ }
152
+ catch (error) {
153
+ throw new Error(`Refusing to write ${path}: the edit did not produce valid TOML (${error instanceof Error ? error.message : "unknown error"}). Nothing was changed.`);
154
+ }
155
+ for (const key of new Set([
156
+ ...Object.keys(input.previousConfig),
157
+ ...Object.keys(nextConfig),
158
+ ])) {
159
+ if (key === "otel")
160
+ continue;
161
+ if (JSON.stringify(input.previousConfig[key]) !==
162
+ JSON.stringify(nextConfig[key])) {
163
+ throw new Error(`Refusing to write ${path}: editing [otel] would have changed [${key}] as well. ` +
164
+ "Nothing was changed — please add the [otel] section by hand, or move the file aside.");
165
+ }
166
+ }
167
+ mkdirSync(dirname(path), { recursive: true });
168
+ let backupPath = null;
169
+ if (existsSync(path)) {
170
+ backupPath = `${path}.clocktopus-backup`;
171
+ copyFileSync(path, backupPath);
172
+ }
173
+ // Rename rather than write in place: a crash midway leaves either the old file or the
174
+ // new one, never a truncated config Codex would refuse to start with.
175
+ const temporaryPath = `${path}.clocktopus-tmp`;
176
+ writeFileSync(temporaryPath, input.nextText, {
177
+ encoding: "utf8",
178
+ mode: 0o600,
179
+ });
180
+ renameSync(temporaryPath, path);
181
+ return { backupPath };
182
+ }
183
+ /** What config.toml currently declares, for `status` and `doctor`. */
184
+ export function readCodexTelemetry(path = codexConfigPath()) {
185
+ const { exists, modifiedAt, config } = readCodexConfig(path);
186
+ const otel = asTable(config.otel);
187
+ const http = asTable(asTable(otel.exporter)["otlp-http"]);
188
+ const headers = asTable(http.headers);
189
+ const authorization = typeof headers.Authorization === "string"
190
+ ? headers.Authorization
191
+ : typeof headers.authorization === "string"
192
+ ? headers.authorization
193
+ : null;
194
+ const rawEndpoint = typeof http.endpoint === "string" ? http.endpoint : null;
195
+ return {
196
+ path,
197
+ exists,
198
+ modifiedAt,
199
+ token: authorization?.replace(/^Bearer\s+/i, "").trim() || null,
200
+ endpoint: rawEndpoint
201
+ ? rawEndpoint.replace(new RegExp(`${CODEX_LOGS_PATH}/?$`), "")
202
+ : null,
203
+ protocol: typeof http.protocol === "string" ? http.protocol : null,
204
+ logUserPrompt: typeof otel.log_user_prompt === "boolean" ? otel.log_user_prompt : null,
205
+ };
206
+ }
207
+ export function applyCodexOtel(current, input) {
208
+ const merged = {
209
+ ...asTable(current.config.otel),
210
+ ...buildCodexOtel(input),
211
+ };
212
+ return spliceOtelSection(current.text, merged);
213
+ }
214
+ export function removeCodexOtel(current) {
215
+ const otel = { ...asTable(current.config.otel) };
216
+ const removedKeys = [];
217
+ for (const key of OWNED_OTEL_KEYS) {
218
+ if (key in otel) {
219
+ delete otel[key];
220
+ removedKeys.push(key);
221
+ }
222
+ }
223
+ return {
224
+ nextText: spliceOtelSection(current.text, Object.keys(otel).length > 0 ? otel : null),
225
+ removedKeys,
226
+ };
227
+ }
228
+ export function readCodexHooks(path = codexHooksPath()) {
229
+ if (!existsSync(path)) {
230
+ return {
231
+ path,
232
+ exists: false,
233
+ modifiedAt: null,
234
+ file: {},
235
+ commands: {},
236
+ };
237
+ }
238
+ const raw = readFileSync(path, "utf8");
239
+ const modifiedAt = statSync(path).mtime;
240
+ let file = {};
241
+ if (raw.trim() !== "") {
242
+ try {
243
+ const parsed = JSON.parse(raw);
244
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
245
+ throw new Error("expected an object");
246
+ }
247
+ file = parsed;
248
+ }
249
+ catch (error) {
250
+ throw new CodexConfigParseError(path, error instanceof Error ? error.message : "unknown error");
251
+ }
252
+ }
253
+ const hooks = asTable(file.hooks);
254
+ const commands = {};
255
+ for (const event of HOOK_EVENTS) {
256
+ for (const group of asGroups(hooks[event])) {
257
+ const ours = (group.hooks ?? []).find(isClocktopusHook);
258
+ if (ours?.command) {
259
+ commands[event] = ours.command;
260
+ break;
261
+ }
262
+ }
263
+ }
264
+ return { path, exists: true, modifiedAt, file, commands };
265
+ }
266
+ function isClocktopusHook(entry) {
267
+ const command = typeof entry.command === "string" ? entry.command : "";
268
+ return /clocktopus/i.test(command) && /agent\s+hook/.test(command);
269
+ }
270
+ function asGroups(value) {
271
+ return Array.isArray(value) ? value : [];
272
+ }
273
+ function withoutOurHooks(groups) {
274
+ return groups
275
+ .map((group) => ({
276
+ ...group,
277
+ hooks: (group.hooks ?? []).filter((entry) => !isClocktopusHook(entry)),
278
+ }))
279
+ .filter((group) => (group.hooks ?? []).length > 0);
280
+ }
281
+ export function applyCodexHooks(file, input) {
282
+ const existing = asTable(file.hooks);
283
+ const hooks = { ...existing };
284
+ for (const event of HOOK_EVENTS) {
285
+ // Remove-then-append rather than append: re-running setup must not leave two hooks
286
+ // firing per session, which would double every SessionStart POST.
287
+ hooks[event] = [
288
+ ...withoutOurHooks(asGroups(existing[event])),
289
+ {
290
+ hooks: [
291
+ {
292
+ type: "command",
293
+ command: input.hookCommand,
294
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS[event],
295
+ // No `async` key, deliberately. Codex 0.147 *skips* an async SessionStart outright —
296
+ // costing every session its repository context — and every version so far still runs
297
+ // an async SessionEnd synchronously and warns once per session. `agent hook`
298
+ // backgrounds itself instead, which is correct and quiet on all of them.
299
+ },
300
+ ],
301
+ },
302
+ ];
303
+ }
304
+ return {
305
+ // Codex shows this string when it asks the user to trust the file, so it is the one
306
+ // chance to say where it came from.
307
+ description: typeof file.description === "string" && file.description
308
+ ? file.description
309
+ : "Clocktopus agent telemetry",
310
+ ...file,
311
+ hooks,
312
+ };
313
+ }
314
+ export function removeCodexHooks(file) {
315
+ const existing = asTable(file.hooks);
316
+ const hooks = { ...existing };
317
+ let removed = false;
318
+ for (const event of HOOK_EVENTS) {
319
+ const before = asGroups(existing[event]);
320
+ const after = withoutOurHooks(before);
321
+ if (JSON.stringify(before) !== JSON.stringify(after))
322
+ removed = true;
323
+ if (after.length > 0)
324
+ hooks[event] = after;
325
+ else
326
+ delete hooks[event];
327
+ }
328
+ const next = { ...file };
329
+ if (Object.keys(hooks).length > 0)
330
+ next.hooks = hooks;
331
+ else
332
+ delete next.hooks;
333
+ return { file: next, removed };
334
+ }
335
+ export function writeCodexHooks(file, path = codexHooksPath()) {
336
+ mkdirSync(dirname(path), { recursive: true });
337
+ let backupPath = null;
338
+ if (existsSync(path)) {
339
+ backupPath = `${path}.clocktopus-backup`;
340
+ copyFileSync(path, backupPath);
341
+ }
342
+ const temporaryPath = `${path}.clocktopus-tmp`;
343
+ writeFileSync(temporaryPath, `${JSON.stringify(file, null, 2)}\n`, {
344
+ encoding: "utf8",
345
+ mode: 0o600,
346
+ });
347
+ renameSync(temporaryPath, path);
348
+ return { backupPath };
349
+ }
350
+ // Which of our hooks Codex has been shown and told to run.
351
+ //
352
+ // Trust is recorded per hook *entry*, not per file, keyed
353
+ // `<path>:<event>:<group index>:<hook index>` with the event in snake_case:
354
+ //
355
+ // [hooks.state."/home/you/.codex/hooks.json:session_end:0:0"]
356
+ // trusted_hash = "sha256:…"
357
+ //
358
+ // Per-entry granularity is why this reports each event rather than a single boolean:
359
+ // partial trust is a real state, and the one that matters. A machine whose SessionEnd
360
+ // is trusted and whose SessionStart is not records spend with no repository attached.
361
+ //
362
+ // The hash is Codex's own and is not recomputed here; changing `hooks.json`
363
+ // invalidates it and Codex re-prompts, which is the behaviour we want.
364
+ export function readCodexHookTrust(path = codexConfigPath()) {
365
+ let config;
366
+ try {
367
+ ({ config } = readCodexConfig(path));
368
+ }
369
+ catch {
370
+ return { trusted: [], untrusted: [...HOOK_EVENTS] };
371
+ }
372
+ const state = asTable(asTable(config.hooks).state);
373
+ const prefix = `${codexHooksPath()}:`;
374
+ const trusted = new Set();
375
+ for (const [key, value] of Object.entries(state)) {
376
+ if (!key.startsWith(prefix))
377
+ continue;
378
+ // `enabled` is optional and version-dependent: 0.147 wrote `enabled = true` next to
379
+ // the hash, 0.148 persists `trusted_hash` alone. Requiring it read a freshly approved
380
+ // 0.148 install as pending forever. Absent means approved; only an explicit `false`
381
+ // counts as untrusted.
382
+ const entry = asTable(value);
383
+ if (entry.enabled === false || typeof entry.trusted_hash !== "string")
384
+ continue;
385
+ // `<path>:<event>:<group>:<index>` — sliced by prefix length rather than split, since
386
+ // a path could in principle contain a colon.
387
+ const event = key.slice(prefix.length).split(":")[0];
388
+ const match = HOOK_EVENTS.find((candidate) => toSnakeCase(candidate) === event);
389
+ if (match)
390
+ trusted.add(match);
391
+ }
392
+ return {
393
+ trusted: HOOK_EVENTS.filter((event) => trusted.has(event)),
394
+ untrusted: HOOK_EVENTS.filter((event) => !trusted.has(event)),
395
+ };
396
+ }
397
+ function toSnakeCase(event) {
398
+ return event.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
399
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=codex-config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codex-config.test.d.ts","sourceRoot":"","sources":["../../../src/lib/codex-config.test.ts"],"names":[],"mappings":""}