UNPKG

rigjs

Version:

A multi-repos dev tool based on yarn and git.Rigjs is intended to be the simplest way to develop,share and deliver codes between different developers or different projects.

182 lines (163 loc) 6.83 kB
import fs from 'fs'; import path from 'path'; import { uniqueBackupPath } from './settings'; type JsonObject = Record<string, any>; export interface CodexHooksMutation { changed: boolean; backupPath?: string; } // Codex ignores matchers for UserPromptSubmit. Keep a stable marker on Rig's // group so install/uninstall never claims another tool's lookalike command. export const CODEX_HANDOFF_OWNER = 'rigjs:handoff:v1'; const LEGACY_CODEX_HANDOFF_STATUS = 'Preparing Claude handoff…'; export function buildCodexHookCommand(executable: string): string { return `${shellQuote(path.resolve(executable))} handoff from-codex hook`; } export function installCodexHooks(hooksPath: string, backupDir: string, command: string): CodexHooksMutation { const config = readCodexHooks(hooksPath); const before = stableJson(config); stripOwnedCodexHooks(config); if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) config.hooks = {}; if (!Array.isArray(config.hooks.UserPromptSubmit)) config.hooks.UserPromptSubmit = []; config.hooks.UserPromptSubmit.push({ matcher: CODEX_HANDOFF_OWNER, hooks: [{ type: 'command', command, timeout: 5, }], }); if (before === stableJson(config)) return { changed: false }; const backupPath = writeCodexHooks(hooksPath, backupDir, config); return { changed: true, backupPath }; } export function uninstallCodexHooks(hooksPath: string, backupDir: string): CodexHooksMutation { if (!fs.existsSync(hooksPath)) return { changed: false }; const config = readCodexHooks(hooksPath); const before = stableJson(config); stripOwnedCodexHooks(config); if (before === stableJson(config)) return { changed: false }; const backupPath = writeCodexHooks(hooksPath, backupDir, config); return { changed: true, backupPath }; } export function hasInstalledCodexHook(hooksPath: string): boolean { if (!fs.existsSync(hooksPath)) return false; const config = readCodexHooks(hooksPath); const groups = config.hooks && config.hooks.UserPromptSubmit; if (!Array.isArray(groups)) return false; return groups.some((group: any) => isOwnedCodexGroup(group) && group.hooks.some(isOwnedCodexHandler)); } export function readCodexHooks(hooksPath: string): JsonObject { if (!fs.existsSync(hooksPath)) { let stat: fs.Stats; try { stat = fs.lstatSync(hooksPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; throw error; } if (stat.isSymbolicLink()) throw new Error(`Codex hooks symlink is dangling: ${hooksPath}`); return {}; } let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); } catch (error) { throw new Error(`cannot parse Codex hooks ${hooksPath}: ${error instanceof Error ? error.message : error}`); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`Codex hooks must contain a JSON object: ${hooksPath}`); } const config = parsed as JsonObject; if (config.hooks != null && (typeof config.hooks !== 'object' || Array.isArray(config.hooks))) { throw new Error(`Codex hooks.hooks must contain a JSON object: ${hooksPath}`); } if (config.hooks && config.hooks.UserPromptSubmit != null && !Array.isArray(config.hooks.UserPromptSubmit)) { throw new Error(`Codex hooks.UserPromptSubmit must contain an array: ${hooksPath}`); } return config; } export function isCodexHooksFeatureDisabled(configPath: string): boolean { let content: string; try { content = fs.readFileSync(configPath, 'utf8'); } catch { return false; } const match = content.match(/(?:^|\n)\s*\[features\]\s*\n([\s\S]*?)(?=\n\s*\[|$)/); return !!match && /^\s*hooks\s*=\s*false\s*(?:#.*)?$/m.test(match[1]); } function stripOwnedCodexHooks(config: JsonObject): void { if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) return; const groups = config.hooks.UserPromptSubmit; if (!Array.isArray(groups)) return; const nextGroups: any[] = []; for (const group of groups) { const currentOwned = isOwnedCodexGroup(group); const legacyOwned = isLegacyOwnedCodexGroup(group); if (!currentOwned && !legacyOwned) { nextGroups.push(group); continue; } const hooks = group.hooks.filter((handler: any) => currentOwned ? !isOwnedCodexHandler(handler) : !isLegacyOwnedCodexHandler(handler)); if (hooks.length > 0) nextGroups.push({ ...group, hooks }); } if (nextGroups.length > 0) config.hooks.UserPromptSubmit = nextGroups; else delete config.hooks.UserPromptSubmit; if (Object.keys(config.hooks).length === 0) delete config.hooks; } function isOwnedCodexGroup(group: any): boolean { return !!group && typeof group === 'object' && group.matcher === CODEX_HANDOFF_OWNER && Array.isArray(group.hooks); } function isOwnedCodexHandler(handler: any): boolean { return !!handler && handler.type === 'command' && typeof handler.command === 'string' && /(?:^|\s)handoff\s+from-codex\s+hook(?:\s|$)/.test(handler.command); } function isLegacyOwnedCodexGroup(group: any): boolean { return !!group && typeof group === 'object' && group.matcher == null && Array.isArray(group.hooks) && group.hooks.some(isLegacyOwnedCodexHandler); } function isLegacyOwnedCodexHandler(handler: any): boolean { return isOwnedCodexHandler(handler) && handler.statusMessage === LEGACY_CODEX_HANDOFF_STATUS; } function writeCodexHooks(hooksPath: string, backupDir: string, config: JsonObject): string | undefined { const writePath = resolveWritePath(hooksPath); fs.mkdirSync(path.dirname(writePath), { recursive: true }); let backupPath: string | undefined; let mode = 0o600; if (fs.existsSync(writePath)) { mode = fs.statSync(writePath).mode & 0o777; fs.mkdirSync(backupDir, { recursive: true }); backupPath = uniqueBackupPath(backupDir, 'codex-hooks', '.json'); fs.copyFileSync(writePath, backupPath); fs.chmodSync(backupPath, 0o600); } const temp = `${writePath}.rig-handoff-${process.pid}-${Date.now()}`; try { fs.writeFileSync(temp, stableJson(config), { mode }); fs.renameSync(temp, writePath); } finally { if (fs.existsSync(temp)) fs.rmSync(temp, { force: true }); } return backupPath; } function resolveWritePath(value: string): string { let stat: fs.Stats; try { stat = fs.lstatSync(value); } catch { return value; } if (!stat.isSymbolicLink()) return value; try { return fs.realpathSync(value); } catch { throw new Error(`Codex hooks symlink is dangling: ${value}`); } } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } function stableJson(value: JsonObject): string { return JSON.stringify(value, null, 2) + '\n'; }