Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
4 changes: 3 additions & 1 deletion hooks/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 26 additions & 4 deletions hooks/codex/rtk-awareness.md
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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
Expand All @@ -28,5 +47,8 @@ rtk proxy <cmd> # Run raw command without filtering
```bash
rtk --version
rtk gain
which rtk
```

```powershell
Get-Command rtk
```
4 changes: 3 additions & 1 deletion src/cmds/system/grep_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ fn compact_path(path: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

#[test]
fn test_clean_line() {
Expand Down Expand Up @@ -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!(
Expand Down
87 changes: 49 additions & 38 deletions src/core/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
Expand All @@ -660,24 +680,22 @@ 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());
}

#[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());
Expand Down Expand Up @@ -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,
Expand All @@ -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!(
Expand All @@ -764,39 +778,36 @@ 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);
}

#[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);
Expand All @@ -805,26 +816,26 @@ 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);
}

#[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"));
Expand Down
65 changes: 56 additions & 9 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand All @@ -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<String> {
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<String> {
let v: Value = serde_json::from_str(input).ok()?;
match process_claude_like_payload(&v) {
PayloadAction::Rewrite { output, .. } => Some(output.to_string()),
_ => None,
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading