Skip to content

fix(hook): replace rtk-rewrite.sh with native rtk hook claude (no jq dependency)#862

Open
FlorianBruniaux wants to merge 5 commits intodevelopfrom
fix/hook-claude-no-jq
Open

fix(hook): replace rtk-rewrite.sh with native rtk hook claude (no jq dependency)#862
FlorianBruniaux wants to merge 5 commits intodevelopfrom
fix/hook-claude-no-jq

Conversation

@FlorianBruniaux
Copy link
Copy Markdown
Collaborator

@FlorianBruniaux FlorianBruniaux commented Mar 26, 2026

Problem

`rtk init -g` installs a shell script (`rtk-rewrite.sh`) as a Claude Code PreToolUse hook. That script has a hard dependency on `jq` to parse and build JSON.

When `jq` is not installed, the hook silently exits 0 with no output — Claude Code sees no rewrite, no error, nothing. Users end up with RTK installed but zero commands being rewritten, with no indication of why.

The warning is printed to stderr inside the hook process, which is swallowed by Claude Code's hook runner. So the user never sees it:

```bash
if ! command -v jq &>/dev/null; then
echo "[rtk] WARNING: jq is not installed..." >&2
exit 0 # silent passthrough — user never knows
fi
```

This affects anyone on macOS who did not install jq via Homebrew, and Linux users on minimal installs.

Solution

Replace the shell script with a native Rust subcommand `rtk hook claude` that uses `serde_json` for JSON parsing. No external dependencies — if `rtk` is in PATH, the hook works.

The hook command registered in `settings.json` changes from:
```json
{ "command": "/Users/you/.claude/hooks/rtk-rewrite.sh" }
```
to:
```json
{ "command": "rtk hook claude" }
```

Changes

  • Add `rtk hook claude` subcommand (`hook_cmd.rs`): reads Claude Code PreToolUse JSON from stdin, rewrites Bash tool commands, preserves all `tool_input` fields (description, etc.)
  • Extract core logic into `process_claude_hook(input: &str) -> Option` for direct unit testability
  • Update `rtk init -g` to register `rtk hook claude` instead of writing/installing `rtk-rewrite.sh`
  • Auto-migrate: if `rtk-rewrite.sh` is already in `settings.json`, re-running `rtk init -g` replaces it in-place with `rtk hook claude`
  • Remove `prepare_hook_paths` and `ensure_hook_installed` (dead code after this change)
  • Legacy `hooks/rtk-rewrite.sh` stays in the repo for reference and for users who installed it manually

Upgrade path for existing users

```bash
cargo install rtk # or: brew upgrade rtk-ai/tap/rtk
rtk init -g # auto-migrates rtk-rewrite.sh → rtk hook claude in settings.json

Restart Claude Code

```

Test plan

  • `rtk hook claude` rewrites `git status` → `rtk git status`
  • Non-Bash tools (Edit, Write...) pass through silently
  • Already-`rtk`-prefixed commands pass through silently
  • `rtk init -g` on fresh install registers `rtk hook claude` in settings.json
  • `rtk init -g` on existing install with `rtk-rewrite.sh` migrates automatically
  • 1139 unit tests pass, 0 clippy warnings

Closes #430. Implements #154.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings March 26, 2026 14:54
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR migrates the Claude Code PreToolUse rewrite hook from an installed shell script (with jq dependency) to a native Rust subcommand (rtk hook claude), and updates global init to register/migrate the hook in ~/.claude/settings.json.

Changes:

  • Add rtk hook claude subcommand and core hook logic (process_claude_hook) with unit tests.
  • Update rtk init -g to install the hook as rtk hook claude (and migrate legacy rtk-rewrite.sh settings entries).
  • Adjust legacy hook script handling/tests and related init output.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/hook_cmd.rs Adds native Claude hook processing + tests.
src/main.rs Registers hook claude subcommand and dispatch.
src/init.rs Stops installing shell hook file; patches/migrates Claude settings.json to use rtk hook claude; updates tests/output.
src/integrity.rs Makes legacy hook hash baseline writer test-only.
src/rake_cmd.rs Minor Rust idiom tweak (is_some_and).

Comment on lines +161 to +163
// Only handle Bash tool
let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or("");
if tool_name != "Bash" {
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.
Comment on lines +661 to +676
// Migrate old rtk-rewrite.sh hook to native command
if migrate_legacy_hook(&mut root, hook_command) {
if verbose > 0 {
eprintln!("Migrated rtk-rewrite.sh → {hook_command}");
}
let serialized =
serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?;
atomic_write(&settings_path, &serialized)?;
println!("\n settings.json: migrated rtk-rewrite.sh → {hook_command}");
if include_opencode {
println!(" Restart Claude Code and OpenCode. Test with: git status");
} else {
println!(" Restart Claude Code. Test with: git status");
}
return Ok(PatchResult::Patched);
}
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.

patch_settings_json performs legacy-hook migration and writes settings.json before honoring PatchMode. This means --no-patch (Skip) and Ask can still modify an existing settings file without consent, which is inconsistent with the documented patch modes. Consider gating migration behind the same mode/consent flow (or only auto-migrating when mode is Auto).

Copilot uses AI. Check for mistakes.
eprintln!("Migrated rtk-rewrite.sh → {hook_command}");
}
let serialized =
serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?;
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.

During the migration path, settings.json is rewritten via atomic_write without creating the usual .json.bak backup. Since this is modifying an existing user config file, it should follow the same backup behavior as the normal patch flow (copy to .json.bak before the write).

Suggested change
serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?;
serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?;
// Backup original before migration, consistent with normal patch flow
if settings_path.exists() {
let backup_path = settings_path.with_extension("json.bak");
fs::copy(&settings_path, &backup_path).with_context(|| {
format!("Failed to backup to {}", backup_path.display())
})?;
if verbose > 0 {
eprintln!("Backup: {}", backup_path.display());
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +904 to +906
// 3. Print success message
println!("\nRTK installed (global).\n");
println!(" Hook: {CLAUDE_HOOK_CMD} (built-in, no dependencies)");
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.

Now that rtk init -g installs the Claude hook as a command (rtk hook claude) rather than writing ~/.claude/hooks/rtk-rewrite.sh, the rest of the codebase should stop treating the presence/version of rtk-rewrite.sh as the indicator of a working Claude hook. As-is, hook_check/integrity will likely report the hook as missing after a successful install, causing confusing warnings on every run. Consider updating the hook health checks to look for the settings.json entry (and treat the shell script as legacy-only).

Copilot uses AI. Check for mistakes.
@@ -63,6 +63,7 @@ fn hash_path(hook_path: &Path) -> PathBuf {
/// against casual modification. Not a security boundary — an
/// attacker with write access can chmod it — but forces a
/// deliberate action rather than accidental overwrite.
#[cfg(test)]
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.

Switching store_hash to #[cfg(test)] makes it impossible for production code to establish an integrity baseline for the legacy rtk-rewrite.sh hook, yet rtk verify/runtime_check still describe and depend on that baseline. If the intent is to deprecate file-based hook integrity entirely, the user-facing verify/runtime logic should be updated accordingly; otherwise store_hash likely needs to remain available (and called) when installing/updating the legacy hook.

Suggested change
#[cfg(test)]

Copilot uses AI. Check for mistakes.
const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh");

// Native Rust hook command: no jq dependency, no shell script file to write
const CLAUDE_HOOK_CMD: &str = "rtk hook claude";

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.

With the hook command now being rtk hook claude, uninstall/removal logic needs to treat that command as an RTK hook entry too. Currently the settings.json removal path only matches commands containing rtk-rewrite.sh, so rtk init -g --uninstall will leave the new hook entry behind. Consider expanding removal matching to include the native command as well.

Suggested change
/// Identifiers that mark a VS Code / Cursor hook entry as managed by RTK.
///
/// This must include both the legacy shell script and the native hook command so that
/// uninstall/removal logic can correctly detect and remove all RTK hook entries
/// from settings.json (e.g. in `rtk init -g --uninstall`).
const RTK_HOOK_IDENTIFIERS: [&str; 2] = ["rtk-rewrite.sh", CLAUDE_HOOK_CMD];

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

@pszymkowiak pszymkowiak left a comment

Choose a reason for hiding this comment

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

Excellent work @FlorianBruniaux — this is significantly better than #785. The migration logic (migrate_legacy_hook), the 10 tests, the description field preservation, and the correct absence of permissionDecision in the output all show careful attention to the Claude Code protocol.

The jq removal solves a real pain point (#430) and the native Rust path is the right direction.

P0 — Security: permissions deny/ask not called

process_claude_hook() calls get_rewritten()rewrite_command() but never check_command() from src/permissions.rs. The current bash script calls rtk rewrite which internally runs check_command() and exits with:

  • Exit 2 → deny (pass through unchanged, Claude Code native deny handles it)
  • Exit 3 → ask (rewrite but omit permissionDecision, Claude Code prompts user)
  • Exit 0 → allow (rewrite + auto-allow)

Without this, any command matching a user's permissions.deny or permissions.ask rules in settings.json is silently auto-allowed. This is a security regression from PR #576.

Fix sketch:

pub(crate) fn process_claude_hook(input: &str) -> Option<String> {
    // ... parse JSON, extract cmd ...
    
    // Check permissions BEFORE rewriting
    match crate::permissions::check_command(cmd) {
        PermissionVerdict::Deny => return None,  // pass through, Claude Code handles deny
        PermissionVerdict::Ask => {
            let rewritten = get_rewritten(cmd)?;
            // Rewrite but omit permissionDecision — Claude Code will prompt
            return Some(json!({
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "updatedInput": build_updated_input(&v, &rewritten)
                }
            }).to_string());
        }
        PermissionVerdict::Allow => {}  // continue to normal rewrite
    }
    
    let rewritten = get_rewritten(cmd)?;
    // ... current code with permissionDecision omitted (correct) ...
}

P1 — Audit logging dropped

RTK_HOOK_AUDIT=1 writes to ~/.local/share/rtk/hook-audit.log in the bash script. The native command doesn't replicate this. Users relying on rtk hook-audit for rewrite metrics will get an empty log. Gate on std::env::var("RTK_HOOK_AUDIT") and write the same pipe-separated format.

P1 — Integrity check orphaned

store_hash() is now #[cfg(test)] only, but runtime_check() in integrity.rs still looks for ~/.claude/hooks/rtk-rewrite.sh. After migration, the file is gone → runtime_check() returns NotInstalled forever. Either disable runtime_check when using native hook mode, or adapt it to verify the "rtk hook claude" entry in settings.json instead.

P2 — Uninstall path

Does rtk uninstall also clean up the "rtk hook claude" entry from settings.json? The current uninstall removes rtk-rewrite.sh file — verify it also handles the new command string.

P2 — Scope: other agents

rtk hook gemini and rtk hook copilot already exist as native Rust commands. Do they also lack the permission check? Worth verifying for consistency — if Claude gets deny/ask, Gemini and Copilot should too.


Summary: fix the permissions check (P0), add audit logging (P1), and this is ready. The core implementation is solid — much cleaner than #785. Also closes the discussion on #785 since this PR supersedes it.

@pszymkowiak
Copy link
Copy Markdown
Collaborator

Additional P1 — runtime_check() and rtk verify silently broken

Following up on the integrity point: the current runtime_check() in integrity.rs runs on every RTK command and checks that ~/.claude/hooks/rtk-rewrite.sh exists + SHA-256 matches. After this PR:

  • The file is no longer installed → runtime_check() returns NotInstalled (silent, no warning)
  • rtk verify reports "SKIP RTK hook not installed" even though the hook IS installed (as "rtk hook claude" in settings.json)
  • The "run rtk init -g" nudge in rtk gain stops working

We lose:

  1. Presence check — no way to detect if the user forgot rtk init -g
  2. Tampering detection — less critical now (no external script to tamper with), but the presence check matters
  3. rtk verify — broken output, confusing for users

The fix: adapt runtime_check() and rtk verify to check for "rtk hook claude" in ~/.claude/settings.json instead of looking for the .sh file. No SHA-256 needed anymore (the binary is the hook), just verify the entry exists in the hooks array.

Something like:

pub fn verify_hook() -> Result<IntegrityStatus> {
    let settings = read_settings_json()?;
    if hook_entry_present(&settings, "rtk hook claude") {
        Ok(IntegrityStatus::Verified)
    } else if hook_entry_present(&settings, "rtk-rewrite.sh") {
        Ok(IntegrityStatus::NoBaseline) // legacy, needs migration
    } else {
        Ok(IntegrityStatus::NotInstalled)
    }
}

This keeps rtk verify, rtk gain nudge, and presence detection working with the new native hook.

@aeppling
Copy link
Copy Markdown
Contributor

Hey

We are cleaning up the codebase and improving the project structure for better onboarding. As part of this effort, PR #826 reorganizes src/ from a flat layout into subfolders.

No logic changes — only file moves and import path updates.

What you need to do

Rebase your branch on develop when receiving this comment:

git fetch origin && git rebase origin/develop

Git detects renames automatically. If you get import conflicts, update the paths:

use crate::git;        // now: use crate::cmds::git::git;
use crate::tracking;   // now: use crate::core::tracking;
use crate::config;     // now: use crate::core::config;
use crate::init;       // now: use crate::hooks::init;
use crate::gain;       // now: use crate::analytics::gain;

Need help rebasing? Tag @aeppling

FlorianBruniaux and others added 5 commits March 27, 2026 10:47
Replace rtk-rewrite.sh (requires jq) with a native Rust subcommand
`rtk hook claude` that reads Claude Code PreToolUse JSON from stdin
and rewrites Bash tool commands to rtk equivalents.

- Add `run_claude()` in hook_cmd.rs: parses JSON via serde_json,
  preserves all tool_input fields (description etc.), rewrites command
- Add `HookCommands::Claude` variant and match arm in main.rs
- Update `rtk init -g` to register `rtk hook claude` in settings.json
  instead of writing/installing rtk-rewrite.sh
- Add migration: if legacy rtk-rewrite.sh entry is found in
  settings.json, automatically replace it with `rtk hook claude`
- Update `hook_already_present` to detect both legacy and native commands
- Remove `prepare_hook_paths` and `ensure_hook_installed` (dead code)
- Mark `store_hash` and `REWRITE_HOOK` as #[cfg(test)] (test-only now)
- Fix pre-existing clippy warning in rake_cmd.rs (map_or → is_some_and)
- Add 5 new tests: Claude hook output format, migration, idempotency

Fixes #430 (silent jq failure). Implements #154 (migrate to Rust).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
Extract core Claude Code hook logic into process_claude_hook(input: &str)
-> Option<String> so tests call the real function, not hand-rolled JSON.

- Replace indirect tests (manual JSON parsing, helper calls) with direct
  calls to process_claude_hook() on full hook input JSON
- Add tests: cargo test rewrite, empty input, invalid JSON, heredoc,
  output schema validation (no permissionDecision in output)
- Update test_default_mode_creates_hook_and_rtk_md → split into
  test_claude_hook_cmd_constant (verifies CLAUDE_HOOK_CMD = "rtk hook claude")
  and test_legacy_hook_script_has_proper_guards (legacy shell script docs)
- 1139 tests total, 0 failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
- CHANGELOG: add Unreleased entries for rtk hook claude feature and
  jq silent failure bug fix (#430)
- README: update Claude Code row in supported tools table to show
  rtk hook claude method; add migration note in Claude Code section
- INSTALL: replace rtk-rewrite.sh references with rtk hook claude,
  update flow diagram and uninstall docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
…aude

- verify_hook() now checks ~/.claude/settings.json for "rtk hook claude"
  instead of looking for the rtk-rewrite.sh file + SHA-256 hash
- Verified = rtk hook claude present in PreToolUse hooks
- NoBaseline = legacy rtk-rewrite.sh detected (still works, needs migration)
- NotInstalled = neither hook registered
- run_verify() output updated to reflect settings.json check
- runtime_check() simplified (all settings.json states are non-blocking)
- show_claude_config() (rtk init --show) now reports hook presence from
  settings.json instead of the old file-based integrity check
- verify_hook_at() + read_stored_hash() gated #[cfg(test)] (legacy SHA-256
  logic only needed by existing hash-based tests)
- 7 new tests for verify_hook_in_settings() covering: native hook, legacy
  hook, missing file, empty object, no hooks key, no PreToolUse, unrelated cmd

Fixes: rtk verify reporting "SKIP RTK hook not installed" even when
rtk hook claude is registered; rtk init --show showing stale hook file status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
CLAUDE.md is the AI guidance file, not user-facing docs.
Python/Go command coverage check should only apply to README.md.
The check was causing false failures on develop itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants