diff --git a/README.md b/README.md index d91e21b246..a02eabd38b 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rt | **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) | | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | | **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook | -| **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | +| **Codex** | `rtk init -g --codex` | PreToolUse hook + AGENTS.md fallback instructions | | **Windsurf** | `rtk init -g --agent windsurf` | .windsurfrules (project-scoped) | | **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) | | **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) | diff --git a/docs/contributing/TECHNICAL.md b/docs/contributing/TECHNICAL.md index ddadf8d73c..d8c015d5c2 100644 --- a/docs/contributing/TECHNICAL.md +++ b/docs/contributing/TECHNICAL.md @@ -315,7 +315,7 @@ Start here, then drill down into each README for file-level details. | [`cursor/`](../hooks/cursor/README.md) | Cursor IDE | Shell hook, empty JSON response requirement | | [`cline/`](../hooks/cline/README.md) | Cline / Roo Code | Rules file (prompt-level, no programmatic hook) | | [`windsurf/`](../hooks/windsurf/README.md) | Windsurf / Cascade | Rules file (workspace-scoped) | -| [`codex/`](../hooks/codex/README.md) | OpenAI Codex CLI | Awareness document, AGENTS.md integration | +| [`codex/`](../hooks/codex/README.md) | OpenAI Codex CLI | Rust binary hook, hooks.json, AGENTS.md fallback guidance | | [`opencode/`](../hooks/opencode/README.md) | OpenCode | TypeScript plugin, zx library, in-place mutation | --- @@ -333,7 +333,7 @@ RTK supports the following LLM agents through hook integrations: | Gemini CLI | Rust binary | `rtk hook gemini` reads JSON | Yes (`hookSpecificOutput`) | | Cline/Roo Code | Rules file | Prompt-level guidance | N/A (prompt) | | Windsurf | Rules file | Prompt-level guidance | N/A (prompt) | -| Codex CLI | Awareness doc | AGENTS.md integration | N/A (prompt) | +| Codex CLI | Rust binary | `rtk hook codex` reads `PreToolUse` JSON | Yes (`updatedInput` with `permissionDecision: "allow"`) | | OpenCode | TS plugin | `tool.execute.before` event | Yes (in-place mutation) | > **Details**: [`hooks/README.md`](../hooks/README.md) has the full JSON schemas for each agent. [`src/hooks/README.md`](../src/hooks/README.md) covers installation, integrity verification, and the rewrite command. diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index d5a0ad87c7..249c2c37b9 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -39,7 +39,7 @@ Agent runs "cargo test" | Hermes | Python plugin (`terminal` command mutation) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | -| Codex CLI | AGENTS.md instructions | N/A | +| Codex CLI | PreToolUse hook + AGENTS.md fallback instructions | Yes | | Kilo Code | Rules file (prompt-level) | N/A | | Google Antigravity | Rules file (prompt-level) | N/A | | Mistral Vibe | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Pending upstream | @@ -154,8 +154,8 @@ rtk init --global --agent windsurf # creates .windsurfrules in current projec ### Codex CLI ```bash -rtk init --codex # project-scoped (AGENTS.md) -rtk init --global --codex # user-global (~/.codex/AGENTS.md) +rtk init --codex # project-scoped (AGENTS.md + .codex/hooks.json) +rtk init --global --codex # user-global (~/.codex/AGENTS.md + hooks.json) ``` ### Kilo Code @@ -186,7 +186,7 @@ Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https:// | **Plugin** | TypeScript, JavaScript, or Python in agent's plugin system | Transparent, in-place mutation when the agent allows it | | **Rules file** | Prompt-level instructions | Guidance only — agent is told to prefer `rtk ` | -Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API. +Rules file integrations (Cline, Windsurf, Kilo Code, Antigravity) rely on the model following instructions. Full hook integrations (Claude Code, Cursor, Gemini, Codex) are guaranteed — the command is rewritten before the agent sees it. Plugin integrations (OpenCode, Pi) use in-place mutation via the agent's TypeScript extension API. ## Windows support diff --git a/hooks/README.md b/hooks/README.md index 55b2149ddf..246ff759e3 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -38,7 +38,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`cursor/`](cursor/README.md)** — Shell hook, Cursor JSON format, empty `{}` response requirement - **[`cline/`](cline/README.md)** — Rules file (prompt-level), `.clinerules` project-local installation - **[`windsurf/`](windsurf/README.md)** — Rules file (prompt-level), `.windsurfrules` workspace-scoped -- **[`codex/`](codex/README.md)** — Awareness document, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location +- **[`codex/`](codex/README.md)** — Rust binary hook, Codex `PreToolUse` JSON, `hooks.json`, `AGENTS.md` fallback guidance - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation - **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, `isToolCallEventType` guard, in-place mutation, `~/.pi/agent/extensions/` - **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation @@ -54,7 +54,7 @@ Each agent subdirectory has its own README with hook-specific details: | Gemini CLI | Rust binary (`rtk hook gemini`) | Transparent rewrite | Yes (`hookSpecificOutput`) | | Cline / Roo Code | Custom instructions (rules file) | Prompt-level guidance | N/A | | Windsurf | Custom instructions (rules file) | Prompt-level guidance | N/A | -| Codex CLI | AGENTS.md / instructions | Prompt-level guidance | N/A | +| Codex CLI | Rust binary (`rtk hook codex`) | Transparent rewrite | Yes (`updatedInput`) | | OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes | | Pi | TypeScript extension (`tool_call` event) | In-place mutation | Yes | | Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | diff --git a/hooks/codex/README.md b/hooks/codex/README.md index 50030e958a..57cca51505 100644 --- a/hooks/codex/README.md +++ b/hooks/codex/README.md @@ -4,6 +4,30 @@ ## Specifics -- Prompt-level guidance via awareness document -- no programmatic hook -- `rtk-awareness.md` is injected into `AGENTS.md` with an `@RTK.md` reference -- Installed to `$CODEX_HOME` when set, otherwise `~/.codex/`, by `rtk init --codex` +- Command-based `PreToolUse` hook via Codex `hooks.json` +- Returns `hookSpecificOutput.updatedInput.command` with `permissionDecision: "allow"` so Codex applies the rewritten shell command before execution +- `permissionDecision: "deny"` is used only for explicit RTK deny rules and does not include `updatedInput` +- `rtk-awareness.md` is still injected into `AGENTS.md` with an `@RTK.md` reference as prompt-level backup guidance +- Installed locally to `.codex/hooks.json` by `rtk init --codex`, or globally to `$CODEX_HOME/hooks.json` / `~/.codex/hooks.json` by `rtk init -g --codex` + +## Hook entry + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": "rtk hook codex", + "timeout": 10, + "statusMessage": "RTK rewriting shell command" + } + ] + } + ] + } +} +``` diff --git a/src/hooks/README.md b/src/hooks/README.md index a0c76b76de..574806cb3b 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -28,7 +28,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md | | Windsurf | `rtk init -g --agent windsurf` | `.windsurfrules` | -- | | Cline | `rtk init --agent cline` | `.clinerules` | -- | -| Codex | `rtk init --codex` | RTK.md in `$CODEX_HOME` or `~/.codex` | AGENTS.md | +| Codex | `rtk init --codex` | RTK.md + `.codex/hooks.json` | AGENTS.md + hooks.json | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | | Pi | `rtk init --agent pi` | `.pi/extensions/rtk.ts` | -- | | Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | @@ -88,13 +88,13 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i | Copilot VS Code (rtk hook copilot) | Yes | `permissionDecision: "ask"` — user prompted | | Gemini CLI (rtk hook gemini) | No (allow/deny only) | allow (limitation — no ask mode in Gemini) | | Copilot CLI (rtk hook copilot) | No updatedInput | deny-with-suggestion (unchanged) | -| Codex | ask parsed but no-op | allow (limitation — fails open) | +| Codex CLI (rtk hook codex) | No native ask rewrite | `permissionDecision: "allow"` + `updatedInput` so Codex applies the rewrite | ### Implementation - `permissions.rs` — loads deny/ask/allow rules, evaluates precedence, returns `PermissionVerdict` - `rewrite_cmd.rs` — maps verdict to exit code (consumed by shell hook) -- `hook_cmd.rs` — maps verdict to JSON `permissionDecision` field (Copilot/Gemini) +- `hook_cmd.rs` — maps verdict to JSON `permissionDecision` field (Copilot/Gemini/Codex) ## Exit Code Contract diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 23d2e10899..26fa98708b 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -10,6 +10,8 @@ pub const BEFORE_TOOL_KEY: &str = "BeforeTool"; /// Native Rust hook command for Claude Code (replaces rtk-rewrite.sh). pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; +/// Native Rust hook command for Codex CLI. +pub const CODEX_HOOK_COMMAND: &str = "rtk hook codex"; /// Native Rust hook command for Cursor (replaces rtk-rewrite.sh). pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 953080fdfa..df39fa3d49 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -332,6 +332,10 @@ enum PayloadAction { rewritten: String, output: Value, }, + Block { + cmd: String, + output: Value, + }, Skip { reason: &'static str, cmd: String, @@ -420,6 +424,10 @@ pub fn run_claude() -> Result<()> { audit_log("rewrite", &cmd, &rewritten); let _ = writeln!(io::stdout(), "{output}"); } + PayloadAction::Block { cmd, output } => { + audit_log("deny", &cmd, ""); + let _ = writeln!(io::stdout(), "{output}"); + } PayloadAction::Skip { reason, cmd } => { audit_log(reason, &cmd, ""); } @@ -434,10 +442,127 @@ fn run_claude_inner(input: &str) -> Option { let v: Value = serde_json::from_str(input).ok()?; match process_claude_payload(&v) { PayloadAction::Rewrite { output, .. } => Some(output.to_string()), + PayloadAction::Block { output, .. } => Some(output.to_string()), _ => None, } } +// ── Codex CLI native hook ────────────────────────────────────── + +fn process_codex_payload(v: &Value) -> PayloadAction { + let cmd = match v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + Some(c) => c, + None => return PayloadAction::Ignore, + }; + + process_codex_command( + cmd, + permissions::check_command_for(cmd, permissions::Host::Codex), + ) +} + +#[cfg(test)] +fn process_codex_payload_with_rules( + v: &Value, + deny: &[String], + ask: &[String], + allow: &[String], +) -> PayloadAction { + let cmd = match v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + Some(c) => c, + None => return PayloadAction::Ignore, + }; + + process_codex_command( + cmd, + permissions::check_command_with_rules(cmd, deny, ask, allow), + ) +} + +fn process_codex_command(cmd: &str, verdict: PermissionVerdict) -> PayloadAction { + match decide_from_verdict(cmd, verdict) { + HookDecision::Deny => PayloadAction::Block { + cmd: cmd.to_string(), + output: json!({ + "hookSpecificOutput": { + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecision": "deny", + "permissionDecisionReason": "Blocked by RTK permission rule" + } + }), + }, + HookDecision::Defer => PayloadAction::Skip { + reason: "skip:defer", + cmd: cmd.to_string(), + }, + HookDecision::AllowRewrite(rewritten) | HookDecision::AskRewrite(rewritten) => { + codex_rewrite_action(cmd, rewritten) + } + } +} + +fn codex_rewrite_action(cmd: &str, rewritten: String) -> PayloadAction { + PayloadAction::Rewrite { + cmd: cmd.to_string(), + rewritten: rewritten.clone(), + output: json!({ + "hookSpecificOutput": { + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { "command": rewritten } + } + }), + } +} + +/// Run the Codex CLI PreToolUse hook natively. +pub fn run_codex() -> Result<()> { + let input = read_stdin_limited()?; + + let input = strip_leading_bom(&input).trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}"); + return Ok(()); + } + }; + + match process_codex_payload(&v) { + PayloadAction::Rewrite { + cmd, + rewritten, + output, + } => { + audit_log("rewrite", &cmd, &rewritten); + let _ = writeln!(io::stdout(), "{output}"); + } + PayloadAction::Block { cmd, output } => { + audit_log("deny", &cmd, ""); + let _ = writeln!(io::stdout(), "{output}"); + } + PayloadAction::Skip { reason, cmd } => { + audit_log(reason, &cmd, ""); + } + PayloadAction::Ignore => {} + } + + Ok(()) +} + // ── Cursor native hook ───────────────────────────────────────── /// Cursor on Windows ships hook payloads with one or more leading @@ -1018,6 +1143,87 @@ mod tests { assert!(run_claude_inner(&input).is_none()); } + // --- Codex handler --- + + fn codex_input(cmd: &str) -> String { + json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { "command": cmd }, + "tool_use_id": "call_123", + "session_id": "session_123", + "turn_id": "turn_123", + "cwd": "/tmp", + "transcript_path": null, + "model": "gpt-5.1-codex", + "permission_mode": "default" + }) + .to_string() + } + + fn run_codex_with_rules( + input: &str, + deny: &[String], + ask: &[String], + allow: &[String], + ) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + match process_codex_payload_with_rules(&v, deny, ask, allow) { + PayloadAction::Rewrite { output, .. } => Some(output.to_string()), + PayloadAction::Block { output, .. } => Some(output.to_string()), + _ => None, + } + } + + #[test] + fn test_codex_allow_rewrites_with_required_allow_decision() { + let result = run_codex_with_rules(&codex_input("git status"), &[], &[], &["*".into()]) + .expect("allow rule should transparently rewrite"); + let v: Value = serde_json::from_str(&result).unwrap(); + let hook = &v["hookSpecificOutput"]; + + assert_eq!(hook["hookEventName"], PRE_TOOL_USE_KEY); + assert_eq!(hook["permissionDecision"], "allow"); + assert_eq!(hook["permissionDecisionReason"], "RTK auto-rewrite"); + assert_eq!(hook["updatedInput"]["command"], "rtk git status"); + } + + #[test] + fn test_codex_default_rewrites_without_context_pollution() { + let result = run_codex_with_rules(&codex_input("git status"), &[], &[], &[]) + .expect("default ask semantics should still rewrite for Codex"); + let v: Value = serde_json::from_str(&result).unwrap(); + let hook = &v["hookSpecificOutput"]; + + assert_eq!(hook["permissionDecision"], "allow"); + assert_eq!(hook["updatedInput"]["command"], "rtk git status"); + assert!(hook.get("additionalContext").is_none()); + } + + #[test] + fn test_codex_deny_returns_codex_deny_payload() { + let result = + run_codex_with_rules(&codex_input("git status"), &["git status".into()], &[], &[]) + .expect("deny rule should emit a Codex deny payload"); + let v: Value = serde_json::from_str(&result).unwrap(); + let hook = &v["hookSpecificOutput"]; + + assert_eq!(hook["hookEventName"], PRE_TOOL_USE_KEY); + assert_eq!(hook["permissionDecision"], "deny"); + assert_eq!( + hook["permissionDecisionReason"], + "Blocked by RTK permission rule" + ); + assert!(hook.get("updatedInput").is_none()); + } + + #[test] + fn test_codex_passthrough_no_output() { + assert!(run_codex_with_rules(&codex_input("htop"), &[], &[], &[]).is_none()); + assert!(run_codex_with_rules(&codex_input("rtk git status"), &[], &[], &[]).is_none()); + assert!(run_codex_with_rules("not valid json {{{", &[], &[], &[]).is_none()); + } + // --- Cursor handler --- fn cursor_input(cmd: &str) -> String { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 28363f4ce6..cf27180af8 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,11 +13,11 @@ use crate::hooks::constants::{ }; use super::constants::{ - BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, - GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, - HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, - PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, - PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, + BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CODEX_HOOK_COMMAND, + CURSOR_HOOK_COMMAND, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, + HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, + HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, + PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -273,13 +273,7 @@ pub fn run( if hook_only { anyhow::bail!("--codex cannot be combined with --hook-only"); } - if matches!(patch_mode, PatchMode::Auto) { - anyhow::bail!("--codex cannot be combined with --auto-patch"); - } - if matches!(patch_mode, PatchMode::Skip) { - anyhow::bail!("--codex cannot be combined with --no-patch"); - } - run_codex_mode(global, ctx)?; + run_codex_mode(global, patch_mode, ctx)?; } else { // Validation: Global-only features if install_opencode && !global { @@ -925,6 +919,37 @@ fn uninstall_codex_at(codex_dir: &Path, ctx: InitContext) -> Result> removed.push("AGENTS.md: removed @RTK.md reference".to_string()); } + let hooks_json_path = codex_dir.join(HOOKS_JSON); + if hooks_json_path.exists() { + let content = fs::read_to_string(&hooks_json_path) + .with_context(|| format!("Failed to read {}", hooks_json_path.display()))?; + + if !content.trim().is_empty() { + let mut root: serde_json::Value = + serde_json::from_str(&content).with_context(|| { + format!("Failed to parse {} as JSON", hooks_json_path.display()) + })?; + if remove_codex_hook_from_json(&mut root) { + if dry_run { + println!( + "[dry-run] would remove RTK entry from Codex hooks.json: {}", + hooks_json_path.display() + ); + } else { + let backup_path = hooks_json_path.with_extension("json.bak"); + fs::copy(&hooks_json_path, &backup_path).ok(); + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize hooks.json")?; + atomic_write(&hooks_json_path, &serialized)?; + if verbose > 0 { + eprintln!("Removed RTK hook from Codex hooks.json"); + } + } + removed.push("hooks.json: removed RTK PreToolUse hook".to_string()); + } + } + } + Ok(removed) } @@ -2256,21 +2281,38 @@ fn normalized_yaml_scalar(value: &str) -> Option { (!trimmed.is_empty()).then(|| trimmed.to_string()) } -fn run_codex_mode(global: bool, ctx: InitContext) -> Result<()> { - let (agents_md_path, rtk_md_path) = if global { +fn run_codex_mode(global: bool, patch_mode: PatchMode, ctx: InitContext) -> Result<()> { + let (agents_md_path, rtk_md_path, hooks_json_path) = if global { let codex_dir = resolve_codex_dir()?; - (codex_dir.join(AGENTS_MD), codex_dir.join(RTK_MD)) + ( + codex_dir.join(AGENTS_MD), + codex_dir.join(RTK_MD), + codex_dir.join(HOOKS_JSON), + ) } else { - (PathBuf::from(AGENTS_MD), PathBuf::from(RTK_MD)) + ( + PathBuf::from(AGENTS_MD), + PathBuf::from(RTK_MD), + PathBuf::from(".codex").join(HOOKS_JSON), + ) }; - run_codex_mode_with_paths(agents_md_path, rtk_md_path, global, ctx) + run_codex_mode_with_paths( + agents_md_path, + rtk_md_path, + hooks_json_path, + global, + patch_mode, + ctx, + ) } fn run_codex_mode_with_paths( agents_md_path: PathBuf, rtk_md_path: PathBuf, + hooks_json_path: PathBuf, global: bool, + patch_mode: PatchMode, ctx: InitContext, ) -> Result<()> { let InitContext { dry_run, .. } = ctx; @@ -2300,6 +2342,7 @@ fn run_codex_mode_with_paths( write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, RTK_MD, ctx)?; let added_ref = patch_agents_md(&agents_md_path, &rtk_md_ref, ctx)?; + let hook_result = patch_codex_hooks_json(&hooks_json_path, patch_mode, ctx)?; if !dry_run { println!("\nRTK configured for Codex CLI.\n"); @@ -2309,6 +2352,16 @@ fn run_codex_mode_with_paths( } else { println!(" AGENTS.md: {} reference already present", rtk_md_ref); } + println!(" hooks.json: {}", hooks_json_path.display()); + match hook_result { + PatchResult::Patched => println!(" hooks.json: RTK PreToolUse hook added"), + PatchResult::AlreadyPresent => { + println!(" hooks.json: RTK PreToolUse hook already present") + } + PatchResult::Declined => println!(" hooks.json: hook patch declined"), + PatchResult::Skipped => println!(" hooks.json: hook patch skipped"), + PatchResult::WouldPatch => {} + } if global { println!( "\n Codex global instructions path: {}", @@ -2320,6 +2373,7 @@ fn run_codex_mode_with_paths( agents_md_path.display() ); } + println!(" Restart Codex CLI. Test with: git status"); } Ok(()) @@ -2621,6 +2675,206 @@ fn patch_agents_md(path: &Path, rtk_md_ref: &str, ctx: InitContext) -> Result Result { + let InitContext { verbose, dry_run } = ctx; + let mut root = if path.exists() { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + if content.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {} as JSON", path.display()))? + } + } else { + serde_json::json!({}) + }; + + if codex_hook_already_present(&root) { + if verbose > 0 { + eprintln!("Codex hooks.json: RTK hook already present"); + } + return Ok(PatchResult::AlreadyPresent); + } + + match mode { + PatchMode::Skip => { + print_codex_manual_instructions(CODEX_HOOK_COMMAND); + return Ok(PatchResult::Skipped); + } + PatchMode::Ask => { + if dry_run { + println!( + "[dry-run] would prompt before patching Codex hooks.json: {}", + path.display() + ); + } else if !prompt_user_consent(path)? { + print_codex_manual_instructions(CODEX_HOOK_COMMAND); + return Ok(PatchResult::Declined); + } + } + PatchMode::Auto => {} + } + + remove_codex_legacy_hooks_from_json(&mut root); + insert_codex_hook_entry(&mut root)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + + if dry_run { + println!("[dry-run] would patch Codex hooks.json: {}", path.display()); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(PatchResult::WouldPatch); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create Codex hooks directory: {}", + parent.display() + ) + })?; + } + + if path.exists() { + let backup_path = path.with_extension("json.bak"); + fs::copy(path, &backup_path) + .with_context(|| format!("Failed to backup to {}", backup_path.display()))?; + if verbose > 0 { + eprintln!("Backup: {}", backup_path.display()); + } + } + + atomic_write(path, &serialized)?; + Ok(PatchResult::Patched) +} + +fn codex_hook_already_present(root: &serde_json::Value) -> bool { + let hooks = match root + .get("hooks") + .and_then(|h| h.get(PRE_TOOL_USE_KEY)) + .and_then(|p| p.as_array()) + { + Some(arr) => arr, + None => return false, + }; + + hooks + .iter() + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() + .filter_map(|hook| hook.get("command")?.as_str()) + .any(|cmd| cmd == CODEX_HOOK_COMMAND) +} + +fn insert_codex_hook_entry(root: &mut serde_json::Value) -> Result<()> { + let root_obj = match root.as_object_mut() { + Some(obj) => obj, + None => { + *root = serde_json::json!({}); + root.as_object_mut().expect("just-created json object") + } + }; + + let hooks = root_obj + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .context("hooks value is not an object")?; + + let pre_tool_use = hooks + .entry(PRE_TOOL_USE_KEY) + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .context("PreToolUse value is not an array")?; + + pre_tool_use.push(serde_json::json!({ + "matcher": "^Bash$", + "hooks": [{ + "type": "command", + "command": CODEX_HOOK_COMMAND, + "timeout": 10, + "statusMessage": "RTK rewriting shell command" + }] + })); + + Ok(()) +} + +fn remove_codex_hook_from_json(root: &mut serde_json::Value) -> bool { + remove_codex_matching_hooks_from_json(root, |cmd| { + cmd == CODEX_HOOK_COMMAND || is_codex_legacy_hook_command(cmd) + }) +} + +fn remove_codex_legacy_hooks_from_json(root: &mut serde_json::Value) -> bool { + remove_codex_matching_hooks_from_json(root, is_codex_legacy_hook_command) +} + +fn is_codex_legacy_hook_command(cmd: &str) -> bool { + cmd == CLAUDE_HOOK_COMMAND || cmd.contains(REWRITE_HOOK_FILE) +} + +fn remove_codex_matching_hooks_from_json( + root: &mut serde_json::Value, + predicate: impl Fn(&str) -> bool, +) -> bool { + let pre_tool_use = match root + .get_mut("hooks") + .and_then(|h| h.get_mut(PRE_TOOL_USE_KEY)) + .and_then(|p| p.as_array_mut()) + { + Some(arr) => arr, + None => return false, + }; + + let mut removed = false; + for entry in pre_tool_use.iter_mut() { + let Some(hooks) = entry + .get_mut("hooks") + .and_then(|hooks| hooks.as_array_mut()) + else { + continue; + }; + let original_hook_len = hooks.len(); + hooks.retain(|hook| { + !hook + .get("command") + .and_then(|command| command.as_str()) + .is_some_and(&predicate) + }); + removed |= hooks.len() < original_hook_len; + } + + let original_entry_len = pre_tool_use.len(); + pre_tool_use.retain( + |entry| match entry.get("hooks").and_then(|hooks| hooks.as_array()) { + Some(hooks) => !hooks.is_empty(), + None => true, + }, + ); + + removed || pre_tool_use.len() < original_entry_len +} + fn has_rtk_reference(content: &str, refs: &[&str]) -> bool { content .lines() @@ -3544,6 +3798,22 @@ fn show_codex_config() -> Result<()> { println!("[--] Global RTK.md: not found"); } + let global_hooks_json = codex_dir.join(HOOKS_JSON); + if global_hooks_json.exists() { + let content = fs::read_to_string(&global_hooks_json)?; + if let Ok(root) = serde_json::from_str::(&content) { + if codex_hook_already_present(&root) { + println!("[ok] Global hooks.json: RTK PreToolUse hook"); + } else { + println!("[--] Global hooks.json: exists but rtk hook not configured"); + } + } else { + println!("[!!] Global hooks.json: invalid JSON"); + } + } else { + println!("[--] Global hooks.json: not found"); + } + if global_agents_md.exists() { let content = fs::read_to_string(&global_agents_md)?; if has_rtk_reference(&content, &[RTK_MD_REF, global_rtk_md_ref.as_str()]) { @@ -3563,6 +3833,22 @@ fn show_codex_config() -> Result<()> { println!("[--] Local RTK.md: not found"); } + let local_hooks_json = PathBuf::from(".codex").join(HOOKS_JSON); + if local_hooks_json.exists() { + let content = fs::read_to_string(&local_hooks_json)?; + if let Ok(root) = serde_json::from_str::(&content) { + if codex_hook_already_present(&root) { + println!("[ok] Local hooks.json: RTK PreToolUse hook"); + } else { + println!("[--] Local hooks.json: exists but rtk hook not configured"); + } + } else { + println!("[!!] Local hooks.json: invalid JSON"); + } + } else { + println!("[--] Local hooks.json: not found"); + } + if local_agents_md.exists() { let content = fs::read_to_string(&local_agents_md)?; if has_rtk_reference(&content, &[RTK_MD_REF]) { @@ -3577,8 +3863,10 @@ fn show_codex_config() -> Result<()> { } println!("\nUsage:"); - println!(" rtk init --codex # Configure local AGENTS.md + RTK.md"); - println!(" rtk init -g --codex # Configure $CODEX_HOME/AGENTS.md + $CODEX_HOME/RTK.md (or ~/.codex/)"); + println!( + " rtk init --codex # Configure local AGENTS.md + RTK.md + .codex/hooks.json" + ); + println!(" rtk init -g --codex # Configure $CODEX_HOME/AGENTS.md + RTK.md + hooks.json (or ~/.codex/)"); println!(" rtk init -g --codex --uninstall # Remove global Codex RTK artifacts"); Ok(()) @@ -4374,47 +4662,49 @@ mod tests { } #[test] - fn test_codex_mode_rejects_auto_patch() { - let err = run( - false, - false, - false, - false, - false, - false, - false, + fn test_codex_mode_accepts_auto_patch() { + let temp = TempDir::new().unwrap(); + let agents_md = temp.path().join(AGENTS_MD); + let rtk_md = temp.path().join(RTK_MD); + let hooks_json = temp.path().join(".codex").join(HOOKS_JSON); + + run_codex_mode_with_paths( + agents_md, + rtk_md, + hooks_json.clone(), false, - true, PatchMode::Auto, InitContext::default(), ) - .unwrap_err(); + .unwrap(); + + let hooks: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap(); + assert!(codex_hook_already_present(&hooks)); assert_eq!( - err.to_string(), - "--codex cannot be combined with --auto-patch" + hooks["hooks"][PRE_TOOL_USE_KEY][0]["hooks"][0]["command"], + CODEX_HOOK_COMMAND ); } #[test] - fn test_codex_mode_rejects_no_patch() { - let err = run( - false, - false, - false, - false, - false, - false, - false, + fn test_codex_mode_accepts_no_patch() { + let temp = TempDir::new().unwrap(); + let agents_md = temp.path().join(AGENTS_MD); + let rtk_md = temp.path().join(RTK_MD); + let hooks_json = temp.path().join(".codex").join(HOOKS_JSON); + + run_codex_mode_with_paths( + agents_md, + rtk_md, + hooks_json.clone(), false, - true, PatchMode::Skip, InitContext::default(), ) - .unwrap_err(); - assert_eq!( - err.to_string(), - "--codex cannot be combined with --no-patch" - ); + .unwrap(); + + assert!(!hooks_json.exists()); } #[test] @@ -4989,11 +5279,14 @@ mod tests { let temp = TempDir::new().unwrap(); let agents_md = temp.path().join("AGENTS.md"); let rtk_md = temp.path().join("RTK.md"); + let hooks_json = temp.path().join("hooks.json"); run_codex_mode_with_paths( agents_md.clone(), rtk_md.clone(), + hooks_json.clone(), true, + PatchMode::Auto, InitContext::default(), ) .unwrap(); @@ -5004,6 +5297,9 @@ mod tests { fs::read_to_string(&agents_md).unwrap(), format!("{}\n", codex_rtk_md_ref(temp.path())) ); + let hooks: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap(); + assert!(codex_hook_already_present(&hooks)); } #[test] @@ -5092,16 +5388,23 @@ mod tests { let codex_dir = temp.path(); let agents_md = codex_dir.join("AGENTS.md"); let rtk_md = codex_dir.join("RTK.md"); + let hooks_json = codex_dir.join(HOOKS_JSON); fs::write(&agents_md, "# Team rules\n\n@RTK.md\n").unwrap(); fs::write(&rtk_md, "codex config").unwrap(); + let mut hooks = serde_json::json!({}); + insert_codex_hook_entry(&mut hooks).unwrap(); + fs::write(&hooks_json, serde_json::to_string_pretty(&hooks).unwrap()).unwrap(); let removed_first = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); let removed_second = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); - assert_eq!(removed_first.len(), 2); + assert_eq!(removed_first.len(), 3); assert!(removed_second.is_empty()); assert!(!rtk_md.exists()); + let hooks_after: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap(); + assert!(!codex_hook_already_present(&hooks_after)); let content = fs::read_to_string(&agents_md).unwrap(); assert!(!content.contains("@RTK.md")); @@ -5184,11 +5487,14 @@ mod tests { let temp = TempDir::new().unwrap(); let agents_md = temp.path().join("AGENTS.md"); let rtk_md = temp.path().join("RTK.md"); + let hooks_json = temp.path().join("hooks.json"); run_codex_mode_with_paths( agents_md.clone(), rtk_md.clone(), + hooks_json.clone(), true, + PatchMode::Auto, InitContext { dry_run: true, ..Default::default() @@ -5206,6 +5512,92 @@ mod tests { "dry-run must not create AGENTS.md: {}", agents_md.display() ); + assert!( + !hooks_json.exists(), + "dry-run must not create hooks.json: {}", + hooks_json.display() + ); + } + + #[test] + fn test_insert_codex_hook_entry_preserves_existing_hooks() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "^Bash$", + "hooks": [{"type": "command", "command": "other hook"}] + }] + } + }); + + insert_codex_hook_entry(&mut root).unwrap(); + + let pre_tool_use = root["hooks"][PRE_TOOL_USE_KEY].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 2); + assert_eq!(pre_tool_use[1]["hooks"][0]["command"], CODEX_HOOK_COMMAND); + } + + #[test] + fn test_remove_codex_hook_from_json_removes_only_rtk_hook() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + {"type": "command", "command": CODEX_HOOK_COMMAND}, + {"type": "command", "command": "same entry hook"} + ] + }, + { + "matcher": "^Bash$", + "hooks": [{"type": "command", "command": "other hook"}] + } + ] + } + }); + + assert!(remove_codex_hook_from_json(&mut root)); + let pre_tool_use = root["hooks"][PRE_TOOL_USE_KEY].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 2); + assert_eq!(pre_tool_use[0]["hooks"][0]["command"], "same entry hook"); + assert_eq!(pre_tool_use[1]["hooks"][0]["command"], "other hook"); + } + + #[test] + fn test_patch_codex_hooks_json_migrates_legacy_claude_hook() { + let temp = TempDir::new().unwrap(); + let hooks_json = temp.path().join(HOOKS_JSON); + fs::write( + &hooks_json, + serde_json::to_string_pretty(&serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [{"type": "command", "command": CLAUDE_HOOK_COMMAND}] + }, + { + "matcher": "^Bash$", + "hooks": [{"type": "command", "command": "other hook"}] + } + ] + } + })) + .unwrap(), + ) + .unwrap(); + + let result = + patch_codex_hooks_json(&hooks_json, PatchMode::Auto, InitContext::default()).unwrap(); + assert_eq!(result, PatchResult::Patched); + + let root: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap(); + let pre_tool_use = root["hooks"][PRE_TOOL_USE_KEY].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 2); + assert_eq!(pre_tool_use[0]["hooks"][0]["command"], "other hook"); + assert_eq!(pre_tool_use[1]["hooks"][0]["command"], CODEX_HOOK_COMMAND); } #[test] diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index a4f04b78bd..bff6493872 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -30,6 +30,7 @@ pub fn check_command(cmd: &str) -> PermissionVerdict { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Host { Claude, + Codex, Cursor, Gemini, } @@ -37,6 +38,7 @@ pub enum Host { pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { let (deny_rules, ask_rules, allow_rules) = match host { Host::Claude => load_permission_rules(), + Host::Codex => (Vec::new(), Vec::new(), Vec::new()), Host::Cursor => load_cursor_rules(), Host::Gemini => load_gemini_rules(), }; diff --git a/src/main.rs b/src/main.rs index a5bde544a3..e35d6c5e48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -777,6 +777,8 @@ enum Commands { enum HookCommands { /// Process Claude Code PreToolUse hook (reads JSON from stdin) Claude, + /// Process Codex CLI PreToolUse hook (reads JSON from stdin) + Codex, /// Process Cursor Agent hook (reads JSON from stdin) Cursor, /// Process Gemini CLI BeforeTool hook (reads JSON from stdin) @@ -1408,9 +1410,6 @@ where } fn run_cli() -> Result { - // Fire-and-forget telemetry ping (1/day, non-blocking) - core::telemetry::maybe_ping(); - let cli = match Cli::try_parse() { Ok(cli) => cli, Err(e) => { @@ -1421,9 +1420,17 @@ fn run_cli() -> Result { } }; + // Hook subcommands run inside agent hook pipelines and must stay quiet/fast. + let is_hook_subcommand = matches!(cli.command, Commands::Hook { .. }); + + // Fire-and-forget telemetry ping (1/day, non-blocking) + if !is_hook_subcommand { + core::telemetry::maybe_ping(); + } + // Warn if installed hook is outdated/missing (1/day, non-blocking). // Skip for Gain — it shows its own inline hook warning. - if !matches!(cli.command, Commands::Gain { .. }) { + if !matches!(cli.command, Commands::Gain { .. }) && !is_hook_subcommand { hooks::hook_check::maybe_warn(); } @@ -2210,6 +2217,10 @@ fn run_cli() -> Result { hooks::hook_cmd::run_claude()?; 0 } + HookCommands::Codex => { + hooks::hook_cmd::run_codex()?; + 0 + } HookCommands::Cursor => { hooks::hook_cmd::run_cursor()?; 0 @@ -2871,6 +2882,17 @@ mod tests { )); } + #[test] + fn test_hook_codex_parses() { + let cli = Cli::try_parse_from(["rtk", "hook", "codex"]).unwrap(); + assert!(matches!( + cli.command, + Commands::Hook { + command: HookCommands::Codex + } + )); + } + #[test] fn test_hook_check_parses() { let cli = Cli::try_parse_from(["rtk", "hook", "check", "git", "status"]).unwrap();