From 284ee06c209bb5169d81319a1e73a4c23c1d524c Mon Sep 17 00:00:00 2001 From: CC Date: Sat, 23 May 2026 19:30:48 +0800 Subject: [PATCH] feat: improve Codex support on Windows --- README.md | 2 +- hooks/codex/README.md | 4 +- hooks/codex/rtk-awareness.md | 30 +++++++++++-- src/cmds/system/grep_cmd.rs | 4 +- src/core/stream.rs | 87 ++++++++++++++++++++---------------- src/hooks/hook_cmd.rs | 65 +++++++++++++++++++++++---- src/hooks/init.rs | 48 +++++++++++++++++++- src/main.rs | 6 +++ 8 files changed, 190 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index f8d65efe5..18a753390 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` | AGENTS.md + RTK.md instructions, experimental `rtk hook codex` entry | | **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/codex/README.md b/hooks/codex/README.md index 50030e958..e500d4a0a 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 +- Prompt-level guidance via awareness document is the guaranteed Codex integration path today +- RTK also exposes an experimental `rtk hook codex` entry for Codex environments that support lifecycle hook execution - `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` +- On Windows, the installed `RTK.md` includes PowerShell-friendly verification guidance and explicit `rtk ...` usage rules diff --git a/hooks/codex/rtk-awareness.md b/hooks/codex/rtk-awareness.md index 7ae285e1a..e6722ce13 100644 --- a/hooks/codex/rtk-awareness.md +++ b/hooks/codex/rtk-awareness.md @@ -1,10 +1,10 @@ -# RTK - Rust Token Killer (Codex CLI) +# RTK - Rust Token Killer (Codex) -**Usage**: Token-optimized CLI proxy for shell commands. +**Usage**: Token-optimized CLI proxy for shell commands in Codex. ## Rule -Always prefix shell commands with `rtk`. +Always prefix RTK-covered shell commands with `rtk`. Examples: @@ -13,8 +13,27 @@ rtk git status rtk cargo test rtk npm run build rtk pytest -q +rtk read src/main.rs +rtk grep "TODO" src ``` +## Compound Commands + +In compound commands, prefix each RTK-covered segment instead of only the first command. + +```bash +rtk git add . && rtk cargo test +cd app && rtk npm test +``` + +## Windows / PowerShell + +On Windows, prefer explicit RTK commands because Codex always reads `AGENTS.md`, while shell-hook support may vary by Codex environment. + +- Use `rtk read`, `rtk grep`, and `rtk find` for file inspection +- Use `rtk git ...`, `rtk cargo ...`, `rtk pytest`, `rtk npm ...` for verbose workflows +- Keep PowerShell-native helpers for verification only, such as `Get-Command rtk` + ## Meta Commands ```bash @@ -28,5 +47,8 @@ rtk proxy # Run raw command without filtering ```bash rtk --version rtk gain -which rtk +``` + +```powershell +Get-Command rtk ``` diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 6a33cf3a4..89ce1d41c 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -250,6 +250,7 @@ fn compact_path(path: &str) -> String { #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; #[test] fn test_clean_line() { @@ -395,12 +396,13 @@ mod tests { fn test_rg_no_ignore_vcs_flag_accepted() { // Verify rg accepts --no-ignore-vcs (used to match grep -r behavior for .gitignore) let mut cmd = resolved_command("rg"); + let temp = tempdir().unwrap(); cmd.args([ "-n", "--no-heading", "--no-ignore-vcs", "NONEXISTENT_PATTERN_12345", - ".", + temp.path().to_str().unwrap(), ]); if let Ok(output) = cmd.output() { assert!( diff --git a/src/core/stream.rs b/src/core/stream.rs index 02a6cfc79..d08a7c2b9 100644 --- a/src/core/stream.rs +++ b/src/core/stream.rs @@ -566,15 +566,36 @@ pub(crate) mod tests { } } + fn shell_command(script: &str) -> Command { + #[cfg(windows)] + { + let comspec = std::env::var("ComSpec").unwrap_or_else(|_| "cmd.exe".to_string()); + let mut cmd = Command::new(comspec); + cmd.args(["/C", script]); + cmd + } + + #[cfg(not(windows))] + { + let mut cmd = Command::new("sh"); + cmd.args(["-c", script]); + cmd + } + } + + fn exit_command(code: i32) -> Command { + shell_command(&format!("exit {}", code)) + } + #[test] fn test_exit_code_zero() { - let status = Command::new("true").status().unwrap(); + let status = exit_command(0).status().unwrap(); assert_eq!(status_to_exit_code(status), 0); } #[test] fn test_exit_code_nonzero() { - let status = Command::new("false").status().unwrap(); + let status = exit_command(1).status().unwrap(); assert_eq!(status_to_exit_code(status), 1); } @@ -650,8 +671,7 @@ pub(crate) mod tests { #[test] fn test_run_streaming_passthrough_echo() { - let mut cmd = Command::new("echo"); - cmd.arg("hello"); + let mut cmd = shell_command("echo hello"); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); assert_eq!(result.exit_code, 0); // Passthrough inherits TTY — raw/filtered are empty @@ -660,16 +680,14 @@ pub(crate) mod tests { #[test] fn test_run_streaming_exit_code_preserved() { - // nosemgrep: interpreter-execution - let mut cmd = Command::new("sh"); - cmd.args(["-c", "exit 42"]); + let mut cmd = exit_command(42); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); assert_eq!(result.exit_code, 42); } #[test] fn test_run_streaming_exit_code_zero() { - let mut cmd = Command::new("true"); + let mut cmd = exit_command(0); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); assert_eq!(result.exit_code, 0); assert!(result.success()); @@ -677,7 +695,7 @@ pub(crate) mod tests { #[test] fn test_run_streaming_exit_code_one() { - let mut cmd = Command::new("false"); + let mut cmd = exit_command(1); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); assert_eq!(result.exit_code, 1); assert!(!result.success()); @@ -725,13 +743,11 @@ pub(crate) mod tests { #[test] fn test_run_streaming_raw_cap_at_10mb() { - // nosemgrep: interpreter-execution - let mut cmd = Command::new("sh"); - // ~11 MiB of 80-char lines (fast: fewer lines than `yes | head -6M`) - cmd.args([ - "-c", - "dd if=/dev/zero bs=1024 count=11264 2>/dev/null | tr '\\0' 'a' | fold -w 80", - ]); + let mut cmd = shell_command(if cfg!(windows) { + "for /L %i in (1,1,150000) do @echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } else { + "dd if=/dev/zero bs=1024 count=11264 2>/dev/null | tr '\\0' 'a' | fold -w 80" + }); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly).unwrap(); assert!( result.raw.len() <= 10_485_760 + 100, @@ -746,13 +762,11 @@ pub(crate) mod tests { #[test] fn test_run_streaming_stderr_cap_at_10mb() { - // nosemgrep: interpreter-execution - let mut cmd = Command::new("sh"); - // ~11 MiB on stderr, nothing on stdout - cmd.args([ - "-c", - "dd if=/dev/zero bs=1024 count=11264 2>/dev/null | tr '\\0' 'a' | fold -w 80 1>&2", - ]); + let mut cmd = shell_command(if cfg!(windows) { + "for /L %i in (1,1,150000) do @echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1>&2" + } else { + "dd if=/dev/zero bs=1024 count=11264 2>/dev/null | tr '\\0' 'a' | fold -w 80 1>&2" + }); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly).unwrap(); // raw = raw_stdout + raw_stderr; stdout is empty so raw ≈ stderr size assert!( @@ -764,7 +778,7 @@ pub(crate) mod tests { #[test] fn test_child_guard_prevents_zombie() { - let mut cmd = Command::new("true"); + let mut cmd = exit_command(0); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly); assert!(result.is_ok()); assert_eq!(result.unwrap().exit_code, 0); @@ -772,31 +786,28 @@ pub(crate) mod tests { #[test] fn test_run_streaming_null_stdin_cat() { - let mut cmd = Command::new("cat"); + let mut cmd = shell_command(if cfg!(windows) { "more" } else { "cat" }); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::Passthrough).unwrap(); assert_eq!(result.exit_code, 0); } #[test] fn test_run_streaming_raw_contains_stdout() { - let mut cmd = Command::new("echo"); - cmd.arg("test_output_xyz"); + let mut cmd = shell_command("echo test_output_xyz"); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly).unwrap(); assert!(result.raw.contains("test_output_xyz")); } #[test] fn test_run_streaming_capture_only_filtered_equals_raw() { - let mut cmd = Command::new("echo"); - cmd.arg("check_equality"); + let mut cmd = shell_command("echo check_equality"); let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly).unwrap(); assert_eq!(result.filtered.trim(), result.raw_stdout.trim()); } #[test] fn test_exec_capture_success() { - let mut cmd = Command::new("echo"); - cmd.arg("hello_capture"); + let mut cmd = shell_command("echo hello_capture"); let result = exec_capture(&mut cmd).unwrap(); assert!(result.success()); assert_eq!(result.exit_code, 0); @@ -805,7 +816,7 @@ pub(crate) mod tests { #[test] fn test_exec_capture_failure() { - let mut cmd = Command::new("false"); + let mut cmd = exit_command(1); let result = exec_capture(&mut cmd).unwrap(); assert!(!result.success()); assert_eq!(result.exit_code, 1); @@ -813,18 +824,18 @@ pub(crate) mod tests { #[test] fn test_exec_capture_stderr() { - // nosemgrep: interpreter-execution - let mut cmd = Command::new("sh"); - cmd.args(["-c", "echo err_msg >&2"]); + let mut cmd = shell_command("echo err_msg >&2"); let result = exec_capture(&mut cmd).unwrap(); assert!(result.stderr.contains("err_msg")); } #[test] fn test_exec_capture_combined() { - // nosemgrep: interpreter-execution - let mut cmd = Command::new("sh"); - cmd.args(["-c", "echo out_msg; echo err_msg >&2"]); + let mut cmd = shell_command(if cfg!(windows) { + "echo out_msg & echo err_msg >&2" + } else { + "echo out_msg; echo err_msg >&2" + }); let result = exec_capture(&mut cmd).unwrap(); let combined = result.combined(); assert!(combined.contains("out_msg")); diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5..f83be12e9 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -297,7 +297,7 @@ enum PayloadAction { Ignore, } -fn process_claude_payload(v: &Value) -> PayloadAction { +fn process_claude_like_payload(v: &Value) -> PayloadAction { let cmd = match v .pointer("/tool_input/command") .and_then(|c| c.as_str()) @@ -353,24 +353,21 @@ fn process_claude_payload(v: &Value) -> PayloadAction { } } -/// Run the Claude Code PreToolUse hook natively. -pub fn run_claude() -> Result<()> { - let input = read_stdin_limited()?; - +fn run_claude_like_hook(input: &str) { let input = input.trim(); if input.is_empty() { - return Ok(()); + return; } 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(()); + return; } }; - match process_claude_payload(&v) { + match process_claude_like_payload(&v) { PayloadAction::Rewrite { cmd, rewritten, @@ -384,14 +381,35 @@ pub fn run_claude() -> Result<()> { } PayloadAction::Ignore => {} } +} +/// Run the Claude Code PreToolUse hook natively. +pub fn run_claude() -> Result<()> { + let input = read_stdin_limited()?; + run_claude_like_hook(&input); Ok(()) } #[cfg(test)] fn run_claude_inner(input: &str) -> Option { let v: Value = serde_json::from_str(input).ok()?; - match process_claude_payload(&v) { + match process_claude_like_payload(&v) { + PayloadAction::Rewrite { output, .. } => Some(output.to_string()), + _ => None, + } +} + +/// Run a Codex hook using the same payload contract as Claude-style hooks. +pub fn run_codex() -> Result<()> { + let input = read_stdin_limited()?; + run_claude_like_hook(&input); + Ok(()) +} + +#[cfg(test)] +fn run_codex_inner(input: &str) -> Option { + let v: Value = serde_json::from_str(input).ok()?; + match process_claude_like_payload(&v) { PayloadAction::Rewrite { output, .. } => Some(output.to_string()), _ => None, } @@ -780,6 +798,35 @@ mod tests { assert!(run_claude_inner(&input).is_none()); } + // --- Codex handler --- + + #[test] + fn test_codex_rewrite_git_status() { + let result = run_codex_inner(&claude_input("git status")).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let cmd = v + .pointer("/hookSpecificOutput/updatedInput/command") + .and_then(|c| c.as_str()) + .unwrap(); + assert_eq!(cmd, "rtk git status"); + } + + #[test] + fn test_codex_rewrite_preserves_tool_input_fields() { + let input = claude_input_with_fields("cargo test", 30000, "Run tests"); + let result = run_codex_inner(&input).unwrap(); + let v: Value = serde_json::from_str(&result).unwrap(); + let updated = &v["hookSpecificOutput"]["updatedInput"]; + assert_eq!(updated["command"], "rtk cargo test"); + assert_eq!(updated["timeout"], 30000); + assert_eq!(updated["description"], "Run tests"); + } + + #[test] + fn test_codex_passthrough_no_output() { + assert!(run_codex_inner(&claude_input("htop")).is_none()); + } + // --- Cursor handler --- fn cursor_input(cmd: &str) -> String { diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 9af21dc57..1f28b3808 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -2298,6 +2298,11 @@ fn run_codex_mode_with_paths( agents_md_path.display() ); } + println!(" Integration: AGENTS.md instructions (works in Codex today)"); + println!(" Experimental hook: rtk hook codex (requires hook-capable Codex host)"); + if cfg!(windows) { + println!(" Windows: prefer explicit `rtk ...` commands in PowerShell sessions"); + } } Ok(()) @@ -2707,9 +2712,27 @@ fn resolve_claude_dir() -> Result { } fn resolve_codex_dir() -> Result { - resolve_codex_dir_from( + resolve_codex_dir_from_env( std::env::var_os("CODEX_HOME").map(PathBuf::from), dirs::home_dir(), + std::env::var_os("USERPROFILE").map(PathBuf::from), + ) +} + +fn resolve_codex_dir_from_env( + codex_home: Option, + home_dir: Option, + user_profile: Option, +) -> Result { + resolve_codex_dir_from( + codex_home, + if cfg!(windows) { + user_profile + .filter(|path| !path.as_os_str().is_empty()) + .or(home_dir) + } else { + home_dir + }, ) } @@ -2723,7 +2746,11 @@ fn resolve_codex_dir_from( home_dir .map(|home| home.join(CODEX_DIR)) - .context("Cannot determine Codex config directory. Set $CODEX_HOME or $HOME.") + .context(if cfg!(windows) { + "Cannot determine Codex config directory. Set %CODEX_HOME%, %USERPROFILE%, or $HOME." + } else { + "Cannot determine Codex config directory. Set $CODEX_HOME or $HOME." + }) } fn resolve_hermes_home() -> Result { @@ -3365,6 +3392,8 @@ fn show_codex_config() -> Result<()> { let local_rtk_md = PathBuf::from(RTK_MD); println!("rtk Configuration (Codex CLI):\n"); + println!(" Integration mode: AGENTS.md instructions"); + println!(" Experimental hook entry: rtk hook codex (requires hook-capable Codex host)\n"); if global_rtk_md.exists() { println!("[ok] Global RTK.md: {}", global_rtk_md.display()); @@ -4617,6 +4646,9 @@ mod tests { fs::read_to_string(&agents_md).unwrap(), format!("{}\n", codex_rtk_md_ref(temp.path())) ); + let rtk_md = fs::read_to_string(temp.path().join("RTK.md")).unwrap(); + assert!(rtk_md.contains("Get-Command rtk")); + assert!(rtk_md.contains("rtk read")); } #[test] @@ -4635,6 +4667,18 @@ mod tests { assert_eq!(missing_falls_back, home_dir.join(".codex")); } + #[cfg(windows)] + #[test] + fn test_resolve_codex_dir_uses_userprofile_before_home_on_windows() { + let home_dir = PathBuf::from("C:/wrong-home"); + let user_profile = PathBuf::from("C:/Users/example"); + + let resolved = + resolve_codex_dir_from_env(None, Some(home_dir), Some(user_profile.clone())).unwrap(); + + assert_eq!(resolved, user_profile.join(".codex")); + } + #[test] fn test_resolve_hermes_home_prefers_hermes_home() { let hermes_home = OsString::from("~/custom hermes home"); diff --git a/src/main.rs b/src/main.rs index c1a897190..b5bea83a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -768,6 +768,8 @@ enum Commands { enum HookCommands { /// Process Claude Code PreToolUse hook (reads JSON from stdin) Claude, + /// Process Codex hook payloads (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