Repository Public
1/**2 * Codex `notify` program installation (the Codex counterpart of the Claude Code hooks block).3 *4 * Codex runs the program configured by the TOP-LEVEL `notify` key of `$CODEX_HOME/config.toml`5 * with one extra argument: a JSON object such as6 * {"type":"agent-turn-complete","thread-id":"<uuid>","turn-id":…,"cwd":…,"client":…,7 * "input-messages":[…],"last-assistant-message":"…"}8 * (field names observed in the codex-cli 0.166.0 binary). `bin/cr-codex-notify` forwards that9 * JSON to `/hooks/claude`, where `agent-turn-complete` becomes the `Stop` event.10 *11 * The file is edited line by line, never re-serialised: every other key, table, comment and12 * blank line is preserved byte for byte. A `notify` that is not ours is NEVER replaced.13 */14import fs from 'node:fs/promises';15import os from 'node:os';16import path from 'node:path';17import { fileURLToPath } from 'node:url';18import { atomicWrite } from './core-util.js';1920export const NOTIFY_SCRIPT_NAME = 'cr-codex-notify';2122export type NotifyInstallStatus =23 /** Our entry is in the file (written now or already there). */24 | 'installed'25 /** Another program is configured: the file was left untouched. */26 | 'conflict'27 /** Nothing of ours in the file (after uninstall, or nothing to remove). */28 | 'absent';2930export interface NotifyResult {31 status: NotifyInstallStatus;32 /** The file was modified by this call. */33 changed: boolean;34}3536interface NotifyEntry {37 /** First line of the `notify = …` assignment (0-based). */38 start: number;39 /** Line after its last line. */40 end: number;41 /** Raw text of the value (after `=`), comments included. */42 rawValue: string;43}4445interface Scan {46 entry: NotifyEntry | null;47 /** Index of the first table header line; `lines.length` when the file has none. */48 firstTable: number;49 /** A multi-line string or array is still open at the end of the top-level region. */50 unbalanced: boolean;51}5253/**54 * Walks the top-level region (everything before the first `[table]` header), tracking55 * multi-line strings and arrays so that a `[` inside a value is not taken for a header and a56 * `notify =` inside a multi-line string is not taken for the key.57 */58function scanTopLevel(lines: readonly string[]): Scan {59 let multi: '"""' | "'''" | null = null;60 let depth = 0;61 let entry: NotifyEntry | null = null;62 let open: { start: number; raw: string[] } | null = null;63 let firstTable = lines.length;6465 for (let i = 0; i < lines.length; i++) {66 const line = lines[i] as string;67 const atStatementStart = multi === null && depth === 0;68 if (atStatementStart && /^\s*\[/.test(line)) {69 firstTable = i;70 break;71 }72 let valueStart = 0;73 if (atStatementStart) {74 const m = /^\s*(?:notify|"notify"|'notify')\s*=/.exec(line);75 if (m && entry === null) {76 open = { start: i, raw: [] };77 valueStart = m[0].length;78 }79 }80 // Scan the line for string / array structure.81 let j = 0;82 if (atStatementStart) {83 const eq = line.indexOf('=');84 // Blank line, comment, or something that is not `key = value`: nothing to scan.85 j = eq < 0 || /^\s*#/.test(line) ? line.length : Math.max(valueStart, eq + 1);86 }87 for (; j < line.length; j++) {88 if (multi !== null) {89 if (multi === '"""' && line[j] === '\\') {90 j++;91 continue;92 }93 if (line.startsWith(multi, j)) {94 j += 2;95 multi = null;96 }97 continue;98 }99 const ch = line[j];100 if (ch === '#') break;101 if (line.startsWith('"""', j) || line.startsWith("'''", j)) {102 multi = line.slice(j, j + 3) as '"""' | "'''";103 j += 2;104 } else if (ch === '"') {105 for (j++; j < line.length && line[j] !== '"'; j++) if (line[j] === '\\') j++;106 } else if (ch === "'") {107 for (j++; j < line.length && line[j] !== "'"; j++);108 } else if (ch === '[' || ch === '{') {109 depth++;110 } else if (ch === ']' || ch === '}') {111 depth = Math.max(0, depth - 1);112 }113 }114 if (open) {115 open.raw.push(open.raw.length === 0 ? line.slice(valueStart) : line);116 if (multi === null && depth === 0) {117 entry = { start: open.start, end: i + 1, rawValue: open.raw.join('\n') };118 open = null;119 }120 }121 }122 return { entry, firstTable, unbalanced: multi !== null || depth !== 0 || open !== null };123}124125/**126 * Parses a TOML array of plain strings (`["a", 'b']`, possibly on several lines, with comments).127 * Returns null for anything else: such a value is treated as "someone else's".128 */129export function parseTomlStringArray(raw: string): string[] | null {130 const out: string[] = [];131 let i = 0;132 const skip = (): void => {133 for (;;) {134 while (i < raw.length && /\s/.test(raw[i] as string)) i++;135 if (raw[i] !== '#') return;136 while (i < raw.length && raw[i] !== '\n') i++;137 }138 };139 skip();140 if (raw[i] !== '[') return null;141 i++;142 for (;;) {143 skip();144 if (raw[i] === ']') {145 i++;146 break;147 }148 if (raw[i] === '"') {149 if (raw.startsWith('"""', i)) return null;150 let j = i + 1;151 while (j < raw.length && raw[j] !== '"') j += raw[j] === '\\' ? 2 : 1;152 if (j >= raw.length) return null;153 try {154 // TOML basic strings share JSON's escapes (plus \UXXXXXXXX and \e, not expected in a path).155 const v: unknown = JSON.parse(raw.slice(i, j + 1));156 if (typeof v !== 'string') return null;157 out.push(v);158 } catch {159 return null;160 }161 i = j + 1;162 } else if (raw[i] === "'") {163 if (raw.startsWith("'''", i)) return null;164 const j = raw.indexOf("'", i + 1);165 if (j < 0) return null;166 out.push(raw.slice(i + 1, j));167 i = j + 1;168 } else {169 return null;170 }171 skip();172 if (raw[i] === ',') i++;173 else if (raw[i] !== ']') return null;174 }175 skip();176 return i === raw.length ? out : null;177}178179/** Ours = an array whose first element is a file named `cr-codex-notify` (whatever its directory). */180function isOurs(entry: NotifyEntry): boolean {181 const argv = parseTomlStringArray(entry.rawValue);182 return argv !== null && argv.length >= 1 && path.basename(argv[0] as string) === NOTIFY_SCRIPT_NAME;183}184185interface Loaded {186 raw: string | null;187 lines: string[];188 eol: string;189 mode: number;190}191async function load(configTomlPath: string): Promise<Loaded> {192 let raw: string;193 try {194 raw = await fs.readFile(configTomlPath, 'utf8');195 } catch (err) {196 if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { raw: null, lines: [], eol: '\n', mode: 0o600 };197 throw err;198 }199 const eol = raw.includes('\r\n') ? '\r\n' : '\n';200 const lines = raw === '' ? [] : raw.split(eol);201 // A trailing newline yields a final empty element: drop it, it is re-added when writing.202 if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();203 return { raw, lines, eol, mode: (await fs.stat(configTomlPath)).mode & 0o777 };204}205206async function save(configTomlPath: string, l: Loaded, lines: readonly string[]): Promise<boolean> {207 const next = lines.length === 0 ? '' : lines.join(l.eol) + l.eol;208 if (l.raw !== null && next === l.raw) return false;209 await fs.mkdir(path.dirname(configTomlPath), { recursive: true });210 await atomicWrite(configTomlPath, next, l.mode);211 return true;212}213214/**215 * Idempotently sets `notify = ["<hookScriptPath>"]` at the top level of a Codex `config.toml`.216 * - no file / no `notify` key → the line is added before the first table header;217 * - our entry (any directory) → rewritten only if the path differs;218 * - a different `notify` → `conflict`, the file is not touched;219 * - a file this scanner cannot follow (unterminated string / array) → throws, not touched.220 */221export async function installCodexNotify(configTomlPath: string, hookScriptPath: string): Promise<NotifyResult> {222 if (!path.isAbsolute(hookScriptPath) || path.basename(hookScriptPath) !== NOTIFY_SCRIPT_NAME) {223 throw new Error(`hookScriptPath must be an absolute path to a file named ${NOTIFY_SCRIPT_NAME}`);224 }225 const l = await load(configTomlPath);226 const scan = scanTopLevel(l.lines);227 if (scan.unbalanced) throw new Error(`${configTomlPath}: unterminated string or array, file left untouched`);228 const ours = `notify = [${JSON.stringify(hookScriptPath)}]`;229 const lines = [...l.lines];230 if (scan.entry) {231 if (!isOurs(scan.entry)) return { status: 'conflict', changed: false };232 lines.splice(scan.entry.start, scan.entry.end - scan.entry.start, ours);233 } else {234 // End of the top-level region: after its last non-blank line, before the first table.235 let at = scan.firstTable;236 while (at > 0 && (lines[at - 1] as string).trim() === '') at--;237 const block = [ours];238 if (at < lines.length && (lines[at] as string).trim() !== '') block.push('');239 lines.splice(at, 0, ...block);240 }241 return { status: 'installed', changed: await save(configTomlPath, l, lines) };242}243244/** Removes our `notify` entry (and only ours). */245export async function uninstallCodexNotify(configTomlPath: string): Promise<NotifyResult> {246 const l = await load(configTomlPath);247 if (l.raw === null) return { status: 'absent', changed: false };248 const scan = scanTopLevel(l.lines);249 if (scan.unbalanced) throw new Error(`${configTomlPath}: unterminated string or array, file left untouched`);250 if (!scan.entry) return { status: 'absent', changed: false };251 if (!isOurs(scan.entry)) return { status: 'conflict', changed: false };252 const lines = [...l.lines];253 lines.splice(scan.entry.start, scan.entry.end - scan.entry.start);254 // Do not leave the blank separator we added in front of the first table as a leading blank line.255 if (scan.entry.start === 0) while (lines.length > 0 && (lines[0] as string).trim() === '') lines.shift();256 return { status: 'absent', changed: await save(configTomlPath, l, lines) };257}258259export function defaultCodexHome(env: NodeJS.ProcessEnv = process.env): string {260 const custom = env.CODEX_HOME;261 return custom && custom.trim() !== '' ? path.resolve(custom) : path.join(env.HOME || os.homedir(), '.codex');262}263264export function defaultCodexConfigPath(env: NodeJS.ProcessEnv = process.env): string {265 return path.join(defaultCodexHome(env), 'config.toml');266}267268export function defaultNotifyScriptPath(): string {269 return fileURLToPath(new URL(`../bin/${NOTIFY_SCRIPT_NAME}`, import.meta.url)); // same relative place from src/ and dist/270}271