SPB Git

Repository Public

codex-notify.ts 10.2 KB 271 lines typescript

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
Files main
Menu

Theme