Skip to content

feat(hook): native Claude Code hook for Windows (no bash/jq required)#954

Open
Warrio111 wants to merge 1 commit intortk-ai:developfrom
Warrio111:feat/windows-native-hook-rewrite
Open

feat(hook): native Claude Code hook for Windows (no bash/jq required)#954
Warrio111 wants to merge 1 commit intortk-ai:developfrom
Warrio111:feat/windows-native-hook-rewrite

Conversation

@Warrio111
Copy link
Copy Markdown

Problem

On Windows, rtk init -g prints a warning and silently falls back to --claude-md mode because the Claude Code hook was implemented as a bash script (rtk-rewrite.sh) requiring bash + jq — neither available natively on Windows.

[warn] Hook-based mode requires Unix (macOS/Linux).
    Windows: use --claude-md mode for full injection.
    Falling back to --claude-md mode.

This means Windows users get no automatic command rewriting in Claude Code sessions.

Solution

Add rtk hook claude — a native Rust subcommand that reads Claude Code's PreToolUse JSON from stdin and rewrites commands, with zero external dependencies.

The implementation reuses the existing run_copilot() logic (which already handles Claude Code's JSON format: tool_name: "Bash" + tool_input.command).

On Windows, rtk init -g now installs "<rtk.exe>" hook claude directly in settings.json instead of falling back.

Changes

File Change
src/hooks/hook_cmd.rs Add pub fn run_claude() — wraps run_copilot(), same JSON format
src/main.rs Add HookCommands::Claude subcommand routing to run_claude()
src/hooks/init.rs native_hook_command(), patch_settings_native(), Windows run_default_mode installs native hook, hook_already_present/remove_hook_from_json recognize "hook claude" entries

Before / After

Before (Windows):

$ rtk init -g
[warn] Hook-based mode requires Unix (macOS/Linux).
    Falling back to --claude-md mode.

After (Windows):

$ rtk init -g
RTK hook installed (Windows native).

  Hook:      "C:\Users\...\rtk.exe" hook claude
  RTK.md:    C:\Users\...\.claude\RTK.md (10 lines)
  CLAUDE.md: @RTK.md reference added
  settings.json: hook added

Relation to PR #150

PR #150 proposes a similar fix with a full standalone reimplementation (1126 lines). This PR takes a simpler approach: the existing run_copilot() already handles Claude Code's hook format, so run_claude() is a thin wrapper. The result is ~160 lines of changes vs ~1200, with no new dependencies and no structural refactor.

Test plan

  • cargo check passes on Windows (0 errors)
  • cargo test hooks — 124 tests, 0 failures
  • rtk hook claude subcommand routes correctly
  • hook_already_present recognizes existing "hook claude" entries (idempotent)
  • remove_hook_from_json removes "hook claude" entries on uninstall

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings March 31, 2026 18:59
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

Adds a Windows-native Claude Code hook entrypoint so rtk init -g can install a hook without relying on bash/jq, enabling automatic command rewriting in Claude Code on Windows.

Changes:

  • Add rtk hook claude subcommand routing (native hook processor).
  • Update Windows rtk init -g default path to install a native hook command into ~/.claude/settings.json.
  • Extend hook detection/removal logic to recognize "hook claude" entries.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.

File Description
src/main.rs Adds hook claude subcommand to the CLI router and documents intended settings.json usage.
src/hooks/init.rs Implements Windows-native hook install flow (native_hook_command, patch_settings_native) and recognizes/removes hook claude entries.
src/hooks/hook_cmd.rs Adds run_claude() wrapper that reuses existing Copilot/Claude-compatible hook JSON handling.

Comment on lines +918 to +924
println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:");
println!(" {{");
println!(" \"hooks\": {{ \"PreToolUse\": [{{");
println!(" \"matcher\": \"Bash\",");
println!(" \"hooks\": [{{ \"type\": \"command\",");
println!(" \"command\": \"{}\"", hook_command);
println!(" }}]");
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

In PatchMode::Skip, the manual settings.json snippet prints the hook_command inside JSON quotes, but hook_command itself contains embedded double-quotes ("") so the resulting example line is invalid JSON (unescaped quotes). Consider JSON-escaping the command when printing (e.g., serialize hook_command as a JSON string) or avoid embedding quotes in the displayed snippet.

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

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

Comment on lines +976 to +981
let claude_dir = resolve_claude_dir()?;
let rtk_md_path = claude_dir.join("RTK.md");
let claude_md_path = claude_dir.join("CLAUDE.md");

// 1. Write RTK.md
write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Windows run_default_mode writes ~/.claude/RTK.md and ~/.claude/CLAUDE.md via write_if_changed/patch_claude_md, but unlike the unix path it never creates the ~/.claude directory first. If the user runs rtk init -g before Claude has created the folder, these writes will fail. Consider fs::create_dir_all(&claude_dir) right after resolve_claude_dir() (and/or ensure parents exist before each write).

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

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

Comment on lines +990 to +1007
println!("\nRTK hook installed (Windows native).\n");
println!(" Hook: {} hook claude", hook_command);
println!(" RTK.md: {} (10 lines)", rtk_md_path.display());
println!(" CLAUDE.md: @RTK.md reference added");

if migrated {
println!("\n [ok] Migrated: removed RTK block from CLAUDE.md, replaced with @RTK.md");
}

match patch_result {
PatchResult::Patched => {}
PatchResult::AlreadyPresent => {
println!("\n settings.json: hook already present");
println!(" Restart Claude Code. Test with: git status");
}
PatchResult::Declined | PatchResult::Skipped => {}
}

Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

This success banner is printed unconditionally, even when patch_settings_native returns Declined/Skipped (user opted out or manual mode). That can mislead users into thinking the hook is installed when settings.json wasn’t patched. Consider tailoring the message based on PatchResult (e.g., only say “installed” on Patched/AlreadyPresent, and otherwise say “manual step required”).

Suggested change
println!("\nRTK hook installed (Windows native).\n");
println!(" Hook: {} hook claude", hook_command);
println!(" RTK.md: {} (10 lines)", rtk_md_path.display());
println!(" CLAUDE.md: @RTK.md reference added");
if migrated {
println!("\n [ok] Migrated: removed RTK block from CLAUDE.md, replaced with @RTK.md");
}
match patch_result {
PatchResult::Patched => {}
PatchResult::AlreadyPresent => {
println!("\n settings.json: hook already present");
println!(" Restart Claude Code. Test with: git status");
}
PatchResult::Declined | PatchResult::Skipped => {}
}
match patch_result {
PatchResult::Patched => {
println!("\nRTK hook installed (Windows native).\n");
println!(" Hook: {} hook claude", hook_command);
println!(" RTK.md: {} (10 lines)", rtk_md_path.display());
println!(" CLAUDE.md: @RTK.md reference added");
println!(" settings.json: hook added");
println!(" Restart Claude Code. Test with: git status");
}
PatchResult::AlreadyPresent => {
println!("\nRTK hook installed (Windows native).\n");
println!(" Hook: {} hook claude", hook_command);
println!(" RTK.md: {} (10 lines)", rtk_md_path.display());
println!(" CLAUDE.md: @RTK.md reference added");
println!(" settings.json: hook already present");
println!(" Restart Claude Code. Test with: git status");
}
PatchResult::Declined | PatchResult::Skipped => {
println!("\nRTK hook not installed into settings.json (user opted out / manual mode).\n");
println!(" Hook command (for manual setup): {} hook claude", hook_command);
println!(" RTK.md: {} (10 lines)", rtk_md_path.display());
println!(" CLAUDE.md: @RTK.md reference added");
println!(" Next step: update settings.json manually to call the hook command above.");
}
}
if migrated {
println!("\n [ok] Migrated: removed RTK block from CLAUDE.md, replaced with @RTK.md");
}

Copilot uses AI. Check for mistakes.
Comment on lines 865 to 870
.any(|cmd| {
// Exact match OR both contain rtk-rewrite.sh
// Exact match OR both contain same hook fingerprint
cmd == hook_command
|| (cmd.contains("rtk-rewrite.sh") && hook_command.contains("rtk-rewrite.sh"))
|| (cmd.contains("hook claude") && hook_command.contains("hook claude"))
})
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

hook_already_present now treats any command containing hook claude as an installed RTK hook, but the existing unit tests only cover the rtk-rewrite.sh fingerprint. Please add a test case for a native Windows command (e.g., "C:\\...\\rtk.exe" hook claude) to ensure idempotency works for the new hook path.

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

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

Comment on lines 472 to 476
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()) {
if command.contains("rtk-rewrite.sh") {
if command.contains("rtk-rewrite.sh") || command.contains("hook claude") {
return false; // Remove this entry
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

remove_hook_from_json now removes entries whose command contains hook claude, but the existing remove_hook_from_json tests only cover removal of rtk-rewrite.sh. Please add a test that verifies native hook commands are removed on uninstall without affecting other hooks.

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

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

@Warrio111 Warrio111 changed the base branch from master to develop March 31, 2026 19:06
@Warrio111 Warrio111 force-pushed the feat/windows-native-hook-rewrite branch from b6abc40 to e831a63 Compare March 31, 2026 19:14
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 31, 2026

CLA assistant check
All committers have signed the CLA.

@Warrio111 Warrio111 force-pushed the feat/windows-native-hook-rewrite branch from e831a63 to 02d9683 Compare March 31, 2026 19:20
@Warrio111
Copy link
Copy Markdown
Author

@copilot apply changes based on the comments in this thread

@aeppling
Copy link
Copy Markdown
Contributor

aeppling commented Apr 3, 2026

Hello, i have a few things to solve on others PR then i can get to you for this review

You can run all test on your machine and repush once solved because you got failing a check

…red)

Adds `rtk hook claude` subcommand that reads Claude Code PreToolUse JSON
from stdin and rewrites commands natively — no bash or jq dependency.

On Windows, `rtk init -g` now installs `rtk hook claude` directly in
settings.json instead of printing a warning and falling back to --claude-md.

Changes:
- src/hooks/hook_cmd.rs: add run_claude() (wraps run_copilot(), same format)
- src/main.rs: add HookCommands::Claude routing to run_claude()
- src/hooks/init.rs:
  - native_hook_command(): resolves rtk binary path
  - patch_settings_native(): installs command string in settings.json
  - fs::create_dir_all before writing RTK.md/CLAUDE.md on Windows
  - JSON-escape hook_command in manual snippet to avoid invalid JSON
  - Conditional banner based on PatchResult (not unconditional)
  - hook_already_present/remove_hook_from_json recognize 'hook claude'
  - Tests: hook_already_present and remove_hook_from_json for native hook
@Warrio111 Warrio111 force-pushed the feat/windows-native-hook-rewrite branch from e96e4a3 to f631af6 Compare April 3, 2026 18:54
@Warrio111
Copy link
Copy Markdown
Author

Sorry @aeppling I didn't saw the failure last day.

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