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