From 9a587795085b5dd95c4db886fa51a987e987b009 Mon Sep 17 00:00:00 2001 From: BEOKS Date: Fri, 22 May 2026 15:28:33 +0900 Subject: [PATCH] feat(codex): add PreToolUse hook support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: BEOKS --- README.md | 2 +- hooks/README.md | 2 +- hooks/codex/README.md | 6 +- src/hooks/README.md | 2 +- src/hooks/constants.rs | 2 + src/hooks/hook_cmd.rs | 169 +++++++++++++++++++++++ src/hooks/init.rs | 304 ++++++++++++++++++++++++++++++++++++++--- src/main.rs | 19 ++- 8 files changed, 480 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f8d65efe5..b8916846c 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ RTK supports 13 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 + RTK.md | | **Windsurf** | `rtk init --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/hooks/README.md b/hooks/README.md index 0879de9bb..bae0323fc 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)** — Native `PreToolUse` hook, `AGENTS.md` integration, `$CODEX_HOME` or `~/.codex/` location - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation - **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation diff --git a/hooks/codex/README.md b/hooks/codex/README.md index 50030e958..7bc995814 100644 --- a/hooks/codex/README.md +++ b/hooks/codex/README.md @@ -4,6 +4,8 @@ ## Specifics -- Prompt-level guidance via awareness document -- no programmatic hook +- Native `PreToolUse` hook via `hooks.json` +- Returns `hookSpecificOutput.updatedInput` for transparent command rewrite - `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` +- Installed to project `.codex/hooks.json` by `rtk init --codex` +- Installed to `$CODEX_HOME/hooks.json` when set, otherwise `~/.codex/hooks.json`, by `rtk init -g --codex` diff --git a/src/hooks/README.md b/src/hooks/README.md index 01a0213cb..72490ccb5 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 + PreToolUse hook in `$CODEX_HOME` or `~/.codex` | AGENTS.md + hooks.json | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | | Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 85340510c..0e2c033c0 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -12,6 +12,8 @@ pub const BEFORE_TOOL_KEY: &str = "BeforeTool"; pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; /// Native Rust hook command for Cursor (replaces rtk-rewrite.sh). pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; +/// Native Rust hook command for Codex CLI lifecycle hooks. +pub const CODEX_HOOK_COMMAND: &str = "rtk hook codex"; pub const CONFIG_DIR: &str = ".config"; pub const OPENCODE_SUBDIR: &str = "opencode"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5..d13533392 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -290,6 +290,10 @@ enum PayloadAction { rewritten: String, output: Value, }, + Block { + cmd: String, + output: Value, + }, Skip { reason: &'static str, cmd: String, @@ -379,6 +383,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, ""); } @@ -397,6 +405,138 @@ fn run_claude_inner(input: &str) -> Option { } } +// ── Codex native hook ────────────────────────────────────────── + +fn process_codex_payload_with_checker(v: &Value, check_command: F) -> PayloadAction +where + F: FnOnce(&str) -> PermissionVerdict, +{ + 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, + }; + + if check_command(cmd) == PermissionVerdict::Deny { + return PayloadAction::Block { + cmd: cmd.to_string(), + output: json!({ + "hookSpecificOutput": { + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecision": "deny", + "permissionDecisionReason": "Blocked by RTK permission rule" + } + }), + }; + } + + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => { + return PayloadAction::Skip { + reason: "skip:no_match", + cmd: cmd.to_string(), + } + } + }; + + let updated_input = { + let mut ti = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); + if let Some(obj) = ti.as_object_mut() { + obj.insert("command".into(), Value::String(rewritten.clone())); + } + ti + }; + + PayloadAction::Rewrite { + cmd: cmd.to_string(), + rewritten, + output: json!({ + "hookSpecificOutput": { + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": updated_input + } + }), + } +} + +fn process_codex_payload(v: &Value) -> PayloadAction { + process_codex_payload_with_checker(v, permissions::check_command) +} + +/// Run the Codex CLI PreToolUse hook natively. +pub fn run_codex() -> Result<()> { + let input = read_stdin_limited()?; + + let input = 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(()) +} + +#[cfg(test)] +fn run_codex_inner(input: &str) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + match process_codex_payload(&v) { + PayloadAction::Rewrite { output, .. } | PayloadAction::Block { output, .. } => { + Some(output.to_string()) + } + _ => None, + } +} + +#[cfg(test)] +fn run_codex_inner_with_rules( + input: &str, + deny_rules: &[String], + ask_rules: &[String], + allow_rules: &[String], +) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + match process_codex_payload_with_checker(&v, |cmd| { + permissions::check_command_with_rules(cmd, deny_rules, ask_rules, allow_rules) + }) { + PayloadAction::Rewrite { output, .. } | PayloadAction::Block { output, .. } => { + Some(output.to_string()) + } + _ => None, + } +} + // ── Cursor native hook ───────────────────────────────────────── /// Cursor on Windows ships hook payloads with one or more leading @@ -780,6 +920,35 @@ mod tests { assert!(run_claude_inner(&input).is_none()); } + #[test] + fn test_codex_rewrite_git_status_allows_updated_input() { + let result = run_codex_inner(&claude_input("git status")).unwrap(); + 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_deny_blocks_with_explicit_permission_decision() { + let deny = vec!["git status".to_string()]; + let result = run_codex_inner_with_rules(&claude_input("git status"), &deny, &[], &[]) + .expect("codex deny should emit blocking hook output"); + 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() || hook["updatedInput"].is_null()); + } + // --- Cursor handler --- fn cursor_input(cmd: &str) -> String { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 9af21dc57..0f45646d8 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -12,10 +12,10 @@ 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, 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, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, }; use super::integrity; @@ -903,6 +903,11 @@ 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 remove_codex_hook_from_json(&hooks_json_path, ctx)? { + removed.push("hooks.json: removed RTK PreToolUse hook".to_string()); + } + Ok(removed) } @@ -1072,6 +1077,156 @@ fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) -> Result Ok(()) } +/// Patch Codex hooks.json with the RTK PreToolUse hook. +fn patch_codex_hooks_json(path: &Path, ctx: InitContext) -> 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 Codex hooks config: {}", path.display()))?; + if content.trim().is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str(&content).with_context(|| { + format!( + "Failed to parse Codex hooks config as JSON: {}", + path.display() + ) + })? + } + } else { + serde_json::json!({}) + }; + + if codex_hook_already_present(&root) { + if verbose > 0 { + eprintln!("Codex hooks.json: hook already present"); + } + return Ok(PatchResult::AlreadyPresent); + } + + insert_codex_hook_entry(&mut root)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize Codex 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); + } + + atomic_write(path, &serialized)?; + Ok(PatchResult::Patched) +} + +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 + }] + })); + Ok(()) +} + +fn codex_hook_already_present(root: &serde_json::Value) -> bool { + let pre_tool_use_array = 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, + }; + + pre_tool_use_array + .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 remove_codex_hook_from_json(path: &Path, ctx: InitContext) -> Result { + let InitContext { verbose, dry_run } = ctx; + if !path.exists() { + return Ok(false); + } + + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read Codex hooks.json: {}", path.display()))?; + if content.trim().is_empty() { + return Ok(false); + } + + let mut root: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse Codex hooks.json: {}", path.display()))?; + if !remove_codex_hook_from_value(&mut root) { + return Ok(false); + } + + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize Codex hooks.json")?; + if dry_run { + println!( + "[dry-run] would remove RTK hook from Codex hooks.json: {}", + path.display() + ); + if verbose > 0 { + println!("[dry-run] content:\n{}", serialized); + } + return Ok(true); + } + + atomic_write(path, &serialized)?; + Ok(true) +} + +fn remove_codex_hook_from_value(root: &mut serde_json::Value) -> bool { + let Some(pre_tool_use_array) = root + .get_mut("hooks") + .and_then(|h| h.get_mut(PRE_TOOL_USE_KEY)) + .and_then(|p| p.as_array_mut()) + else { + return false; + }; + + let before = pre_tool_use_array.len(); + pre_tool_use_array.retain(|entry| { + let Some(hooks) = entry.get("hooks").and_then(|h| h.as_array()) else { + return true; + }; + !hooks + .iter() + .filter_map(|hook| hook.get("command")?.as_str()) + .any(|cmd| cmd == CODEX_HOOK_COMMAND) + }); + + pre_tool_use_array.len() != before +} + /// Check if RTK hook is already present in settings.json /// Matches on legacy rtk-rewrite.sh path OR new `rtk hook claude` command fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { @@ -2235,31 +2390,42 @@ fn normalized_yaml_scalar(value: &str) -> Option { } fn run_codex_mode(global: bool, ctx: InitContext) -> Result<()> { - let (agents_md_path, rtk_md_path) = if global { + 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_DIR).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, ctx) } fn run_codex_mode_with_paths( agents_md_path: PathBuf, rtk_md_path: PathBuf, + hooks_json_path: PathBuf, global: bool, ctx: InitContext, ) -> Result<()> { let InitContext { dry_run, .. } = ctx; - if global && !dry_run { - if let Some(parent) = agents_md_path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create Codex config directory: {}", - parent.display() - ) - })?; + if !dry_run { + for path in [&agents_md_path, &rtk_md_path, &hooks_json_path] { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create Codex config directory: {}", + parent.display() + ) + })?; + } } } @@ -2278,10 +2444,18 @@ 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, ctx)?; if !dry_run { println!("\nRTK configured for Codex CLI.\n"); println!(" RTK.md: {}", rtk_md_path.display()); + match hook_result { + PatchResult::Patched => println!(" hooks.json: PreToolUse hook added"), + PatchResult::AlreadyPresent => { + println!(" hooks.json: PreToolUse hook already present") + } + _ => {} + } if added_ref { println!(" AGENTS.md: {} reference added", rtk_md_ref); } else { @@ -3348,8 +3522,10 @@ fn show_claude_config() -> Result<()> { println!(" rtk init -g --uninstall # Remove all RTK artifacts"); println!(" rtk init -g --claude-md # Legacy: full injection into ~/.claude/CLAUDE.md"); println!(" rtk init -g --hook-only # Hook only, no RTK.md"); - 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 --opencode # OpenCode plugin only"); println!(" rtk init -g --agent cursor # Install Cursor Agent hooks"); @@ -3360,9 +3536,11 @@ fn show_codex_config() -> Result<()> { let codex_dir = resolve_codex_dir()?; let global_agents_md = codex_dir.join(AGENTS_MD); let global_rtk_md = codex_dir.join(RTK_MD); + let global_hooks_json = codex_dir.join(HOOKS_JSON); let global_rtk_md_ref = codex_rtk_md_ref(&codex_dir); let local_agents_md = PathBuf::from(AGENTS_MD); let local_rtk_md = PathBuf::from(RTK_MD); + let local_hooks_json = PathBuf::from(CODEX_DIR).join(HOOKS_JSON); println!("rtk Configuration (Codex CLI):\n"); @@ -3385,6 +3563,21 @@ fn show_codex_config() -> Result<()> { println!("[--] Global AGENTS.md: not found"); } + 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!("[warn] Global hooks.json: invalid JSON"); + } + } else { + println!("[--] Global hooks.json: not found"); + } + if local_rtk_md.exists() { println!("[ok] Local RTK.md: {}", local_rtk_md.display()); } else { @@ -3404,9 +3597,26 @@ fn show_codex_config() -> Result<()> { println!("[--] Local AGENTS.md: not found"); } + 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 .codex/hooks.json: RTK PreToolUse hook"); + } else { + println!("[--] Local .codex/hooks.json: exists but RTK hook not configured"); + } + } else { + println!("[warn] Local .codex/hooks.json: invalid JSON"); + } + } else { + println!("[--] Local .codex/hooks.json: not found"); + } + 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(()) @@ -4602,10 +4812,12 @@ 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, InitContext::default(), ) @@ -4617,6 +4829,16 @@ 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_eq!( + hooks["hooks"]["PreToolUse"][0]["matcher"], + serde_json::json!("Bash") + ); + assert_eq!( + hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + serde_json::json!("rtk hook codex") + ); } #[test] @@ -4681,6 +4903,41 @@ mod tests { assert!(content.contains("# Team rules")); } + #[test] + fn test_uninstall_codex_at_removes_hooks_json_entry() { + let temp = TempDir::new().unwrap(); + let codex_dir = temp.path(); + let hooks_json = codex_dir.join("hooks.json"); + fs::write( + &hooks_json, + serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": CODEX_HOOK_COMMAND }] + }, + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "other hook" }] + } + ] + } + }) + .to_string(), + ) + .unwrap(); + + let removed = uninstall_codex_at(codex_dir, InitContext::default()).unwrap(); + + assert!(removed.iter().any(|entry| entry.contains("hooks.json"))); + let hooks: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap(); + let pre_tool_use = hooks["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 1); + assert_eq!(pre_tool_use[0]["hooks"][0]["command"], "other hook"); + } + #[test] fn test_uninstall_codex_at_removes_absolute_reference() { let temp = TempDir::new().unwrap(); @@ -4757,10 +5014,12 @@ 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, InitContext { dry_run: true, @@ -4779,6 +5038,11 @@ 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] diff --git a/src/main.rs b/src/main.rs index c1a897190..e9e9dfcf1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -367,7 +367,7 @@ enum Commands { #[arg(long)] uninstall: bool, - /// Target Codex CLI (uses AGENTS.md + RTK.md, no Claude hook patching) + /// Target Codex CLI (uses AGENTS.md + RTK.md + PreToolUse hook) #[arg(long)] codex: bool, @@ -768,6 +768,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) @@ -2175,6 +2177,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 @@ -2836,6 +2842,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();