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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,11 +351,12 @@ rtk git status

## Supported AI Tools

RTK supports 13 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception.
RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception.

| Tool | Install | Method |
|------|---------|--------|
| **Claude Code** | `rtk init -g` | PreToolUse hook (bash) |
| **CodeBuddy Code** | `rtk init -g --agent codebuddy` | PreToolUse hook (bash) |
| **GitHub Copilot (VS Code)** | `rtk init -g --copilot` | PreToolUse hook — transparent rewrite |
| **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) |
| **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) |
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

The **lifecycle management** layer for LLM agent hooks: install, uninstall, verify integrity, audit usage, and manage trust. This component creates and maintains the hook artifacts that live in `hooks/` (root), but does **not** execute rewrite logic itself — that lives in `discover/registry`.

Owns: `rtk init` installation flows (5 agents via `AgentTarget` enum + 3 special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management.
Owns: `rtk init` installation flows (agents via `AgentTarget` enum + special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management.

Does **not** own: the deployed hook scripts themselves (that's `hooks/`), the rewrite pattern registry (that's `discover/`), or command filtering (that's `cmds/`).

Expand All @@ -26,6 +26,7 @@ LLM agent integration layer that installs, validates, and executes command-rewri
| Default (global) | `rtk init -g` | Hook, SHA-256 hash, RTK.md | settings.json, CLAUDE.md |
| Hook only | `rtk init -g --hook-only` | Hook, SHA-256 hash | settings.json |
| Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md |
| CodeBuddy Code | `rtk init -g --agent codebuddy` | Native hook command | `~/.codebuddy/settings.json` |
| 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 |
Expand Down Expand Up @@ -85,6 +86,7 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i
| Tool | ask support | Behavior on Default |
|------|------------|-------------------|
| Claude Code (rtk-rewrite.sh) | Yes | `permissionDecision: "ask"` — user prompted |
| CodeBuddy Code (rtk hook codebuddy) | Yes | uses `modifiedInput` and leaves default permission flow to CodeBuddy |
| 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) |
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/constants.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub const REWRITE_HOOK_FILE: &str = "rtk-rewrite.sh";
pub const GEMINI_HOOK_FILE: &str = "rtk-hook-gemini.sh";
pub const CLAUDE_DIR: &str = ".claude";
pub const CODEBUDDY_DIR: &str = ".codebuddy";
pub const HOOKS_SUBDIR: &str = "hooks";
pub const SETTINGS_JSON: &str = "settings.json";
pub const SETTINGS_LOCAL_JSON: &str = "settings.local.json";
Expand All @@ -10,6 +11,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 CodeBuddy Code.
pub const CODEBUDDY_HOOK_COMMAND: &str = "rtk hook codebuddy";
/// Native Rust hook command for Cursor (replaces rtk-rewrite.sh).
pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor";

Expand Down
159 changes: 159 additions & 0 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,108 @@ fn run_claude_inner(input: &str) -> Option<String> {
}
}

// ── CodeBuddy Code native hook ─────────────────────────────────

fn process_codebuddy_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,
};

let verdict = permissions::check_command(cmd);
if verdict == PermissionVerdict::Deny {
return PayloadAction::Skip {
reason: "skip:deny_rule",
cmd: cmd.to_string(),
};
}

let rewritten = match get_rewritten(cmd) {
Some(r) => r,
None => {
return PayloadAction::Skip {
reason: "skip:no_match",
cmd: cmd.to_string(),
}
}
};

let modified_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
};

let mut hook_output = json!({
"hookEventName": PRE_TOOL_USE_KEY,
"permissionDecisionReason": "RTK auto-rewrite",
"modifiedInput": modified_input
});

if verdict == PermissionVerdict::Allow {
hook_output
.as_object_mut()
.unwrap()
.insert("permissionDecision".into(), json!("allow"));
}

PayloadAction::Rewrite {
cmd: cmd.to_string(),
rewritten,
output: json!({ "hookSpecificOutput": hook_output }),
}
}

/// Run the CodeBuddy Code PreToolUse hook natively.
pub fn run_codebuddy() -> 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_codebuddy_payload(&v) {
PayloadAction::Rewrite {
cmd,
rewritten,
output,
} => {
audit_log("rewrite", &cmd, &rewritten);
let _ = writeln!(io::stdout(), "{output}");
}
PayloadAction::Skip { reason, cmd } => {
audit_log(reason, &cmd, "");
}
PayloadAction::Ignore => {}
}

Ok(())
}

#[cfg(test)]
fn run_codebuddy_inner(input: &str) -> Option<String> {
let v: Value = serde_json::from_str(input).ok()?;
match process_codebuddy_payload(&v) {
PayloadAction::Rewrite { output, .. } => Some(output.to_string()),
_ => None,
}
}

// ── Cursor native hook ─────────────────────────────────────────

/// Cursor on Windows ships hook payloads with one or more leading
Expand Down Expand Up @@ -780,6 +882,63 @@ mod tests {
assert!(run_claude_inner(&input).is_none());
}

// --- CodeBuddy handler ---

#[test]
fn test_codebuddy_rewrite_git_status() {
let result = run_codebuddy_inner(&claude_input("git status")).unwrap();
let v: Value = serde_json::from_str(&result).unwrap();
let cmd = v
.pointer("/hookSpecificOutput/modifiedInput/command")
.and_then(|c| c.as_str())
.unwrap();
assert_eq!(cmd, "rtk git status");
}

#[test]
fn test_codebuddy_rewrite_preserves_tool_input_fields() {
let input = claude_input_with_fields("git status", 30000, "Check repo status");
let result = run_codebuddy_inner(&input).unwrap();
let v: Value = serde_json::from_str(&result).unwrap();
let modified = &v["hookSpecificOutput"]["modifiedInput"];
assert_eq!(modified["command"], "rtk git status");
assert_eq!(modified["timeout"], 30000);
assert_eq!(modified["description"], "Check repo status");
}

#[test]
fn test_codebuddy_passthrough_no_output() {
assert!(run_codebuddy_inner(&claude_input("htop")).is_none());
}

#[test]
fn test_codebuddy_empty_command_passthrough() {
let input = json!({
"tool_name": "Bash",
"tool_input": { "command": "" }
})
.to_string();
assert!(run_codebuddy_inner(&input).is_none());
}

#[test]
fn test_codebuddy_malformed_json_passthrough() {
assert!(run_codebuddy_inner("not valid json {{{").is_none());
}

#[test]
fn test_codebuddy_json_output_structure() {
let result = run_codebuddy_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["permissionDecisionReason"], "RTK auto-rewrite");
assert!(hook["modifiedInput"].is_object());
assert!(hook["modifiedInput"]["command"].is_string());
assert!(hook.get("updatedInput").is_none());
}

// --- Cursor handler ---

fn cursor_input(cmd: &str) -> String {
Expand Down
Loading