@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.
- package/README.md +91 -36
- 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 +157 -95
- 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 +239 -98
- package/dist/src/commands/agent/setup.d.ts +1 -16
- package/dist/src/commands/agent/setup.d.ts.map +1 -1
- package/dist/src/commands/agent/setup.js +198 -81
- 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 +5 -23
- package/dist/src/lib/agent-config.d.ts.map +1 -1
- package/dist/src/lib/agent-config.js +50 -36
- package/dist/src/lib/agent-hook-state.d.ts +11 -7
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
- package/dist/src/lib/agent-hook-state.js +34 -29
- package/dist/src/lib/agents.d.ts +52 -0
- package/dist/src/lib/agents.d.ts.map +1 -0
- package/dist/src/lib/agents.js +238 -0
- package/dist/src/lib/claude-settings.d.ts +0 -36
- package/dist/src/lib/claude-settings.d.ts.map +1 -1
- package/dist/src/lib/claude-settings.js +37 -62
- package/dist/src/lib/codex-config.d.ts +87 -0
- package/dist/src/lib/codex-config.d.ts.map +1 -0
- package/dist/src/lib/codex-config.js +399 -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/declared-commits.d.ts +43 -0
- package/dist/src/lib/declared-commits.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.js +114 -0
- package/dist/src/lib/declared-commits.test.d.ts +2 -0
- package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.test.js +129 -0
- package/dist/src/lib/git-remotes.d.ts +9 -0
- package/dist/src/lib/git-remotes.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.js +50 -0
- package/dist/src/lib/git-remotes.test.d.ts +2 -0
- package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.test.js +52 -0
- package/dist/src/lib/opencode-config.d.ts +23 -0
- package/dist/src/lib/opencode-config.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.js +290 -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
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"codex-config.test.d.ts","sourceRoot":"","sources":["../../../src/lib/codex-config.test.ts"],"names":[],"mappings":""}
|