@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.
- package/README.md +89 -34
- package/dist/src/commands/agent/disable.d.ts +8 -1
- package/dist/src/commands/agent/disable.d.ts.map +1 -1
- package/dist/src/commands/agent/disable.js +79 -45
- package/dist/src/commands/agent/doctor.d.ts.map +1 -1
- package/dist/src/commands/agent/doctor.js +146 -75
- package/dist/src/commands/agent/hook.d.ts +4 -1
- package/dist/src/commands/agent/hook.d.ts.map +1 -1
- package/dist/src/commands/agent/hook.js +152 -15
- package/dist/src/commands/agent/setup.d.ts +17 -10
- package/dist/src/commands/agent/setup.d.ts.map +1 -1
- package/dist/src/commands/agent/setup.js +208 -69
- package/dist/src/commands/agent/status.d.ts.map +1 -1
- package/dist/src/commands/agent/status.js +46 -24
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +18 -4
- package/dist/src/lib/agent-config.d.ts +18 -3
- package/dist/src/lib/agent-config.d.ts.map +1 -1
- package/dist/src/lib/agent-config.js +44 -19
- package/dist/src/lib/agent-hook-state.d.ts +9 -1
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
- package/dist/src/lib/agent-hook-state.js +21 -2
- package/dist/src/lib/agents.d.ts +115 -0
- package/dist/src/lib/agents.d.ts.map +1 -0
- package/dist/src/lib/agents.js +245 -0
- package/dist/src/lib/codex-config.d.ts +166 -0
- package/dist/src/lib/codex-config.d.ts.map +1 -0
- package/dist/src/lib/codex-config.js +441 -0
- package/dist/src/lib/codex-config.test.d.ts +2 -0
- package/dist/src/lib/codex-config.test.d.ts.map +1 -0
- package/dist/src/lib/codex-config.test.js +359 -0
- package/dist/src/lib/opencode-config.d.ts +108 -0
- package/dist/src/lib/opencode-config.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.js +330 -0
- package/dist/src/lib/opencode-config.test.d.ts +2 -0
- package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.test.js +140 -0
- 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 @@
|
|
|
1
|
+
{"version":3,"file":"codex-config.test.d.ts","sourceRoot":"","sources":["../../../src/lib/codex-config.test.ts"],"names":[],"mappings":""}
|