From 787b41dcab195d57d3666c36af56bfd4fff98aaa Mon Sep 17 00:00:00 2001 From: Oxygen56 Date: Sun, 9 Aug 2026 05:16:58 +0800 Subject: [PATCH] fix(hook): register Claude hook without shell wrapper Signed-off-by: Oxygen56 --- docs/contributing/TECHNICAL.md | 47 +- docs/guide/getting-started/installation.md | 2 +- .../guide/getting-started/supported-agents.md | 16 +- docs/guide/resources/troubleshooting.md | 15 +- hooks/README.md | 14 +- src/hooks/README.md | 20 +- src/hooks/constants.rs | 5 +- src/hooks/hook_check.rs | 29 +- src/hooks/init.rs | 577 ++++++++++++++++-- src/hooks/integrity.rs | 26 +- src/hooks/mod.rs | 63 +- 11 files changed, 703 insertions(+), 111 deletions(-) diff --git a/docs/contributing/TECHNICAL.md b/docs/contributing/TECHNICAL.md index 54f15422ae..5e6819c53e 100644 --- a/docs/contributing/TECHNICAL.md +++ b/docs/contributing/TECHNICAL.md @@ -72,12 +72,11 @@ This is the full lifecycle of a command through RTK, from LLM agent to filtered The user runs `rtk init` to set up hooks for their LLM agent. This: -1. Writes a thin shell hook script (e.g., `~/.claude/hooks/rtk-rewrite.sh`) -2. Stores its SHA-256 hash for integrity verification -3. Patches the agent's settings file (e.g., `settings.json`) to register the hook -4. Writes RTK awareness instructions (e.g., `RTK.md`) for prompt-level guidance +1. Patches the agent's settings file (e.g., `settings.json`) to register the hook +2. Registers the RTK binary directly when the agent supports it; other integrations install their required script or plugin +3. Writes RTK awareness instructions (e.g., `RTK.md`) for prompt-level guidance -RTK supports 7 agents, each with its own installation mode. The hook scripts are embedded in the binary and written at install time. +Claude Code uses the shell-free `rtk hook claude` binary entry. Other agents use the installation mode described in their hook documentation. > **Details**: [`src/hooks/README.md`](../src/hooks/README.md) covers all installation modes, configuration files, and the uninstall flow. @@ -86,11 +85,11 @@ RTK supports 7 agents, each with its own installation mode. The hook scripts are When an LLM agent runs a command (e.g., `git status`): 1. The agent fires a `PreToolUse` event (or equivalent) containing the command as JSON -2. The hook script reads the JSON, extracts the command string -3. The hook calls `rtk rewrite "git status"` as a subprocess -4. `rtk rewrite` consults the command registry and returns `rtk git status` +2. The agent-specific hook reads the JSON and extracts the command string +3. The native hook or delegate calls RTK's rewrite logic +4. The command registry returns `rtk git status` when a rewrite applies 5. The hook sends a response telling the agent to use the rewritten command -6. If anything fails (jq missing, rtk not found, no match), the hook exits silently -- the raw command runs unchanged +6. If parsing or rewriting fails, or no rule matches, the hook exits silently -- the raw command runs unchanged All rewrite logic lives in Rust (`src/discover/registry.rs`). Hooks are thin delegates that handle agent-specific JSON formats. @@ -101,7 +100,7 @@ All rewrite logic lives in Rust (`src/discover/registry.rs`). Hooks are thin del The rewrite pipeline is how RTK intercepts and rewrites commands. The call chain is: ``` -hook shell → rewrite_cmd.rs → rewrite_command() → rewrite_compound() → rewrite_segment() → classify_command() +rtk hook claude → hook_cmd.rs → rewrite_command() → rewrite_compound() → rewrite_segment() → classify_command() ``` Traced step by step for `cargo fmt --all && cargo test 2>&1 | tail -20`: @@ -109,29 +108,27 @@ Traced step by step for `cargo fmt --all && cargo test 2>&1 | tail -20`: ``` LLM Agent: "cargo fmt --all && cargo test 2>&1 | tail -20" | - | Hook shell (hooks/claude/rtk-rewrite.sh) - | Reads JSON from agent, extracts command, calls `rtk rewrite "$CMD"` - | On failure (jq missing, rtk missing, old version): exit 0 (passthrough) + | Native hook (`rtk hook claude`) + | Reads JSON from the agent + | On failure: exit 0 (passthrough) | v -rewrite_cmd::run(cmd) [src/hooks/rewrite_cmd.rs] - | 1. Load config → hooks.exclude_commands - | 2. check_command(cmd) → Deny → exit(2) - | 3. registry::rewrite_command(cmd, excluded) - | → None → exit(1) (no RTK equivalent, passthrough) - | → Some + Allow → print, exit(0) - | → Some + Ask → print, exit(3) +hook_cmd::run_claude() [src/hooks/hook_cmd.rs] + | 1. Parse the bounded stdin payload + | 2. Check hook permissions + | 3. Call registry::rewrite_command(cmd, excluded, transparent_prefixes) + | 4. Emit Claude Code's updatedInput JSON, or pass through silently | v -rewrite_command(cmd, excluded) [src/discover/registry.rs] +rewrite_command(...) [src/discover/registry.rs] | Early exits: | - Empty → None | - Contains "<<" or "$((" (heredoc/arithmetic) → None | - Simple "rtk ..." (no operators) → return as-is - | - Otherwise → rewrite_compound(cmd, excluded) + | - Otherwise → rewrite_compound(...) | v -rewrite_compound(cmd, excluded) [src/discover/registry.rs] +rewrite_compound(...) [src/discover/registry.rs] | | Step 1 — Tokenize (lexer.rs) | tokenize() produces typed tokens with byte offsets: @@ -313,7 +310,7 @@ Start here, then drill down into each README for file-level details. | Directory | Agent | What you'll find in its README | |-----------|-------|-------------------------------| | [`hooks/`](../hooks/README.md) | _(parent)_ | **All JSON formats**, rewrite registry overview, exit code contract, override controls | -| [`claude/`](../hooks/claude/README.md) | Claude Code | Shell hook mechanism, `PreToolUse` JSON, test script | +| [`claude/`](../hooks/claude/README.md) | Claude Code | Awareness file, legacy shell hook, and compatibility tests | | [`copilot/`](../hooks/copilot/README.md) | GitHub Copilot | Rust binary hook, single `PreToolUse` schema shared by VS Code Chat and Copilot CLI | | [`cursor/`](../hooks/cursor/README.md) | Cursor IDE | Shell hook, empty JSON response requirement | | [`cline/`](../hooks/cline/README.md) | Cline / Roo Code | Rules file (prompt-level, no programmatic hook) | @@ -329,7 +326,7 @@ RTK supports the following LLM agents through hook integrations: | Agent | Hook Type | Mechanism | Can Modify Command? | |-------|-----------|-----------|---------------------| -| Claude Code | Shell hook | `PreToolUse` in `settings.json` | Yes (`updatedInput`) | +| Claude Code | Rust binary | `rtk hook claude` via `PreToolUse` | Yes (`updatedInput`) | | GitHub Copilot (VS Code) | Rust binary | `rtk hook copilot` reads JSON | Yes (`updatedInput`) | | GitHub Copilot CLI | Rust binary | `rtk hook copilot` reads JSON | Yes (`updatedInput`) | | Cursor | Rust binary | `rtk hook cursor` reads JSON | Yes (`updated_input`) | diff --git a/docs/guide/getting-started/installation.md b/docs/guide/getting-started/installation.md index 4cc0280941..92d54b936c 100644 --- a/docs/guide/getting-started/installation.md +++ b/docs/guide/getting-started/installation.md @@ -55,7 +55,7 @@ Download from [GitHub releases](https://github.com/rtk-ai/rtk/releases): - Linux: `rtk-x86_64-unknown-linux-musl.tar.gz` / `rtk-aarch64-unknown-linux-gnu.tar.gz` - Windows: `rtk-x86_64-pc-windows-msvc.zip` -**Windows users**: Extract the zip and place `rtk.exe` in a directory on your PATH. Run RTK from Command Prompt, PowerShell, or Windows Terminal — do not double-click the `.exe` (it prints usage and exits immediately). For full hook support, use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) instead. +**Windows users**: Extract the zip and place `rtk.exe` in a directory on your PATH. Run RTK from Command Prompt, PowerShell, or Windows Terminal — do not double-click the `.exe` (it prints usage and exits immediately). Claude Code's native RTK hook works without WSL. ## Verify installation diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index 6af4995e51..491110b2cf 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -28,7 +28,7 @@ Agent runs "cargo test" | Agent | Integration tier | Can rewrite transparently? | |-------|-----------------|---------------------------| -| Claude Code | Shell hook (`PreToolUse`) | Yes | +| Claude Code | Rust binary (`rtk hook claude`, `PreToolUse`) | Yes | | VS Code Copilot Chat | Shell hook (`PreToolUse`) | Yes | | GitHub Copilot CLI | Shell hook (`PreToolUse`) | Yes | | Cursor | Shell hook (`preToolUse`) | Yes | @@ -232,13 +232,15 @@ Rules file integrations (Cline, Windsurf, Codex, Kilo Code, Antigravity) rely on ## Windows support -The shell hook (`rtk-rewrite.sh`) requires a Unix shell. On native Windows: +Claude Code uses the native RTK binary on every platform. `rtk init -g` +registers `rtk` as the executable and passes `hook claude` as an argument array, +so native Windows does not need a Unix shell for this hook. Existing absolute +paths ending in `rtk.exe` are recognized as installed when they use the same +arguments. -- `rtk init -g` automatically falls back to **CLAUDE.md injection mode** (prompt-level instructions) -- Filters work normally (`rtk cargo test`, `rtk git status`) -- Auto-rewrite does not work — the AI assistant is instructed to use RTK but commands are not intercepted - -For full hook support on Windows, use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). Inside WSL, all agents with shell hook integration (Claude Code, Cursor, Gemini) work identically to Linux. +Legacy `rtk-rewrite.sh` integrations still require a Unix shell. Re-run +`rtk init -g` to migrate RTK's older combined Claude Code command entry to the +native form; use WSL only when another shell-script integration requires it. ## Graceful degradation diff --git a/docs/guide/resources/troubleshooting.md b/docs/guide/resources/troubleshooting.md index c0c0d5c0d5..598b09881b 100644 --- a/docs/guide/resources/troubleshooting.md +++ b/docs/guide/resources/troubleshooting.md @@ -103,20 +103,17 @@ rtk --version - Or open PowerShell or Windows Terminal - Then run: `rtk --version` -### Hook not working (no auto-rewrite) +### Claude Code hook reported as missing -**Symptom:** `rtk init -g` shows "Falling back to --claude-md mode" on Windows. +**Symptom:** The RTK hook is configured on native Windows, but `rtk init --show` or `rtk gain` reports it as missing. -**Cause:** The auto-rewrite hook (`rtk-rewrite.sh`) requires a Unix shell. Native Windows doesn't have one. +**Fix:** Update RTK and re-run `rtk init -g`. Current versions register the executable and its arguments separately, without a Unix shell: -**Fix:** Use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) for full hook support: -```bash -# Inside WSL -curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh -rtk init -g # full hook mode works in WSL +```json +{ "type": "command", "command": "rtk", "args": ["hook", "claude"] } ``` -On native Windows, RTK falls back to CLAUDE.md injection. Your AI assistant gets RTK instructions but won't auto-rewrite commands. It can still use RTK manually: `rtk cargo test`, `rtk git status`, etc. +An absolute path ending in `rtk.exe` is also recognized with the same arguments. Keep `rtk.exe` on PATH when using the generated form, then restart Claude Code. ### Node.js tools not found diff --git a/hooks/README.md b/hooks/README.md index 6e9cd01a2a..56132ed326 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -33,7 +33,7 @@ All rewrite logic lives in the Rust binary (`src/discover/registry.rs`). Hook sc Each agent subdirectory has its own README with hook-specific details: -- **[`claude/`](claude/README.md)** — Shell hook, `PreToolUse` JSON format, `settings.json` patching, test script +- **[`claude/`](claude/README.md)** — Awareness file plus the legacy shell hook and its compatibility tests; current installs use `rtk hook claude` - **[`copilot/`](copilot/README.md)** — Rust binary hook, dual format (VS Code Chat vs Copilot CLI), deny-with-suggestion fallback - **[`cursor/`](cursor/README.md)** — Shell hook, Cursor JSON format, empty `{}` response requirement - **[`cline/`](cline/README.md)** — Rules file (prompt-level), `.clinerules` project-local installation @@ -48,7 +48,7 @@ Each agent subdirectory has its own README with hook-specific details: | Agent | Mechanism | Hook Type | Can Modify Command? | |-------|-----------|-----------|---------------------| -| Claude Code | Shell hook (`PreToolUse`) | Transparent rewrite | Yes (`updatedInput`) | +| Claude Code | Rust binary (`rtk hook claude`, `PreToolUse`) | Transparent rewrite | Yes (`updatedInput`) | | VS Code Copilot Chat | Rust binary (`rtk hook copilot`) | Transparent rewrite | Yes (`updatedInput`) | | GitHub Copilot CLI | Rust binary (`rtk hook copilot`) | Deny-with-suggestion | No (agent retries) | | Cursor | Rust binary | Transparent rewrite | Yes (`updated_input`) | @@ -63,7 +63,15 @@ Each agent subdirectory has its own README with hook-specific details: ## JSON Formats by Agent -### Claude Code (Shell Hook) +### Claude Code (Rust Binary Hook) + +`rtk init -g` registers the hook as a shell-free executable plus argument array: + +```json +{ "type": "command", "command": "rtk", "args": ["hook", "claude"] } +``` + +Legacy combined command strings remain detectable for compatibility. **Input** (stdin): diff --git a/src/hooks/README.md b/src/hooks/README.md index 586105c8ae..d30485cff6 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -23,8 +23,8 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Mode | Command | Creates | Patches | |------|---------|---------|----------| -| 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 | +| Default (global) | `rtk init -g` | RTK.md | settings.json, CLAUDE.md | +| Hook only | `rtk init -g --hook-only` | -- | settings.json | | Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md | | Windsurf | `rtk init -g --agent windsurf` | `.windsurfrules` | -- | | Cline | `rtk init --agent cline` | `.clinerules` | -- | @@ -34,15 +34,17 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Hermes | `rtk init --agent hermes` | Python plugin in `~/.hermes/plugins/rtk-rewrite/` | `config.yaml` `plugins.enabled` | -## Integrity Verification +## Legacy Shell-Hook Integrity Verification -The integrity system prevents unauthorized hook modifications: +Current Claude Code installs run the RTK binary directly, so they do not create +a hook script or hash file. The integrity system remains for older +`rtk-rewrite.sh` installations until they are migrated: 1. At install: `integrity::store_hash()` computes SHA-256 of the hook file, writes to `~/.claude/hooks/.rtk-hook.sha256` (read-only 0o444) 2. At runtime: `integrity::runtime_check()` re-computes hash and compares; blocks execution if tampered 3. On demand: `rtk verify` prints detailed verification status (PASS/FAIL/WARN/SKIP) -Five integrity states: +Five legacy integrity states: - **Verified**: Hash matches stored value - **Tampered**: Hash mismatch (blocks execution) - **NoBaseline**: Hook exists but no hash stored (old install) @@ -59,6 +61,12 @@ Controls how `rtk init` modifies agent settings files: | Auto | `--auto-patch` | Patches without prompting; for CI/scripted installs | | Skip | `--no-patch` | Prints manual instructions; user patches manually | +Claude Code is registered without a shell wrapper: `settings.json` stores +`"command": "rtk"` and `"args": ["hook", "claude"]`. Detection also accepts +absolute `rtk`/`rtk.exe` paths with the same arguments and the legacy combined +command string. When patching is allowed, `rtk init -g` upgrades the exact +legacy entry it previously generated while preserving fields such as `timeout`. + ## Atomicity and Safety All file operations use atomic writes (tempfile + rename) to prevent corruption on crash. Settings files are backed up to `.bak` before modification. All operations are idempotent -- running `rtk init` multiple times is safe. @@ -84,7 +92,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 | +| Claude Code (`rtk hook claude`) | Yes | `permissionDecision: "ask"` — user prompted | | Copilot VS Code (rtk hook copilot) | Yes | `permissionDecision: "ask"` — user prompted | | Cursor (rtk hook cursor) | Ready | `permission: "ask",` — users will be prompted when Cursor enforces the permission; in the meantime, allow | | Gemini CLI (rtk hook gemini) | No (allow/deny only) | allow (limitation — no ask mode in Gemini) | diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 700e9798f4..3b51a04c97 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -8,8 +8,11 @@ pub const HOOKS_JSON: &str = "hooks.json"; pub const PRE_TOOL_USE_KEY: &str = "PreToolUse"; pub const BEFORE_TOOL_KEY: &str = "BeforeTool"; -/// Native Rust hook command for Claude Code (replaces rtk-rewrite.sh). +/// Human-readable command and the exact shell form emitted by older RTK versions. pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; +/// Shell-free Claude Code registration written to `command` and `args`. +pub const CLAUDE_HOOK_BINARY: &str = "rtk"; +pub const CLAUDE_HOOK_ARGS: [&str; 2] = ["hook", "claude"]; /// Native Rust hook command for Cursor (replaces rtk-rewrite.sh). pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; /// Native Rust hook command for Factory Droid. diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index 5f70a7cd64..2c879a5ee5 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -2,7 +2,7 @@ use super::constants::{HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON}; use super::init::resolve_claude_dir; -use super::is_claude_hook_command; +use super::is_claude_hook_entry; use crate::core::constants::RTK_DATA_DIR; use std::path::PathBuf; @@ -80,8 +80,7 @@ fn binary_hook_registered(claude_dir: &std::path::Path) -> bool { .iter() .filter_map(|entry| entry.get("hooks")?.as_array()) .flatten() - .filter_map(|hook| hook.get("command")?.as_str()) - .any(is_claude_hook_command) + .any(is_claude_hook_entry) } /// Check if the installed hook is missing or outdated, warn once per day. @@ -233,6 +232,30 @@ mod tests { assert!(binary_hook_registered(tmp.path())); } + #[test] + fn test_binary_hook_registered_accepts_windows_exec_form() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(SETTINGS_JSON), + r#"{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "C:\\Users\\me\\.local\\bin\\rtk.exe", + "args": ["hook", "claude"], + "timeout": 10 + }] + }] + } + }"#, + ) + .expect("write settings"); + + assert!(binary_hook_registered(tmp.path())); + } + #[test] fn test_other_integration_none() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/src/hooks/init.rs b/src/hooks/init.rs index bc7b443283..f0f7cd5d55 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -13,16 +13,17 @@ use crate::hooks::constants::{ }; use super::constants::{ - BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_COMMAND, CODEX_DIR, CURSOR_HOOK_COMMAND, DROID_DIR, - DROID_EXECUTE_MATCHER, DROID_HOME_ENV, DROID_HOOKS_FILE, DROID_HOOKS_SUBDIR, - DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, - HERMES_PLUGIN_INIT_FILE, HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, - HOOKS_SUBDIR, PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, - PI_PLUGIN_FILE, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, VIBE_BASH_MATCH, VIBE_DIR, - VIBE_HOOKS_FILE, VIBE_HOOK_COMMAND, VIBE_HOOK_NAME, VIBE_PROMPTS_SUBDIR, VIBE_PROMPT_FILE, + BEFORE_TOOL_KEY, CLAUDE_DIR, CLAUDE_HOOK_ARGS, CLAUDE_HOOK_BINARY, CLAUDE_HOOK_COMMAND, + CODEX_DIR, CURSOR_HOOK_COMMAND, DROID_DIR, DROID_EXECUTE_MATCHER, DROID_HOME_ENV, + DROID_HOOKS_FILE, DROID_HOOKS_SUBDIR, DROID_HOOK_COMMAND, DROID_SETTINGS_FILE, + GEMINI_HOOK_FILE, HERMES_DIR, HERMES_PLUGINS_SUBDIR, HERMES_PLUGIN_INIT_FILE, + HERMES_PLUGIN_MANIFEST_FILE, HERMES_PLUGIN_NAME, HOOKS_JSON, HOOKS_SUBDIR, + PI_CODING_AGENT_DIR_ENV, PI_DIR, PI_EXTENSIONS_SUBDIR, PI_LOCAL_DIR, PI_PLUGIN_FILE, + PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, VIBE_BASH_MATCH, VIBE_DIR, VIBE_HOOKS_FILE, + VIBE_HOOK_COMMAND, VIBE_HOOK_NAME, VIBE_PROMPTS_SUBDIR, VIBE_PROMPT_FILE, }; use super::integrity; -use super::is_claude_hook_command; +use super::{is_claude_hook_command, is_claude_hook_entry}; // Embedded OpenCode plugin (auto-rewrite) const OPENCODE_PLUGIN: &str = include_str!("../../hooks/opencode/rtk.ts"); @@ -528,24 +529,31 @@ fn prompt_telemetry_consent() -> Result<()> { Ok(()) } -fn print_manual_instructions(hook_command: &str, include_opencode: bool) { +fn print_manual_instructions(hook_command: &str, include_opencode: bool) -> Result<()> { let settings_path = resolve_claude_dir() .unwrap_or_else(|_| PathBuf::from(format!("~/{}", CLAUDE_DIR))) .join(SETTINGS_JSON); - println!("\n MANUAL STEP: Add this to {}:", settings_path.display()); - println!(" {{"); - println!(" \"hooks\": {{ \"PreToolUse\": [{{"); - println!(" \"matcher\": \"Bash\","); - println!(" \"hooks\": [{{ \"type\": \"command\","); - println!(" \"command\": \"{}\"", hook_command); - println!(" }}]"); - println!(" }}]}}"); - println!(" }}"); + println!( + "\n MANUAL STEP: Use this RTK hook configuration in {} (replace older RTK hook entries):", + settings_path.display() + ); + let manual_config = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [command_hook_entry(hook_command)] + }] + } + }); + let rendered = serde_json::to_string_pretty(&manual_config) + .context("Failed to serialize manual hook configuration")?; + println!("{}", rendered); if include_opencode { println!("\n Then restart Claude Code and OpenCode. Test with: git status\n"); } else { println!("\n Then restart Claude Code. Test with: git status\n"); } + Ok(()) } fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { @@ -562,22 +570,36 @@ fn remove_hook_from_json(root: &mut serde_json::Value) -> bool { None => return false, }; - let original_len = pre_tool_use_array.len(); - pre_tool_use_array.retain(|entry| { - if let Some(hooks_array) = entry.get("hooks").and_then(|h| h.as_array()) { - for hook in hooks_array { - if let Some(command) = hook.get("command").and_then(|c| c.as_str()) { - // Match both legacy script path and new binary command - if command.contains(REWRITE_HOOK_FILE) || is_claude_hook_command(command) { - return false; - } - } - } + let mut removed = false; + let mut empty_managed_groups = Vec::new(); + for (entry_index, entry) in pre_tool_use_array.iter_mut().enumerate() { + let removable_empty_group = + is_bash_hook_group(entry) && entry.as_object().is_some_and(|object| object.len() == 2); + let Some(hooks_array) = entry + .get_mut("hooks") + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + let original_len = hooks_array.len(); + hooks_array.retain(|hook| { + let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) else { + return true; + }; + !(is_claude_hook_entry(hook) + || is_command_hook(hook) && command.contains(REWRITE_HOOK_FILE)) + }); + let removed_here = hooks_array.len() < original_len; + removed |= removed_here; + if removed_here && hooks_array.is_empty() && removable_empty_group { + empty_managed_groups.push(entry_index); } - true - }); + } + for entry_index in empty_managed_groups.into_iter().rev() { + pre_tool_use_array.remove(entry_index); + } - pre_tool_use_array.len() < original_len + removed } /// Remove RTK hook from settings.json file @@ -977,8 +999,12 @@ fn patch_settings_json_command( serde_json::json!({}) }; - // Check idempotency - if hook_already_present(&root, hook_command) { + let managed_shell_hook_present = + is_claude_hook_command(hook_command) && has_managed_claude_shell_hook(&root); + + // An exec-form hook is already at the target state. The exact shell-form + // entry written by older RTK versions is handled below as an upgrade. + if hook_already_present(&root, hook_command) && !managed_shell_hook_present { if verbose > 0 { eprintln!("settings.json: hook already present"); } @@ -988,7 +1014,7 @@ fn patch_settings_json_command( // Handle mode match mode { PatchMode::Skip => { - print_manual_instructions(hook_command, include_opencode); + print_manual_instructions(hook_command, include_opencode)?; return Ok(PatchResult::Skipped); } PatchMode::Ask => { @@ -999,7 +1025,7 @@ fn patch_settings_json_command( settings_path.display() ); } else if !prompt_user_consent(&settings_path)? { - print_manual_instructions(hook_command, include_opencode); + print_manual_instructions(hook_command, include_opencode)?; return Ok(PatchResult::Declined); } } @@ -1008,7 +1034,11 @@ fn patch_settings_json_command( } } - insert_hook_entry(&mut root, hook_command)?; + if managed_shell_hook_present { + upgrade_managed_claude_shell_hooks(&mut root); + } else { + insert_hook_entry(&mut root, hook_command)?; + } let serialized = serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; @@ -1037,7 +1067,7 @@ fn patch_settings_json_command( // Atomic write atomic_write(&settings_path, &serialized)?; - println!("\n settings.json: hook added"); + println!("\n settings.json: hook configured"); if settings_path.with_extension("json.bak").exists() { println!( " Backup: {}", @@ -1085,6 +1115,101 @@ fn clean_double_blanks(content: &str) -> String { /// Deep-merge RTK hook entry into settings.json /// Creates hooks.PreToolUse structure if missing, preserves existing hooks +fn command_hook_entry(hook_command: &str) -> serde_json::Value { + if is_claude_hook_command(hook_command) { + serde_json::json!({ + "type": "command", + "command": CLAUDE_HOOK_BINARY, + "args": CLAUDE_HOOK_ARGS + }) + } else { + serde_json::json!({ + "type": "command", + "command": hook_command + }) + } +} + +fn is_bash_hook_group(entry: &serde_json::Value) -> bool { + entry.get("matcher").and_then(serde_json::Value::as_str) == Some("Bash") +} + +fn is_command_hook(hook: &serde_json::Value) -> bool { + hook.get("type").and_then(serde_json::Value::as_str) == Some("command") +} + +fn is_managed_claude_shell_hook(hook: &serde_json::Value) -> bool { + is_command_hook(hook) + && hook.get("args").is_none() + && hook + .get("command") + .and_then(serde_json::Value::as_str) + .is_some_and(|command| command == CLAUDE_HOOK_COMMAND) +} + +#[cfg(test)] +fn claude_hook_entries(root: &serde_json::Value) -> impl Iterator { + root.get("hooks") + .and_then(|hooks| hooks.get(PRE_TOOL_USE_KEY)) + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() +} + +fn has_managed_claude_shell_hook(root: &serde_json::Value) -> bool { + root.get("hooks") + .and_then(|hooks| hooks.get(PRE_TOOL_USE_KEY)) + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter(|entry| is_bash_hook_group(entry)) + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() + .any(is_managed_claude_shell_hook) +} + +fn upgrade_managed_claude_shell_hooks(root: &mut serde_json::Value) -> bool { + let Some(pre_tool_use) = root + .get_mut("hooks") + .and_then(|hooks| hooks.get_mut(PRE_TOOL_USE_KEY)) + .and_then(serde_json::Value::as_array_mut) + else { + return false; + }; + + let mut upgraded = false; + for entry in pre_tool_use.iter_mut() { + if !is_bash_hook_group(entry) { + continue; + } + let Some(hooks) = entry + .get_mut("hooks") + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + + for hook in hooks { + if !is_managed_claude_shell_hook(hook) { + continue; + } + let Some(object) = hook.as_object_mut() else { + continue; + }; + object.insert( + "command".to_string(), + serde_json::Value::String(CLAUDE_HOOK_BINARY.to_string()), + ); + object.insert("args".to_string(), serde_json::json!(CLAUDE_HOOK_ARGS)); + upgraded = true; + } + } + + upgraded +} + fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) -> Result<()> { let root_obj = match root.as_object_mut() { Some(obj) => obj, @@ -1108,10 +1233,7 @@ fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) -> Result pre_tool_use.push(serde_json::json!({ "matcher": "Bash", - "hooks": [{ - "type": "command", - "command": hook_command - }] + "hooks": [command_hook_entry(hook_command)] })); Ok(()) } @@ -1132,9 +1254,13 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { .iter() .filter_map(|entry| entry.get("hooks")?.as_array()) .flatten() - .filter_map(|hook| hook.get("command")?.as_str()) - .any(|cmd| { - cmd == hook_command || is_claude_hook_command(cmd) || cmd.contains(REWRITE_HOOK_FILE) + .any(|hook| { + let Some(command) = hook.get("command").and_then(|value| value.as_str()) else { + return false; + }; + (is_command_hook(hook) && hook.get("args").is_none() && command == hook_command) + || is_claude_hook_entry(hook) + || (is_command_hook(hook) && command.contains(REWRITE_HOOK_FILE)) }) } @@ -6571,6 +6697,25 @@ mod tests { assert!(hook_already_present(&json_content, CLAUDE_HOOK_COMMAND)); } + #[test] + fn test_hook_already_present_exec_form() { + let json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": r"C:\Users\me\.local\bin\rtk.exe", + "args": ["hook", "claude"], + "timeout": 10 + }] + }] + } + }); + + assert!(hook_already_present(&json_content, CLAUDE_HOOK_COMMAND)); + } + #[test] fn test_hook_not_present_other_hooks() { let json_content = serde_json::json!({ @@ -6587,6 +6732,19 @@ mod tests { let hook_command = "/Users/test/.claude/hooks/rtk-rewrite.sh"; assert!(!hook_already_present(&json_content, hook_command)); + + let prompt_hook = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "prompt", + "command": CLAUDE_HOOK_COMMAND + }] + }] + } + }); + assert!(!hook_already_present(&prompt_hook, CLAUDE_HOOK_COMMAND)); } // Tests for insert_hook_entry() @@ -6612,6 +6770,165 @@ mod tests { assert_eq!(command, hook_command); } + #[test] + fn test_insert_claude_hook_entry_uses_exec_form() { + let mut json_content = serde_json::json!({}); + + insert_hook_entry(&mut json_content, CLAUDE_HOOK_COMMAND).unwrap(); + + let hook = &json_content["hooks"]["PreToolUse"][0]["hooks"][0]; + assert_eq!(hook["command"], "rtk"); + assert_eq!(hook["args"], serde_json::json!(["hook", "claude"])); + } + + #[test] + fn test_upgrade_managed_claude_hook_preserves_fields_order_and_group() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "description": "user-owned group metadata", + "hooks": [ + { + "type": "command", + "command": "third-party-hook" + }, + { + "type": "command", + "command": CLAUDE_HOOK_COMMAND, + "timeout": 10, + "statusMessage": "Optimizing command" + }, + { + "type": "command", + "command": "another-third-party-hook" + } + ] + }] + } + }); + + assert!(upgrade_managed_claude_shell_hooks(&mut root)); + + let hooks = root["hooks"]["PreToolUse"][0]["hooks"].as_array().unwrap(); + assert_eq!(hooks.len(), 3); + assert_eq!( + root["hooks"]["PreToolUse"][0]["description"], + "user-owned group metadata" + ); + assert_eq!(hooks[0]["command"], "third-party-hook"); + assert_eq!(hooks[1]["command"], CLAUDE_HOOK_BINARY); + assert_eq!(hooks[1]["args"], serde_json::json!(CLAUDE_HOOK_ARGS)); + assert_eq!(hooks[1]["timeout"], 10); + assert_eq!(hooks[1]["statusMessage"], "Optimizing command"); + assert_eq!(hooks[2]["command"], "another-third-party-hook"); + } + + #[test] + fn test_upgrade_managed_claude_hook_upgrades_each_entry_without_deduplication() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "third-party-hook" }, + { "type": "command", "command": CLAUDE_HOOK_COMMAND } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": r"C:\Users\me\.local\bin\rtk.exe", + "args": ["hook", "claude"] + }, + { + "type": "command", + "command": CLAUDE_HOOK_COMMAND, + "once": true + } + ] + } + ] + } + }); + + assert!(upgrade_managed_claude_shell_hooks(&mut root)); + assert_eq!( + claude_hook_entries(&root) + .filter(|hook| is_claude_hook_entry(hook)) + .count(), + 3 + ); + assert_eq!( + root["hooks"]["PreToolUse"][0]["hooks"][0]["command"], + "third-party-hook" + ); + assert_eq!( + root["hooks"]["PreToolUse"][0]["hooks"][1]["command"], + CLAUDE_HOOK_BINARY + ); + assert_eq!( + root["hooks"]["PreToolUse"][0]["hooks"][1]["args"], + serde_json::json!(CLAUDE_HOOK_ARGS) + ); + assert_eq!( + root["hooks"]["PreToolUse"][1]["hooks"][0]["command"], + r"C:\Users\me\.local\bin\rtk.exe" + ); + assert_eq!( + root["hooks"]["PreToolUse"][1]["hooks"][1]["command"], + CLAUDE_HOOK_BINARY + ); + assert_eq!( + root["hooks"]["PreToolUse"][1]["hooks"][1]["args"], + serde_json::json!(CLAUDE_HOOK_ARGS) + ); + assert_eq!(root["hooks"]["PreToolUse"][1]["hooks"][1]["once"], true); + } + + #[test] + fn test_upgrade_managed_claude_hook_leaves_unmanaged_entries_unchanged() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "/opt/homebrew/bin/rtk hook claude" + }, + { + "type": "prompt", + "command": CLAUDE_HOOK_COMMAND + }, + { + "type": "command", + "command": CLAUDE_HOOK_COMMAND, + "args": null + } + ] + }, + { + "matcher": "Read", + "hooks": [{ + "type": "command", + "command": CLAUDE_HOOK_COMMAND + }] + } + ] + } + }); + let before = root.clone(); + + assert!(!upgrade_managed_claude_shell_hooks(&mut root)); + assert_eq!(root, before); + assert!(hook_already_present(&root, CLAUDE_HOOK_COMMAND)); + } + #[test] fn test_insert_hook_entry_preserves_existing() { let mut json_content = serde_json::json!({ @@ -6858,6 +7175,82 @@ mod tests { ); } + #[test] + fn test_remove_hook_from_json_exec_form() { + let mut json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "/some/other/hook.sh" + }, + { + "type": "prompt", + "command": CLAUDE_HOOK_BINARY, + "args": ["hook", "claude"] + }, + { + "type": "command", + "command": r"C:\Users\me\.local\bin\rtk.exe", + "args": ["hook", "claude"] + } + ] + }] + } + }); + + assert!(remove_hook_from_json(&mut json_content)); + let pre_tool_use = json_content["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 1); + assert_eq!(pre_tool_use[0]["hooks"].as_array().unwrap().len(), 2); + assert_eq!( + pre_tool_use[0]["hooks"][0]["command"].as_str().unwrap(), + "/some/other/hook.sh" + ); + assert_eq!(pre_tool_use[0]["hooks"][1]["type"], "prompt"); + } + + #[test] + fn test_remove_hook_preserves_unrelated_empty_and_extended_groups() { + let mut json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [] + }, + { + "matcher": "Bash", + "description": "keep this group metadata", + "hooks": [{ + "type": "command", + "command": CLAUDE_HOOK_BINARY, + "args": CLAUDE_HOOK_ARGS + }] + }, + { + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": CLAUDE_HOOK_BINARY, + "args": CLAUDE_HOOK_ARGS + }] + } + ] + } + }); + + assert!(remove_hook_from_json(&mut json_content)); + + let groups = json_content["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0]["hooks"], serde_json::json!([])); + assert_eq!(groups[1]["description"], "keep this group metadata"); + assert_eq!(groups[1]["hooks"], serde_json::json!([])); + } + #[test] fn test_remove_hook_when_not_present() { let mut json_content = serde_json::json!({ @@ -7166,6 +7559,18 @@ mod tests { /// Serialises all tests that mutate the process-wide working directory. static CWD_LOCK: Mutex<()> = Mutex::new(()); + fn claude_hook_entry_count(root: &serde_json::Value) -> usize { + root.get("hooks") + .and_then(|hooks| hooks.get(PRE_TOOL_USE_KEY)) + .and_then(|pre_tool_use| pre_tool_use.as_array()) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() + .filter(|hook| is_claude_hook_entry(hook)) + .count() + } + fn with_claude_dir_override(tmp: &TempDir, f: F) { let _guard = CLAUDE_DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let claude_dir = tmp.path().join(CLAUDE_DIR); @@ -7194,6 +7599,73 @@ mod tests { } } + fn managed_shell_settings() -> String { + serde_json::to_string_pretty(&serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": CLAUDE_HOOK_COMMAND, + "timeout": 10 + }] + }] + } + })) + .unwrap() + } + + #[test] + fn test_global_default_mode_upgrades_managed_shell_hook() { + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + let settings_path = claude_dir.join(SETTINGS_JSON); + fs::write(&settings_path, managed_shell_settings()).unwrap(); + + run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + + let root: serde_json::Value = + serde_json::from_str(&fs::read_to_string(settings_path).unwrap()).unwrap(); + assert_eq!(claude_hook_entry_count(&root), 1); + let hook = claude_hook_entries(&root).next().unwrap(); + assert_eq!(hook["command"], CLAUDE_HOOK_BINARY); + assert_eq!(hook["args"], serde_json::json!(CLAUDE_HOOK_ARGS)); + assert_eq!(hook["timeout"], 10); + }); + } + + #[test] + fn test_shell_hook_upgrade_respects_skip_and_dry_run_modes() { + let tmp = TempDir::new().unwrap(); + with_claude_dir_override(&tmp, |claude_dir| { + let settings_path = claude_dir.join(SETTINGS_JSON); + let original = managed_shell_settings(); + let cases = [ + (PatchMode::Skip, InitContext::default()), + ( + PatchMode::Auto, + InitContext { + dry_run: true, + ..Default::default() + }, + ), + ( + PatchMode::Ask, + InitContext { + dry_run: true, + ..Default::default() + }, + ), + ]; + + for (mode, ctx) in cases { + fs::write(&settings_path, &original).unwrap(); + patch_settings_json_command(CLAUDE_HOOK_COMMAND, mode, false, ctx).unwrap(); + assert_eq!(fs::read_to_string(&settings_path).unwrap(), original); + } + }); + } + #[test] fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); @@ -7209,8 +7681,9 @@ mod tests { let settings = claude_dir.join(SETTINGS_JSON); assert!(settings.exists(), "settings.json must be created"); let content = fs::read_to_string(&settings).unwrap(); + let root = serde_json::from_str(&content).unwrap(); assert!( - content.contains(CLAUDE_HOOK_COMMAND), + hook_already_present(&root, CLAUDE_HOOK_COMMAND), "settings.json must contain hook command" ); }); @@ -7226,8 +7699,9 @@ mod tests { assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); let settings_content = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap_or_default(); + let root = serde_json::from_str(&settings_content).unwrap(); assert!( - !settings_content.contains(CLAUDE_HOOK_COMMAND), + !hook_already_present(&root, CLAUDE_HOOK_COMMAND), "hook entry must be removed from settings.json" ); }); @@ -7241,7 +7715,8 @@ mod tests { run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); - let count = settings.matches(CLAUDE_HOOK_COMMAND).count(); + let root = serde_json::from_str(&settings).unwrap(); + let count = claude_hook_entry_count(&root); assert_eq!(count, 1, "hook command must appear exactly once"); }); } @@ -7261,8 +7736,9 @@ mod tests { assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); + let root = serde_json::from_str(&settings).unwrap(); assert!( - settings.contains(CLAUDE_HOOK_COMMAND), + hook_already_present(&root, CLAUDE_HOOK_COMMAND), "hook must be in settings.json after upgrade" ); }); @@ -7300,8 +7776,9 @@ mod tests { "RTK.md must NOT be created in hook-only mode" ); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); + let root = serde_json::from_str(&settings).unwrap(); assert!( - settings.contains(CLAUDE_HOOK_COMMAND), + hook_already_present(&root, CLAUDE_HOOK_COMMAND), "settings.json must contain hook command" ); }); diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index 21a22716ee..2fc1c87ae6 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -1,6 +1,6 @@ //! Detects if someone tampered with the installed hook file. //! -//! RTK installs a PreToolUse hook (`rtk-rewrite.sh`) that auto-approves +//! Legacy RTK installs used a `rtk-rewrite.sh` PreToolUse hook that auto-approved //! rewritten commands with `permissionDecision: "allow"`. Because this //! hook bypasses Claude Code's permission prompts, any unauthorized //! modification represents a command injection vector. @@ -14,7 +14,7 @@ use super::constants::{HOOKS_SUBDIR, PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE}; use super::init::resolve_claude_dir; -use super::is_claude_hook_command; +use super::is_claude_hook_entry; use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; use std::fs; @@ -375,8 +375,7 @@ fn settings_has_claude_hook(content: &str) -> bool { .flatten() .filter_map(|entry| entry.get("hooks")?.as_array()) .flatten() - .filter_map(|hook| hook.get("command")?.as_str()) - .any(is_claude_hook_command) + .any(is_claude_hook_entry) } #[cfg(test)] @@ -442,6 +441,25 @@ mod tests { assert!(settings_has_claude_hook(settings)); } + #[test] + fn test_settings_has_claude_hook_accepts_windows_exec_form() { + let settings = r#"{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "C:\\Users\\me\\.local\\bin\\rtk.exe", + "args": ["hook", "claude"], + "timeout": 10 + }] + }] + } + }"#; + + assert!(settings_has_claude_hook(settings)); + } + #[test] fn test_verify_detects_tampering() { let temp = TempDir::new().unwrap(); diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index fe7f4c3f1d..94fab3175a 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -12,15 +12,39 @@ pub mod rewrite_cmd; pub mod trust; pub mod verify_cmd; +fn is_rtk_binary(binary: &str) -> bool { + let binary_name = binary.rsplit(['/', '\\']).next().unwrap_or(binary); + binary_name == constants::CLAUDE_HOOK_BINARY || binary_name.eq_ignore_ascii_case("rtk.exe") +} + pub fn is_claude_hook_command(command: &str) -> bool { let parts = crate::discover::lexer::shell_split(command); let [binary, hook, claude] = parts.as_slice() else { return false; }; - let binary_name = binary.rsplit(['/', '\\']).next().unwrap_or(binary); + is_rtk_binary(binary) && hook == "hook" && claude == "claude" +} - binary_name == "rtk" && hook == "hook" && claude == "claude" +pub fn is_claude_hook_entry(hook: &serde_json::Value) -> bool { + if hook.get("type").and_then(serde_json::Value::as_str) != Some("command") { + return false; + } + let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) else { + return false; + }; + match hook.get("args") { + None => is_claude_hook_command(command), + Some(serde_json::Value::Array(args)) => { + let [hook_arg, claude_arg] = args.as_slice() else { + return false; + }; + is_rtk_binary(command) + && hook_arg.as_str() == Some(constants::CLAUDE_HOOK_ARGS[0]) + && claude_arg.as_str() == Some(constants::CLAUDE_HOOK_ARGS[1]) + } + Some(_) => false, + } } #[cfg(test)] @@ -30,6 +54,7 @@ mod tests { #[test] fn claude_hook_command_matches_bare_and_absolute_rtk() { assert!(is_claude_hook_command("rtk hook claude")); + assert!(is_claude_hook_command("rtk.exe hook claude")); assert!(is_claude_hook_command("/opt/homebrew/bin/rtk hook claude")); assert!(is_claude_hook_command( "\"/opt/homebrew/bin/rtk\" hook claude" @@ -42,4 +67,38 @@ mod tests { assert!(!is_claude_hook_command("/opt/homebrew/bin/rtk hook cursor")); assert!(!is_claude_hook_command("echo rtk hook claude")); } + + #[test] + fn claude_hook_entry_matches_windows_exec_form() { + let hook = serde_json::json!({ + "type": "command", + "command": r"C:\Users\me\.local\bin\rtk.exe", + "args": ["hook", "claude"] + }); + + assert!(is_claude_hook_entry(&hook)); + } + + #[test] + fn claude_hook_entry_does_not_treat_empty_args_as_shell_form() { + let empty_args = serde_json::json!({ + "type": "command", + "command": "rtk hook claude", + "args": [] + }); + let wrong_args = serde_json::json!({ + "type": "command", + "command": "rtk.exe", + "args": ["hook", "cursor"] + }); + let prompt_hook = serde_json::json!({ + "type": "prompt", + "command": "rtk.exe", + "args": ["hook", "claude"] + }); + + assert!(!is_claude_hook_entry(&empty_args)); + assert!(!is_claude_hook_entry(&wrong_args)); + assert!(!is_claude_hook_entry(&prompt_hook)); + } }