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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Features

* **hook:** add `rtk hook claude` native Rust subcommand — replaces `rtk-rewrite.sh`, no `jq` dependency required ([#862](https://github.com/rtk-ai/rtk/pull/862))
* **init:** `rtk init -g` now installs `rtk hook claude` (native binary) instead of the shell script; auto-migrates existing `rtk-rewrite.sh` entries on re-run ([#862](https://github.com/rtk-ai/rtk/pull/862))

### Bug Fixes

* **hook:** fix silent failure when `jq` is not installed — `rtk-rewrite.sh` would exit 0 without rewriting any commands ([#430](https://github.com/rtk-ai/rtk/issues/430), [#862](https://github.com/rtk-ai/rtk/pull/862))
* **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))
* **git:** replace vague truncation markers with exact counts in log and grep output ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([185fb97](https://github.com/rtk-ai/rtk/commit/185fb97))

Expand Down
7 changes: 4 additions & 3 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,12 @@ rtk gain # MUST show token savings, not "command not found"

```bash
rtk init -g
# → Installs hook to ~/.claude/hooks/rtk-rewrite.sh
# → Registers "rtk hook claude" in settings.json (native Rust, no jq required)
# → Creates ~/.claude/RTK.md (10 lines, meta commands only)
# → Adds @RTK.md reference to ~/.claude/CLAUDE.md
# → Prompts: "Patch settings.json? [y/N]"
# → If yes: patches + creates backup (~/.claude/settings.json.bak)
# → If rtk-rewrite.sh was previously installed: auto-migrates to native hook

# Automated alternatives:
rtk init -g --auto-patch # Patch without prompting
Expand All @@ -113,7 +114,7 @@ rtk init --show # Check hook is installed and executable
Claude Code's hook registry. RTK adds a PreToolUse hook that rewrites commands transparently. Without this, Claude won't invoke the hook automatically.

```
Claude Code settings.json rtk-rewrite.sh RTK binary
Claude Code settings.json rtk hook claude RTK binary
│ │ │ │
│ "git status" │ │ │
│ ──────────────────►│ │ │
Expand Down Expand Up @@ -247,7 +248,7 @@ rtk vitest run
rtk init -g --uninstall

# What gets removed:
# - Hook: ~/.claude/hooks/rtk-rewrite.sh
# - Hook: "rtk hook claude" entry from settings.json
# - Context: ~/.claude/RTK.md
# - Reference: @RTK.md line from ~/.claude/CLAUDE.md
# - Registration: RTK hook entry from settings.json
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ RTK supports 10 AI coding tools. Each integration transparently rewrites shell c

| Tool | Install | Method |
|------|---------|--------|
| **Claude Code** | `rtk init -g` | PreToolUse hook (bash) |
| **Claude Code** | `rtk init -g` | PreToolUse hook (`rtk hook claude`) — no `jq` required |
| **GitHub Copilot (VS Code)** | `rtk init -g --copilot` | PreToolUse hook (`rtk hook copilot`) — 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 All @@ -321,6 +321,8 @@ rtk init --show # Verify installation
rtk init -g --uninstall # Remove
```

Uses `rtk hook claude` — a native Rust binary that reads JSON from stdin and rewrites Bash tool commands. No external dependencies (`jq` not required). If you previously installed via `rtk-rewrite.sh`, re-running `rtk init -g` auto-migrates to the native hook.

### GitHub Copilot (VS Code + CLI)

```bash
Expand Down
4 changes: 2 additions & 2 deletions scripts/validate-docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ PYTHON_GO_CMDS=("ruff" "pytest" "pip" "go" "golangci")
echo "🐍 Checking Python/Go commands documentation..."

for cmd in "${PYTHON_GO_CMDS[@]}"; do
for file in README.md CLAUDE.md; do
for file in README.md; do
if [ ! -f "$file" ]; then
echo "⚠️ $file not found, skipping"
continue
Expand All @@ -37,7 +37,7 @@ for cmd in "${PYTHON_GO_CMDS[@]}"; do
fi
done
done
echo "✅ Python/Go commands: documented in README.md and CLAUDE.md"
echo "✅ Python/Go commands: documented in README.md"

# 4. Hooks cohérents avec doc
HOOK_FILE=".claude/hooks/rtk-rewrite.sh"
Expand Down
2 changes: 1 addition & 1 deletion src/cmds/ruby/rake_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use anyhow::{Context, Result};
/// `rails test` which handles single files, multiple files, and line-number
/// syntax (`file.rb:15`) natively.
fn select_runner(args: &[String]) -> (&'static str, Vec<String>) {
let has_test_subcommand = args.first().map_or(false, |a| a == "test");
let has_test_subcommand = args.first().is_some_and(|a| a == "test");
if !has_test_subcommand {
return ("rake", args.to_vec());
}
Expand Down
172 changes: 172 additions & 0 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,76 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> {

// ── Gemini hook ───────────────────────────────────────────────

// ── Claude Code hook ──────────────────────────────────────────

/// Core Claude Code hook logic: parse JSON input and return hook response JSON string.
/// Returns `Some(json)` if the command was rewritten, `None` to pass through silently.
/// Extracted for testability — `run_claude()` is the stdin/stdout wrapper.
pub(crate) fn process_claude_hook(input: &str) -> Option<String> {
let input = input.trim();
if input.is_empty() {
return None;
}

let v: Value = match serde_json::from_str(input) {
Ok(v) => v,
Err(e) => {
eprintln!("[rtk hook] Failed to parse JSON input: {e}");
return None;
}
};

// Only handle Bash tool
let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
if tool_name != "Bash" {
Comment on lines +163 to +165
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process_claude_hook only rewrites when tool_name is exactly "Bash", but this codebase already treats VS Code / Claude-style hooks as potentially using "bash" (lowercase) or "runTerminalCommand" (see detect_format). With the new rtk hook claude entrypoint, this strict check can cause the hook to silently do nothing for valid inputs. Consider accepting the same tool_name variants as detect_format (or reusing detect_format/handle_vscode) so Claude Code inputs don’t regress.

Suggested change
// Only handle Bash tool
let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
if tool_name != "Bash" {
// Only handle bash-like tools (VS Code / Claude variants)
let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
if tool_name != "Bash" && tool_name != "bash" && tool_name != "runTerminalCommand" {

Copilot uses AI. Check for mistakes.
return None;
}

let cmd = v
.pointer("/tool_input/command")
.and_then(|c| c.as_str())
.unwrap_or("");

if cmd.is_empty() {
return None;
}

let rewritten = get_rewritten(cmd)?;

// Preserve all tool_input fields (description, etc.), only update command
let mut updated_input = v.get("tool_input").cloned().unwrap_or_else(|| json!({}));
if let Some(obj) = updated_input.as_object_mut() {
obj.insert("command".to_string(), Value::String(rewritten));
}

Some(
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": updated_input
}
})
.to_string(),
)
}

/// Run the Claude Code PreToolUse hook.
/// Reads JSON from stdin, rewrites Bash tool commands to rtk equivalents.
/// No external dependencies — unlike rtk-rewrite.sh which required jq.
pub fn run_claude() -> Result<()> {
let mut input = String::new();
io::stdin()
.read_to_string(&mut input)
.context("Failed to read stdin")?;

if let Some(output) = process_claude_hook(&input) {
println!("{output}");
}
Ok(())
}

// ── Gemini CLI hook ───────────────────────────────────────────

/// Run the Gemini CLI BeforeTool hook.
/// Reads JSON from stdin, rewrites shell commands to rtk equivalents,
/// outputs JSON to stdout in Gemini CLI format.
Expand Down Expand Up @@ -332,4 +402,106 @@ mod tests {
Some("RUST_LOG=debug rtk cargo test".into())
);
}

// --- Claude Code hook (process_claude_hook) ---

fn claude_json(cmd: &str) -> String {
json!({
"tool_name": "Bash",
"tool_input": { "command": cmd, "description": "Run command" }
})
.to_string()
}

fn parse_hook_output(json_str: &str) -> Value {
serde_json::from_str(json_str).expect("hook output must be valid JSON")
}

#[test]
fn test_claude_rewrites_git_status() {
let output = process_claude_hook(&claude_json("git status")).expect("should rewrite");
let v = parse_hook_output(&output);
assert_eq!(
v["hookSpecificOutput"]["updatedInput"]["command"],
"rtk git status"
);
}

#[test]
fn test_claude_rewrites_cargo_test() {
let output = process_claude_hook(&claude_json("cargo test --all")).expect("should rewrite");
let v = parse_hook_output(&output);
assert_eq!(
v["hookSpecificOutput"]["updatedInput"]["command"],
"rtk cargo test --all"
);
}

#[test]
fn test_claude_no_output_for_unknown_command() {
assert!(process_claude_hook(&claude_json("htop")).is_none());
}

#[test]
fn test_claude_no_output_for_already_rtk() {
assert!(process_claude_hook(&claude_json("rtk git status")).is_none());
}

#[test]
fn test_claude_no_output_for_non_bash_tool() {
let input =
json!({ "tool_name": "Edit", "tool_input": { "command": "git status" } }).to_string();
assert!(process_claude_hook(&input).is_none());
}

#[test]
fn test_claude_no_output_for_empty_input() {
assert!(process_claude_hook("").is_none());
assert!(process_claude_hook(" ").is_none());
}

#[test]
fn test_claude_no_output_for_invalid_json() {
assert!(process_claude_hook("not json at all").is_none());
}

#[test]
fn test_claude_preserves_description_field() {
let input = json!({
"tool_name": "Bash",
"tool_input": { "command": "git status", "description": "Check repo status" }
})
.to_string();
let output = process_claude_hook(&input).expect("should rewrite");
let v = parse_hook_output(&output);
assert_eq!(
v["hookSpecificOutput"]["updatedInput"]["command"],
"rtk git status"
);
assert_eq!(
v["hookSpecificOutput"]["updatedInput"]["description"],
"Check repo status"
);
}

#[test]
fn test_claude_output_schema() {
let output = process_claude_hook(&claude_json("git status")).expect("should rewrite");
let v = parse_hook_output(&output);
// Verify expected Claude Code PreToolUse hook response shape
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert!(v["hookSpecificOutput"]["updatedInput"].is_object());
// Must NOT include permissionDecision (Claude Code infers allow from updatedInput presence)
assert!(v["hookSpecificOutput"]["permissionDecision"].is_null());
}

#[test]
fn test_claude_heredoc_passthrough() {
let input = json!({
"tool_name": "Bash",
"tool_input": { "command": "cat <<'EOF'\nhello\nEOF" }
})
.to_string();
assert!(process_claude_hook(&input).is_none());
}
}
Loading
Loading