Skip to content

feat(init): add --native-hook for cross-platform hook support (Windows) - #1090

Closed
ayoub-khemissi wants to merge 2 commits into
rtk-ai:developfrom
ayoub-khemissi:feat/native-hook-v2
Closed

feat(init): add --native-hook for cross-platform hook support (Windows)#1090
ayoub-khemissi wants to merge 2 commits into
rtk-ai:developfrom
ayoub-khemissi:feat/native-hook-v2

Conversation

@ayoub-khemissi

Copy link
Copy Markdown

Context: Why a New PR Instead of Rebasing #150

PR #150 (feat/native-hook-rewrite) added native cross-platform hooks via 8 per-language Rust modules (src/hook/git.rs, cargo.rs, files.rs, etc. -- 1905 lines across 14 files).

Since then, upstream develop has evolved significantly:

  1. PR feat: rtk rewrite — single source of truth for LLM hook rewrites #241 (rtk rewrite) introduced a registry-based rewrite system (src/discover/registry.rs + rules.rs) that replaced all per-language rewrite logic with centralized regex rules
  2. PR feat(refacto-codebase-onboarding): partie 1 - folders and technical docs #826/fix(refacto-p2): more standardize #932 reorganized the codebase from flat src/ to nested subfolders (cmds/, core/, hooks/, analytics/)
  3. hooks/hook_cmd.rs now has native Rust hook processing (run_copilot(), run_gemini()) that reads JSON from stdin and uses discover::registry::rewrite_command()
  4. All P0/P1 bugs from the feat(hook): native cross-platform (Windows & more) hook-rewrite command #150 review (gh --json, git -C, cat multi-file, grep flag reorder) were independently fixed in the registry

Rebasing was not viable: 9 commits over a massively reorganized repo produced modify/delete conflicts on the first commit. The 8 per-language modules were 100% superseded by the registry.

This PR takes the clean approach: fresh branch from develop, reusing the existing rtk hook copilot command (already in upstream), adding only what was missing -- the --native-hook flag in rtk init to install it as the hook on all platforms.

Result: 3 files, 500 LOC (vs 14 files, 1905 LOC in #150).

Note: CLA has been signed as requested in the #150 review.


What This PR Does

Adds rtk init -g --native-hook which installs rtk hook copilot as the PreToolUse hook command instead of the bash script rtk-rewrite.sh.

Why

  • Windows support: The bash hook requires bash + jq -- unavailable on Windows. Currently, rtk init -g falls back to --claude-md mode on Windows (no automatic rewriting). With --native-hook, Windows users get the same hook-based token savings as Unix users.
  • Fewer dependencies: The native hook is the compiled rtk binary itself. No bash, no jq, no shell script on disk.
  • Opt-in on Unix: Unix users can continue using the battle-tested bash hook by default. --native-hook is available for those who prefer the native path.

Behavior Matrix

Command Unix Windows
rtk init -g Bash hook (unchanged) Fallback --claude-md + message suggesting --native-hook
rtk init -g --native-hook Native hook rtk hook copilot Native hook rtk hook copilot
rtk init -g --hook-only Bash hook only Error + message suggesting --native-hook
rtk init -g --hook-only --native-hook Native hook only Native hook only
rtk init --show Detects bash OR native hook Detects bash OR native hook
rtk init -g --uninstall Removes bash OR native hook Removes bash OR native hook
rtk init --codex --native-hook Error: incompatible flags Error: incompatible flags

Code Changes (3 files, 500 insertions, 19 deletions)

src/hooks/constants.rs (+3 lines)

Added NATIVE_HOOK_COMMAND constant:

/// Native hook command -- uses the compiled rtk binary directly, no bash/jq required.
/// Works on all platforms including Windows.
pub const NATIVE_HOOK_COMMAND: &str = "rtk hook copilot";

src/main.rs (+7 lines)

Added --native-hook flag to the Init command enum and passed it through to hooks::init::run().

src/hooks/init.rs (+490 lines, -19 lines)

New functions

Function Purpose
is_rtk_hook_command(cmd) -> bool Single source of truth -- returns true for any RTK hook variant (bash path containing rtk-rewrite.sh OR native rtk hook copilot)
replace_other_rtk_hook_in_json(root, cmd) -> bool Migration helper -- when switching variants (bash to native or native to bash), removes the OTHER variant from settings.json to prevent both hooks running
run_native_hook_mode(global, patch_mode, verbose, opencode) -> Result<()> Full native install: writes RTK.md, patches CLAUDE.md with @RTK.md, patches settings.json with "rtk hook copilot"
run_native_hook_only_mode(global, patch_mode, verbose) -> Result<()> Hook-only native install: patches settings.json only, no RTK.md

Bug fixes (5 edge cases found and fixed)

# Bug Impact Fix
1 remove_hook_from_json() only checked for rtk-rewrite.sh rtk init -g --uninstall would NOT remove the native hook from settings.json Changed to use is_rtk_hook_command() -- now removes both variants
2 hook_already_present() had unconditional cmd == NATIVE_HOOK_COMMAND Asymmetric: prevented migration from bash to native (always returned "already present") Changed to variant-specific matching: bash matches bash, native matches native
3 No migration when switching bash/native Both hooks would coexist in settings.json -- double execution Added replace_other_rtk_hook_in_json() call in patch_settings_json() before inserting new entry
4 show_claude_config() only checked bash hook file on disk rtk init --show showed [--] Hook: not found when native hook was installed (no file on disk) Added settings.json lookup for NATIVE_HOOK_COMMAND when bash file does not exist
5 --codex --native-hook silently ignored --native-hook User thinks native hook is installed but codex mode ignores it Added explicit validation: --codex cannot be combined with --native-hook

Modified existing functions

Function Change
run() Added native_hook: bool parameter, passes to run_default_mode and run_hook_only_mode
run_default_mode (Unix) Early return to run_native_hook_mode() when native_hook is true
run_default_mode (Windows) Same + improved fallback message mentioning --native-hook
run_hook_only_mode (Unix) Early return to run_native_hook_only_mode() when native_hook is true
run_hook_only_mode (Windows) Same + improved error message
hook_already_present() Symmetric variant-specific detection (see fix #2)
remove_hook_from_json() Uses is_rtk_hook_command() instead of contains(REWRITE_HOOK_FILE) (see fix #1)
patch_settings_json() Calls replace_other_rtk_hook_in_json() before insert (see fix #3)
show_claude_config() Detects native hook in settings.json (see fix #4) + checks both variants in settings.json status line

Tests Added (15 new, 1376 total)

All tests are unit tests in src/hooks/init.rs #[cfg(test)] mod tests.

is_rtk_hook_command (1 test, 8 assertions)

Test Assertions
test_is_rtk_hook_command "rtk hook copilot" true, various bash paths true, "rtk hook gemini" false, "" false, "rtk hook copilot" (extra spaces) false, "/some/other/hook.sh" false

hook_already_present (6 tests)

Test Scenario Expected
test_hook_already_present_native_hook Native in JSON, query native true
test_hook_already_present_native_when_bash_exists Bash in JSON, query native false (allows migration)
test_hook_already_present_bash_when_native_exists Native in JSON, query bash false (allows migration)
test_hook_already_present_both_hooks Both in JSON true for either query
test_hook_already_present_empty_command Native in JSON, query "" false
test_hook_already_present_similar_but_different "rtk hook gemini" in JSON false for both native and bash queries

remove_hook_from_json (3 tests)

Test Scenario Verified
test_remove_native_hook_from_json Only native hook Removed, array empty
test_remove_native_hook_preserves_other_hooks Native + unrelated hook Only native removed
test_remove_both_hooks_from_json Bash + native + unrelated Both RTK hooks removed, unrelated preserved

replace_other_rtk_hook_in_json (4 tests)

Test Scenario Verified
test_replace_other_rtk_hook_bash_to_native Bash exists, installing native Bash removed, unrelated preserved
test_replace_other_rtk_hook_native_to_bash Native exists, installing bash Native removed
test_replace_other_rtk_hook_no_replacement_when_same_variant Native exists, installing native No change (idempotent)
test_replace_other_rtk_hook_non_rtk_command Native exists, installing unrelated No change (non-RTK ignored)

Validation (1 test)

Test Scenario Expected
test_codex_mode_rejects_native_hook run(..., codex=true, native_hook=true, ...) Error: "--codex cannot be combined with --native-hook"

Quality Gates

Check Result
cargo fmt --all --check Clean
cargo clippy --all-targets 0 errors, 0 warnings on changed files (pre-existing warnings in unrelated files only)
cargo test --all 1376 passed, 0 failed, 6 ignored

Manual Testing (Windows 11)

Test Command Result
Install native hook rtk init -g --native-hook --auto-patch Hook installed, settings.json patched
Show config rtk init --show [ok] Hook: rtk hook copilot (native, cross-platform)
Hook works git status (via AI agent) Rewrites to rtk git status, compact output
Windows fallback message rtk init -g --auto-patch (no --native-hook) Falls back to --claude-md, mentions --native-hook
Migration rtk init -g --auto-patch then rtk init -g --native-hook --auto-patch Old 137-line block migrated to @RTK.md, no hook duplication
Codex conflict rtk init --codex --native-hook Error: "--codex cannot be combined with --native-hook"
Uninstall rtk init -g --uninstall Native hook removed from settings.json
Post-uninstall rtk init --show [--] Hook: not found, clean state

Supersedes

Supersedes #150 -- all unique functionality ported. The per-language hook modules from #150 are no longer needed since the registry-based rewrite system in develop handles all command rewriting.

Adds `rtk init -g --native-hook` which installs `rtk hook copilot` as
the Claude Code PreToolUse hook instead of the bash script. This enables
hook-based token savings on Windows (where bash is unavailable) and
removes the jq dependency on all platforms.

- Add NATIVE_HOOK_COMMAND constant ("rtk hook copilot")
- Add --native-hook flag to Init command (opt-in on Unix, required on Windows)
- Add run_native_hook_mode() and run_native_hook_only_mode() install paths
- Fix remove_hook_from_json() to also remove native hook entries
- Fix hook_already_present() for symmetric variant-specific detection
- Add replace_other_rtk_hook_in_json() for bash↔native migration
- Fix show_claude_config() to detect native hook in settings.json
- Add --codex + --native-hook conflict validation
- Add 15 tests covering all edge cases
@splusk2006

Copy link
Copy Markdown

Hi @ayoub-khemissi — nice work on this, the native binary approach is clearly the right long-term answer for Windows users without git-bash. Opened #1123 earlier today taking an orthogonal angle on the same Windows gap: instead of adding a native path, it unblocks the existing bash hook by removing the #[cfg(not(unix))] gate and wrapping only the chmod call in an inline #[cfg(unix)] block — matching the pattern install_cursor_hooks (line 1622) and the Gemini installer (line 2152) already use in the same file.

The two PRs look non-conflicting at the code level:

  • This PR adds run_native_hook_mode, threads native_hook through run(), and keeps the #[cfg(not(unix))] bail variants (only improves their message).
  • fix(init): enable hook installation on Windows #1123 removes those #[cfg(not(unix))] variants, unifies the function bodies, and inline-guards only the chmod. One file, +25/−33.

If both merge, Windows users get:

  • git-bash present → rtk init -g installs the bash hook automatically (no flag)
  • no git-bash → rtk init -g --native-hook installs the native binary

With only this PR, rtk init -g without the flag still falls back to --claude-md on Windows, so users need to remember --native-hook (or hit the fallback). #1123 fills that default-UX gap for the common Claude-Code-on-Windows install (which ships with git-bash and jq).

Your PR has much broader scope (500 LOC, 15 tests, 5 edge-case fixes, migration logic) and is clearly the primary Windows effort here — #1123 is a small complement, not a replacement. Happy to close #1123 if you'd prefer a single-PR path, or keep it open if the maintainers see value in shipping both.

Independent verification data, if useful for your review: AccelixGames team ran the bash hook path end-to-end on Windows 11 + Claude Code CLI + git-bash via hand-install (~/.claude/hooks/rtk-rewrite.sh + PreToolUse entry in settings.json). Results: 1234 commands auto-rewritten via the hook in one session, no "No hook installed" warning, subagent sessions inherit the hook, rtk init --show reports [ok] Hook: ... (exists) and [ok] settings.json: RTK hook configured. So the bash path is empirically viable on Windows — the only blocker was the compile-time gate that #1123 removes.

Closes the issues from the opposite direction than yours, but we both end up helping Windows users either way. 👍

@ayoub-khemissi

Copy link
Copy Markdown
Author

Follow-up: 1793eab fix(hooks): detect native hook in settings.json for status and verify

Problem

On Windows (or any machine using --native-hook), users saw this noise on every rtk invocation even right after a clean install:

[rtk] /!\ No hook installed — run `rtk init -g` for automatic token savings

And rtk verify reported:

SKIP  RTK hook not installed
      Run `rtk init -g` to install.

Both were false negatives.

Root cause

The detection logic in src/hooks/hook_check.rs::hook_installed_path() only looked for the bash script at ~/.claude/hooks/rtk-rewrite.sh. The native hook introduced in this branch ships as a command string (rtk hook copilot) inside ~/.claude/settings.json — no file on disk, so the check always returned None and status() returned HookStatus::Missing.

integrity.rs::run_verify() had the same blind spot and reported IntegrityStatus::NotInstalled.

The new native-hook detection was correctly added in init.rs::hook_already_present (line 883) and in rtk init --verify output (line 2060), but this detection was not propagated to hook_check.rs and integrity.rs.

Fix

  • hook_check.rs: new native_hook_installed(claude_dir) that parses settings.json and looks for the native hook command in hooks.PreToolUse[].hooks[].command. status() now falls back to this check when no bash hook file exists. Added native_hook_configured() public helper for cross-module use.
  • integrity.rs: when NotInstalled, defer to hook_check::native_hook_configured(). If the native hook is present, print PASS native hook configured instead of the misleading SKIP. Native hook has no on-disk baseline so integrity hashing does not apply — surfaced explicitly in the output.
  • Cfg-gated test-only imports in hook_check.rs to clear a pre-existing clippy warning on the touched file.

Tests

  • 5 new unit tests covering native hook detection (present / missing file / unrelated hook / invalid JSON / empty config).
  • Existing test_status_returns_valid_variant updated to recognise the native-hook case.
  • Full suite: 1381 passed, 0 failed.

Verification

Before fix (installed native hook on Windows):

$ rtk verify
[rtk] /!\ No hook installed — run `rtk init -g` for automatic token savings
SKIP  RTK hook not installed
      Run `rtk init -g` to install.
141/141 tests passed

After fix:

$ rtk verify
PASS  native hook configured
      Command: rtk hook copilot
      (command lives in settings.json; no file hash to verify)
141/141 tests passed

And rtk git status no longer prints the spurious warning.

@medeiroz

Copy link
Copy Markdown

I'm having the same problem here, looking forward to this update.

@ZeddYu

ZeddYu commented Apr 18, 2026

Copy link
Copy Markdown

Really need this!

@NothingToSay0031

Copy link
Copy Markdown

Can't wait for this to be merged! For anyone eager to try it early, build from source with: cargo install --git https://github.com/ayoub-khemissi/rtk --branch feat/native-hook-v2

@aeppling

Copy link
Copy Markdown
Contributor

Hey, solved on latest releases 0.37.x

Thanks for contributing and trying to address this issue.

Closing this for clean up

@aeppling aeppling closed this Apr 24, 2026
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.

6 participants