From 93537d3ac81111b13c11e2d24f986eb86476884b Mon Sep 17 00:00:00 2001 From: Jin Ku Date: Sat, 28 Mar 2026 07:39:33 -0700 Subject: [PATCH] feat(codex): add native hooks integration Signed-off-by: Jin Ku --- CHANGELOG.md | 4 + README.md | 23 +- hooks/codex/README.md | 15 +- hooks/codex/rtk-awareness.md | 4 + hooks/codex/test-rtk-rewrite.sh | 148 ++++++ src/hooks/hook_cmd.rs | 119 ++++- src/hooks/init.rs | 875 +++++++++++++++++++++++++++++--- src/main.rs | 7 +- 8 files changed, 1117 insertions(+), 78 deletions(-) create mode 100644 hooks/codex/test-rtk-rewrite.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9f02b2..6b93c7ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Features + +* **codex:** add native Codex hook integration with `PreToolUse` deny-and-retry, `CODEX_HOME` support, and Windows prompt-only fallback + ### Bug Fixes * **diff:** correct truncation overflow count in condense_unified_diff ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([5399f83](https://github.com/rtk-ai/rtk/commit/5399f83)) diff --git a/README.md b/README.md index 05f77de1..cea2c340 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,10 @@ rtk init --agent windsurf # Windsurf rtk init --agent cline # Cline / Roo Code # 2. Restart your AI tool, then test -git status # Automatically rewritten to rtk git status +git status # Hook rewrites it or suggests `rtk git status`, depending on the tool ``` -The hook transparently rewrites Bash commands (e.g., `git status` -> `rtk git status`) before execution. Claude never sees the rewrite, it just gets compressed output. +Hooks either transparently rewrite Bash commands (for example `git status` -> `rtk git status`) or, when a harness cannot update the command input yet, block the raw command and tell the model to retry with the exact `rtk ...` replacement. **Important:** the hook only runs on Bash tool calls. Claude Code built-in tools like `Read`, `Grep`, and `Glob` do not pass through the Bash hook, so they are not auto-rewritten. To get RTK's compact output for those workflows, use shell commands (`cat`/`head`/`tail`, `rg`/`grep`, `find`) or call `rtk read`, `rtk grep`, or `rtk find` directly. @@ -305,13 +305,15 @@ RTK supports 10 AI coding tools. Each integration transparently rewrites shell c | **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 (`rtk hook gemini`) | -| **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions | +| **Codex** | `rtk init -g --codex` | PreToolUse deny-with-suggestion (`.codex/hooks.json` + `.codex/config.toml`) | | **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) | | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | | **Mistral Vibe** | Planned (#800) | Blocked on upstream BeforeToolCallback | +Codex on Windows currently falls back to prompt-only setup because upstream Codex does not run lifecycle hooks there. + ### Claude Code (default) ```bash @@ -354,9 +356,20 @@ Creates `~/.gemini/hooks/rtk-hook-gemini.sh` + patches `~/.gemini/settings.json` ```bash rtk init -g --codex +rtk init --codex +rtk init -g --codex --uninstall ``` -Creates `~/.codex/RTK.md` + `~/.codex/AGENTS.md` with `@RTK.md` reference. Codex reads these as global instructions. +On macOS and Linux, global install writes `${CODEX_HOME:-~/.codex}/RTK.md`, `${CODEX_HOME:-~/.codex}/AGENTS.md`, `${CODEX_HOME:-~/.codex}/config.toml`, and `${CODEX_HOME:-~/.codex}/hooks.json`. Project-scoped install writes the same files under `./.codex/`. + +RTK enables `features.codex_hooks = true` and installs a `PreToolUse` Bash hook that runs `rtk hook codex`. + +Codex does not support transparent `updatedInput` rewrites yet, so supported raw Bash commands are denied with the exact `rtk ...` replacement instead of being silently rewritten. + +Notes: +- If `CODEX_HOME` is set, `rtk init -g --codex` uses that directory instead of `~/.codex`. +- On Windows, RTK falls back to `RTK.md` + `AGENTS.md` instructions only because Codex lifecycle hooks are currently disabled upstream. +- Project-scoped `.codex/` installs only activate when Codex trusts the project. ### Windsurf @@ -457,7 +470,7 @@ FAILED: 2/15 tests ### Uninstall ```bash -rtk init -g --uninstall # Remove hook, RTK.md, settings.json entry +rtk init -g --uninstall # Remove RTK-managed hook, RTK.md, and harness config entry cargo uninstall rtk # Remove binary brew uninstall rtk # If installed via Homebrew ``` diff --git a/hooks/codex/README.md b/hooks/codex/README.md index e922e636..7e3082db 100644 --- a/hooks/codex/README.md +++ b/hooks/codex/README.md @@ -4,6 +4,15 @@ ## 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/` by `rtk init --codex` +- Awareness document is injected into `AGENTS.md` with an `@RTK.md` reference +- On macOS and Linux, `rtk init --codex` also installs `config.toml` and `hooks.json` so Codex can run `rtk hook codex` for `PreToolUse` +- Codex currently uses deny-and-retry rather than transparent rewrite because `updatedInput` is not supported yet +- On Windows, RTK falls back to prompt-only guidance because Codex lifecycle hooks are disabled upstream +- Global install goes to `${CODEX_HOME:-~/.codex}` +- Project installs only activate for trusted projects + +## Test + +```bash +bash hooks/codex/test-rtk-rewrite.sh +``` diff --git a/hooks/codex/rtk-awareness.md b/hooks/codex/rtk-awareness.md index 7ae285e1..341bea7f 100644 --- a/hooks/codex/rtk-awareness.md +++ b/hooks/codex/rtk-awareness.md @@ -6,6 +6,10 @@ Always prefix shell commands with `rtk`. +If Codex hooks are enabled, raw supported Bash commands may be blocked with a message telling you to rerun the `rtk ...` form. Follow that guidance rather than retrying the raw command. + +On Windows, or when Codex does not load project hooks such as untrusted local `.codex` config, this prompt guidance may be the only active RTK layer. + Examples: ```bash diff --git a/hooks/codex/test-rtk-rewrite.sh b/hooks/codex/test-rtk-rewrite.sh new file mode 100644 index 00000000..c04c30bd --- /dev/null +++ b/hooks/codex/test-rtk-rewrite.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Test suite for rtk hook codex. +# Feeds mock Codex PreToolUse JSON through `rtk hook codex` and verifies deny/pass-through behavior. +# +# Usage: bash hooks/codex/test-rtk-rewrite.sh + +RTK="${RTK:-rtk}" +PASS=0 +FAIL=0 +TOTAL=0 + +GREEN='\033[32m' +RED='\033[31m' +DIM='\033[2m' +RESET='\033[0m' + +codex_bash_input() { + local cmd="$1" + jq -cn --arg cmd "$cmd" '{"tool_name":"Bash","tool_input":{"command":$cmd}}' +} + +non_bash_input() { + jq -cn '{"tool_name":"Edit","tool_input":{"command":"git status"}}' +} + +test_deny() { + local description="$1" + local input_cmd="$2" + local expected_rtk="$3" + TOTAL=$((TOTAL + 1)) + + local output + output=$(codex_bash_input "$input_cmd" | "$RTK" hook codex 2>/dev/null) || true + + local decision reason + decision=$(echo "$output" | jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null) + reason=$(echo "$output" | jq -r '.hookSpecificOutput.permissionDecisionReason // empty' 2>/dev/null) + + if [ "$decision" = "deny" ] && echo "$reason" | grep -qF "$expected_rtk"; then + printf " ${GREEN}DENY${RESET} %s ${DIM}→ %s${RESET}\n" "$description" "$expected_rtk" + PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected decision: deny, reason containing: %s\n" "$expected_rtk" + printf " actual decision: %s\n" "$decision" + printf " actual reason: %s\n" "$reason" + FAIL=$((FAIL + 1)) + fi +} + +test_allow() { + local description="$1" + local input="$2" + TOTAL=$((TOTAL + 1)) + + local output + output=$(echo "$input" | "$RTK" hook codex 2>/dev/null) || true + + if [ -z "$output" ]; then + printf " ${GREEN}PASS${RESET} %s ${DIM}→ (no output)${RESET}\n" "$description" + PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${RESET} %s\n" "$description" + printf " expected: (no output)\n" + printf " actual: %s\n" "$output" + FAIL=$((FAIL + 1)) + fi +} + +echo "============================================" +echo " RTK Codex Hook Test Suite" +echo "============================================" +echo "" + +echo "--- Deny with RTK suggestion ---" + +test_deny "git status" \ + "git status" \ + "rtk git status" + +test_deny "cargo test" \ + "cargo test" \ + "rtk cargo test" + +test_deny "gh pr list" \ + "gh pr list" \ + "rtk gh" + +echo "" +echo "--- Pass-through ---" + +test_allow "already rtk" \ + "$(codex_bash_input "rtk git status")" + +test_allow "heredoc" \ + "$(codex_bash_input "cat <<'EOF' +hello +EOF")" + +test_allow "unknown command" \ + "$(codex_bash_input "htop")" + +test_allow "non-bash tool" \ + "$(non_bash_input)" + +echo "" +echo "--- Output format ---" + +TOTAL=$((TOTAL + 1)) +raw_output=$(codex_bash_input "git status" | "$RTK" hook codex 2>/dev/null) +if echo "$raw_output" | jq . >/dev/null 2>&1; then + printf " ${GREEN}PASS${RESET} Codex: output is valid JSON\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Codex: output is not valid JSON: %s\n" "$raw_output" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +decision=$(echo "$raw_output" | jq -r '.hookSpecificOutput.permissionDecision') +if [ "$decision" = "deny" ]; then + printf " ${GREEN}PASS${RESET} Codex: hookSpecificOutput.permissionDecision == \"deny\"\n" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Codex: expected \"deny\", got \"%s\"\n" "$decision" + FAIL=$((FAIL + 1)) +fi + +TOTAL=$((TOTAL + 1)) +reason=$(echo "$raw_output" | jq -r '.hookSpecificOutput.permissionDecisionReason') +if echo "$reason" | grep -qE '`rtk [^`]+`'; then + printf " ${GREEN}PASS${RESET} Codex: reason contains backtick-quoted rtk command ${DIM}→ %s${RESET}\n" "$reason" + PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${RESET} Codex: reason missing backtick-quoted command: %s\n" "$reason" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "============================================" +if [ $FAIL -eq 0 ]; then + printf " ${GREEN}ALL $TOTAL TESTS PASSED${RESET}\n" +else + printf " ${RED}$FAIL FAILED${RESET} / $TOTAL total ($PASS passed)\n" +fi +echo "============================================" + +exit $FAIL diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 8eb4e2fa..be263750 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -46,6 +46,32 @@ pub fn run_copilot() -> Result<()> { } } +/// Run the Codex CLI PreToolUse hook. +/// Codex currently supports deny/block for Bash commands but fails open on +/// updatedInput and permissionDecision:allow, so this adapter is deny-only. +pub fn run_codex() -> Result<()> { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("Failed to read stdin")?; + + let input = input.trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(_) => return Ok(()), + }; + + let Some(command) = detect_codex_command(&v) else { + return Ok(()); + }; + + handle_codex(&command) +} + fn detect_format(v: &Value) -> HookFormat { // VS Code Copilot Chat / Claude Code: snake_case keys if let Some(tool_name) = v.get("tool_name").and_then(|t| t.as_str()) { @@ -104,6 +130,24 @@ fn get_rewritten(cmd: &str) -> Option { Some(rewritten) } +fn deny_reason(rewritten: &str) -> String { + format!( + "Token savings: use `{}` instead (rtk saves 60-90% tokens)", + rewritten + ) +} + +fn detect_codex_command(v: &Value) -> Option { + (v.get("tool_name").and_then(|t| t.as_str()) == Some("Bash")) + .then(|| { + v.pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + .map(str::to_string) + }) + .flatten() +} + fn handle_vscode(cmd: &str) -> Result<()> { let rewritten = match get_rewritten(cmd) { Some(r) => r, @@ -130,10 +174,24 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { let output = json!({ "permissionDecision": "deny", - "permissionDecisionReason": format!( - "Token savings: use `{}` instead (rtk saves 60-90% tokens)", - rewritten - ) + "permissionDecisionReason": deny_reason(&rewritten) + }); + println!("{output}"); + Ok(()) +} + +fn handle_codex(cmd: &str) -> Result<()> { + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => return Ok(()), + }; + + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": deny_reason(&rewritten) + } }); println!("{output}"); Ok(()) @@ -212,6 +270,13 @@ mod tests { json!({ "toolName": "bash", "toolArgs": args }) } + fn codex_input(cmd: &str) -> Value { + json!({ + "tool_name": "Bash", + "tool_input": { "command": cmd } + }) + } + #[test] fn test_detect_vscode_bash() { assert!(matches!( @@ -247,6 +312,26 @@ mod tests { assert!(matches!(detect_format(&json!({})), HookFormat::PassThrough)); } + #[test] + fn test_detect_codex_bash() { + assert_eq!( + detect_codex_command(&codex_input("git status")), + Some("git status".to_string()) + ); + } + + #[test] + fn test_detect_codex_non_bash_is_passthrough() { + let v = json!({ "tool_name": "Edit", "tool_input": { "command": "git status" } }); + assert_eq!(detect_codex_command(&v), None); + } + + #[test] + fn test_detect_codex_empty_command_is_passthrough() { + let v = json!({ "tool_name": "Bash", "tool_input": { "command": "" } }); + assert_eq!(detect_codex_command(&v), None); + } + #[test] fn test_get_rewritten_supported() { assert!(get_rewritten("git status").is_some()); @@ -267,6 +352,32 @@ mod tests { assert!(get_rewritten("cat <<'EOF'\nhello\nEOF").is_none()); } + #[test] + fn test_deny_reason_format() { + assert_eq!( + deny_reason("rtk git status"), + "Token savings: use `rtk git status` instead (rtk saves 60-90% tokens)" + ); + } + + #[test] + fn test_codex_deny_output_format() { + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": deny_reason("rtk git status") + } + }); + let json: Value = serde_json::from_str(&output.to_string()).unwrap(); + assert_eq!(json["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "deny"); + assert_eq!( + json["hookSpecificOutput"]["permissionDecisionReason"], + "Token savings: use `rtk git status` instead (rtk saves 60-90% tokens)" + ); + } + // --- Gemini format --- #[test] diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 438aca7a..e9636c3c 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -21,6 +21,21 @@ const OPENCODE_PLUGIN: &str = include_str!("../../hooks/opencode/rtk.ts"); // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../../hooks/claude/rtk-awareness.md"); const RTK_SLIM_CODEX: &str = include_str!("../../hooks/codex/rtk-awareness.md"); +const CODEX_HOOK_COMMAND: &str = "rtk hook codex"; +const CODEX_HOOK_TIMEOUT_SEC: u64 = 5; + +#[derive(Debug, Clone)] +struct CodexPaths { + codex_dir: PathBuf, + agents_md: PathBuf, + rtk_md: PathBuf, + config_toml: PathBuf, + hooks_json: PathBuf, +} + +fn codex_lifecycle_hooks_supported() -> bool { + !cfg!(windows) +} /// Template written by `rtk init` when no filters.toml exists yet. const FILTERS_TEMPLATE: &str = r#"# Project-local RTK filters — commit this file with your repo. @@ -410,6 +425,26 @@ fn atomic_write(path: &Path, content: &str) -> Result<()> { Ok(()) } +fn backup_path(path: &Path) -> PathBuf { + match path.extension().and_then(|ext| ext.to_str()) { + Some(ext) => path.with_extension(format!("{ext}.bak")), + None => path.with_extension("bak"), + } +} + +fn backup_file_if_exists(path: &Path, verbose: u8) -> Result> { + if !path.exists() { + return Ok(None); + } + + let backup = backup_path(path); + fs::copy(path, &backup).with_context(|| format!("Failed to backup to {}", backup.display()))?; + if verbose > 0 { + eprintln!("Backup: {}", backup.display()); + } + Ok(Some(backup)) +} + /// Prompt user for consent to patch settings.json /// Prints to stderr (stdout may be piped), reads from stdin /// Default is No (capital N) @@ -649,45 +684,69 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: } fn uninstall_codex(global: bool, verbose: u8) -> Result<()> { - if !global { - anyhow::bail!( - "Uninstall only works with --global flag. For local projects, manually remove RTK from AGENTS.md" - ); - } - - let codex_dir = resolve_codex_dir()?; - let removed = uninstall_codex_at(&codex_dir, verbose)?; + let paths = codex_paths(global)?; + let removed = uninstall_codex_at(&paths, verbose)?; if removed.is_empty() { println!("RTK was not installed for Codex CLI (nothing to remove)"); } else { println!("RTK uninstalled for Codex CLI:"); - for item in removed { + for item in &removed { println!(" - {}", item); } + + match codex_uninstall_warning(&paths.config_toml) { + Ok(Some(warning)) => { + println!("\n Warning: {warning}"); + } + Ok(None) => {} + Err(err) => { + if verbose > 0 { + eprintln!( + "Warning: failed to inspect Codex config at {}: {err:#}", + paths.config_toml.display() + ); + } + } + } } Ok(()) } -fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { +fn uninstall_codex_at(paths: &CodexPaths, verbose: u8) -> Result> { let mut removed = Vec::new(); - let rtk_md_path = codex_dir.join("RTK.md"); - if rtk_md_path.exists() { - fs::remove_file(&rtk_md_path) - .with_context(|| format!("Failed to remove RTK.md: {}", rtk_md_path.display()))?; + if paths.rtk_md.exists() { + fs::remove_file(&paths.rtk_md) + .with_context(|| format!("Failed to remove RTK.md: {}", paths.rtk_md.display()))?; if verbose > 0 { - eprintln!("Removed RTK.md: {}", rtk_md_path.display()); + eprintln!("Removed RTK.md: {}", paths.rtk_md.display()); } - removed.push(format!("RTK.md: {}", rtk_md_path.display())); + removed.push(format!("RTK.md: {}", paths.rtk_md.display())); } - let agents_md_path = codex_dir.join("AGENTS.md"); - if remove_rtk_reference_from_agents(&agents_md_path, verbose)? { + if remove_rtk_reference_from_agents(&paths.agents_md, verbose)? { removed.push("AGENTS.md: removed @RTK.md reference".to_string()); } + if paths.hooks_json.exists() { + let content = fs::read_to_string(&paths.hooks_json) + .with_context(|| format!("Failed to read {}", paths.hooks_json.display()))?; + + if !content.trim().is_empty() { + if let Ok(mut root) = serde_json::from_str::(&content) { + if remove_codex_hook_from_json(&mut root) { + backup_file_if_exists(&paths.hooks_json, verbose)?; + let serialized = serde_json::to_string_pretty(&root) + .context("Failed to serialize hooks.json")?; + atomic_write(&paths.hooks_json, &serialized)?; + removed.push("hooks.json: removed RTK PreToolUse hook".to_string()); + } + } + } + } + Ok(removed) } @@ -1246,49 +1305,293 @@ fn run_windsurf_mode(verbose: u8) -> Result<()> { } fn run_codex_mode(global: bool, verbose: u8) -> Result<()> { - let (agents_md_path, rtk_md_path) = if global { - let codex_dir = resolve_codex_dir()?; - (codex_dir.join("AGENTS.md"), codex_dir.join("RTK.md")) - } else { - (PathBuf::from("AGENTS.md"), PathBuf::from("RTK.md")) - }; + let paths = codex_paths(global)?; - if global { - 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 global || codex_lifecycle_hooks_supported() { + fs::create_dir_all(&paths.codex_dir).with_context(|| { + format!( + "Failed to create Codex config directory: {}", + paths.codex_dir.display() + ) + })?; + } + + write_if_changed(&paths.rtk_md, RTK_SLIM_CODEX, "RTK.md", verbose)?; + let added_ref = patch_agents_md(&paths.agents_md, verbose)?; + + if !codex_lifecycle_hooks_supported() { + println!("\nRTK configured for Codex CLI (prompt-only fallback).\n"); + println!(" RTK.md: {}", paths.rtk_md.display()); + if added_ref { + println!(" AGENTS.md: @RTK.md reference added"); + } else { + println!(" AGENTS.md: @RTK.md reference already present"); } + println!(" Warning: Codex lifecycle hooks are not supported on Windows yet."); + println!( + " Installed RTK.md + AGENTS.md guidance only; no hook files were patched." + ); + if global { + println!( + "\n Codex global instructions path: {}", + paths.agents_md.display() + ); + } else { + println!( + "\n Codex project instructions path: {}", + paths.agents_md.display() + ); + } + return Ok(()); } - write_if_changed(&rtk_md_path, RTK_SLIM_CODEX, "RTK.md", verbose)?; - let added_ref = patch_agents_md(&agents_md_path, verbose)?; + let (codex_hooks_changed, codex_config_changed) = patch_codex_hook_files(&paths, verbose)?; println!("\nRTK configured for Codex CLI.\n"); - println!(" RTK.md: {}", rtk_md_path.display()); + println!(" RTK.md: {}", paths.rtk_md.display()); if added_ref { println!(" AGENTS.md: @RTK.md reference added"); } else { println!(" AGENTS.md: @RTK.md reference already present"); } + println!( + " config.toml: {}", + if codex_config_changed { + "codex_hooks enabled" + } else { + "codex_hooks already enabled" + } + ); + println!( + " hooks.json: {}", + if codex_hooks_changed { + "RTK PreToolUse hook added" + } else { + "RTK PreToolUse hook already present" + } + ); if global { println!( "\n Codex global instructions path: {}", - agents_md_path.display() + paths.agents_md.display() ); } else { println!( "\n Codex project instructions path: {}", - agents_md_path.display() + paths.agents_md.display() ); + println!(" Note: local .codex hooks only load for trusted projects."); } Ok(()) } +fn codex_hooks_enabled(root: &toml::Value) -> bool { + root.get("features") + .and_then(|features| features.get("codex_hooks")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} + +fn patch_codex_hook_files(paths: &CodexPaths, verbose: u8) -> Result<(bool, bool)> { + let codex_hooks_changed = patch_codex_hooks_json(&paths.hooks_json, verbose)?; + let codex_config_changed = patch_codex_config_toml(&paths.config_toml, verbose)?; + Ok((codex_hooks_changed, codex_config_changed)) +} + +fn codex_uninstall_warning(path: &Path) -> Result> { + if !codex_lifecycle_hooks_supported() || !path.exists() { + return Ok(None); + } + + let content = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + if content.trim().is_empty() { + return Ok(None); + } + + let root = toml::from_str::(&content) + .with_context(|| format!("Failed to parse {} as TOML", path.display()))?; + if !codex_hooks_enabled(&root) { + return Ok(None); + } + + Ok(Some(format!( + "{} still has features.codex_hooks = true. RTK leaves this shared setting unchanged during uninstall; other Codex hooks in this config layer may still run. Disable it manually if you want all Codex hooks off.", + path.display() + ))) +} + +fn patch_codex_config_toml(path: &Path, verbose: u8) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + + 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() { + toml::Value::Table(toml::map::Map::new()) + } else { + toml::from_str(&content) + .with_context(|| format!("Failed to parse {} as TOML", path.display()))? + } + } else { + toml::Value::Table(toml::map::Map::new()) + }; + + if codex_hooks_enabled(&root) { + if verbose > 0 { + eprintln!("Codex config already enables codex_hooks"); + } + return Ok(false); + } + + if !root.is_table() { + root = toml::Value::Table(toml::map::Map::new()); + } + + let root_table = root + .as_table_mut() + .expect("root was normalized to a TOML table"); + + let features = root_table + .entry("features") + .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); + if !features.is_table() { + *features = toml::Value::Table(toml::map::Map::new()); + } + features + .as_table_mut() + .expect("features must be a table") + .insert("codex_hooks".to_string(), toml::Value::Boolean(true)); + + backup_file_if_exists(path, verbose)?; + let serialized = + toml::to_string_pretty(&root).context("Failed to serialize Codex config TOML")?; + atomic_write(path, &serialized)?; + Ok(true) +} + +fn codex_hook_already_present(root: &serde_json::Value) -> bool { + root.get("hooks") + .and_then(|h| h.get("PreToolUse")) + .and_then(|p| p.as_array()) + .into_iter() + .flatten() + .filter(|group| group.get("matcher").and_then(|m| m.as_str()) == Some("Bash")) + .filter_map(|group| group.get("hooks").and_then(|h| h.as_array())) + .flatten() + .any(|hook| { + hook.get("type").and_then(|v| v.as_str()) == Some("command") + && hook.get("command").and_then(|v| v.as_str()) == Some(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() + .context("hooks.json root must be an object")? + } + }; + + let hooks = root_obj + .entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .context("hooks must be an object")?; + + let pre_tool_use = hooks + .entry("PreToolUse") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut() + .context("PreToolUse must be an array")?; + + pre_tool_use.push(serde_json::json!({ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": CODEX_HOOK_COMMAND, + "timeout": CODEX_HOOK_TIMEOUT_SEC + }] + })); + + Ok(()) +} + +fn patch_codex_hooks_json(path: &Path, verbose: u8) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + + 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 already has RTK hook"); + } + return Ok(false); + } + + insert_codex_hook_entry(&mut root) + .with_context(|| format!("Failed to update {}", path.display()))?; + backup_file_if_exists(path, verbose)?; + let serialized = + serde_json::to_string_pretty(&root).context("Failed to serialize hooks.json")?; + atomic_write(path, &serialized)?; + Ok(true) +} + +fn remove_codex_hook_from_json(root: &mut serde_json::Value) -> bool { + let pre_tool_use = match root + .get_mut("hooks") + .and_then(|h| h.get_mut("PreToolUse")) + .and_then(|p| p.as_array_mut()) + { + Some(arr) => arr, + None => return false, + }; + + let mut removed = false; + for group in pre_tool_use.iter_mut() { + if let Some(hooks) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) { + let before = hooks.len(); + hooks.retain(|hook| { + !(hook.get("type").and_then(|v| v.as_str()) == Some("command") + && hook.get("command").and_then(|v| v.as_str()) == Some(CODEX_HOOK_COMMAND)) + }); + if hooks.len() < before { + removed = true; + } + } + } + + pre_tool_use.retain(|group| { + group + .get("hooks") + .and_then(|h| h.as_array()) + .is_none_or(|hooks| !hooks.is_empty()) + }); + + removed +} + // --- upsert_rtk_block: idempotent RTK block management --- #[derive(Debug, Clone, Copy, PartialEq)] @@ -1525,10 +1828,48 @@ fn resolve_claude_dir() -> Result { /// Resolve ~/.codex directory with proper home expansion fn resolve_codex_dir() -> Result { - dirs::home_dir() + resolve_codex_dir_from( + std::env::var_os("CODEX_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from), + dirs::home_dir(), + ) +} + +fn resolve_codex_dir_from( + codex_home: Option, + home_dir: Option, +) -> Result { + if let Some(codex_home) = codex_home { + return Ok(codex_home); + } + + home_dir .map(|h| h.join(".codex")) .context("Cannot determine home directory. Is $HOME set?") } + +fn codex_paths(global: bool) -> Result { + let codex_dir = if global { + resolve_codex_dir()? + } else { + PathBuf::from(".codex") + }; + + let (agents_md, rtk_md) = if global { + (codex_dir.join("AGENTS.md"), codex_dir.join("RTK.md")) + } else { + (PathBuf::from("AGENTS.md"), PathBuf::from("RTK.md")) + }; + + Ok(CodexPaths { + config_toml: codex_dir.join("config.toml"), + hooks_json: codex_dir.join("hooks.json"), + codex_dir, + agents_md, + rtk_md, + }) +} /// Resolve OpenCode config directory (~/.config/opencode) /// OpenCode uses ~/.config/opencode on all platforms (XDG convention), /// NOT the macOS-native ~/Library/Application Support/. @@ -2031,8 +2372,8 @@ 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/AGENTS.md + ~/.codex/RTK.md"); + println!(" rtk init --codex # Configure local AGENTS.md + RTK.md (+ .codex hooks on macOS/Linux)"); + println!(" rtk init -g --codex # Configure $CODEX_HOME or ~/.codex (Windows: prompt-only)"); println!(" rtk init -g --opencode # OpenCode plugin only"); println!(" rtk init -g --agent cursor # Install Cursor Agent hooks"); @@ -2040,22 +2381,26 @@ fn show_claude_config() -> Result<()> { } 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 local_agents_md = PathBuf::from("AGENTS.md"); - let local_rtk_md = PathBuf::from("RTK.md"); + let global_paths = codex_paths(true)?; + let local_paths = codex_paths(false)?; + let hooks_supported = codex_lifecycle_hooks_supported(); println!("rtk Configuration (Codex CLI):\n"); - if global_rtk_md.exists() { - println!("[ok] Global RTK.md: {}", global_rtk_md.display()); + if !hooks_supported { + println!( + "[warn] Codex lifecycle hooks are not supported on Windows; hooks.json and codex_hooks are ignored." + ); + } + + if global_paths.rtk_md.exists() { + println!("[ok] Global RTK.md: {}", global_paths.rtk_md.display()); } else { println!("[--] Global RTK.md: not found"); } - if global_agents_md.exists() { - let content = fs::read_to_string(&global_agents_md)?; + if global_paths.agents_md.exists() { + let content = fs::read_to_string(&global_paths.agents_md)?; if content.contains("@RTK.md") { println!("[ok] Global AGENTS.md: @RTK.md reference"); } else if content.contains("