fix(hooks): false 'No hook installed' warning when GitHub Copilot hook is the active integration - #3642
Conversation
rtk gain printed a false '[warn] No hook installed' and rtk init --show omitted Copilot entirely when the only automatic integration was the user-global GitHub Copilot hook. Root cause: hook_check::status() only inspected the Claude Code setup (~/.claude settings.json / legacy script). A machine with an existing but RTK-unconfigured ~/.claude and a valid Copilot hook under $COPILOT_HOME/hooks/rtk-rewrite.json (default ~/.copilot) was reported as Missing. Fix: - add is_copilot_hook_command (shared shell-split matcher with the Claude variant; accepts bare and absolute rtk paths) - add hook_check::copilot_hook_registered: parses the hook JSON and requires a PreToolUse command entry invoking 'rtk hook copilot' — missing files, malformed JSON, empty PreToolUse, or foreign commands do not count as installed - aggregate in status(): a Claude Missing result is downgraded to Ok only when a valid Copilot hook exists; Outdated Claude hooks still warn - rtk init --show now reports GitHub Copilot hook status ([ok]/[warn]/ [--]) and documents the --copilot init flags Detection stays read-only. Covered by new unit tests in hook_check.rs and e2e regression tests in tests/hook_status_copilot_test.rs using sandboxed HOME/CLAUDE_CONFIG_DIR/COPILOT_HOME. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes a false “No hook installed” warning by extending hook-status detection to recognize a valid GitHub Copilot user-global hook ($COPILOT_HOME / ~/.copilot/hooks/rtk-rewrite.json) and surfacing that status in rtk init --show.
Changes:
- Add shared Copilot hook detection (JSON-parsed + command-validated) and aggregate it into
hook_check::status()so valid Copilot setups suppress the missing-Claude-hook warning. - Extend
rtk init --showto report GitHub Copilot hook status and document the aggregated behavior. - Add unit + end-to-end regression tests covering valid/invalid/missing Copilot hook scenarios and aggregate-status behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/hook_status_copilot_test.rs | New e2e regression tests validating rtk gain warning behavior and rtk init --show Copilot status output in a sandboxed environment. |
| src/hooks/README.md | Documentation update describing aggregated Claude + Copilot status behavior and warning semantics. |
| src/hooks/mod.rs | Generalize hook-command detection and add is_copilot_hook_command with matcher tests. |
| src/hooks/init.rs | Add Copilot hook status line to rtk init --show output and include --copilot usage hints. |
| src/hooks/hook_check.rs | Implement Copilot hook JSON validation and aggregate it into overall hook status() logic; add unit tests for Copilot + aggregate scenarios. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| pub fn status() -> HookStatus { | ||
| let claude_dir = resolve_claude_dir().ok(); | ||
| let copilot_dir = copilot_user_dir().ok(); | ||
| status_at(claude_dir.as_deref(), copilot_dir.as_deref()) | ||
| } |
There was a problem hiding this comment.
Good catch — fixed in 4af75d6. check_and_warn() now checks the rate-limit marker before computing status, so a fresh marker skips all detection I/O and JSON parsing on the hot path. Output behavior is unchanged since a fresh marker suppressed the warning anyway.
check_and_warn() runs on most CLI invocations via maybe_warn(). Check the once-per-day rate-limit marker first so a fresh marker skips all hook detection disk I/O and JSON parsing. Output behavior unchanged: a fresh marker suppressed the warning anyway. Addresses Copilot review feedback on rtk-ai#3642. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Suppressed comments (1)
src/hooks/hook_check.rs:135
- This accepts the command field even when the entry declares another hook type (for example
"type": "http"). Such an entry does not executertk hook copilot, but it would suppress the missing-hook warning. Allow an omitted type (which defaults to command) or require it to be exactlycommand.
entries
.iter()
.filter_map(|entry| entry.get("command")?.as_str())
.any(is_copilot_hook_command)
| let root: serde_json::Value = match serde_json::from_str(&content) { | ||
| Ok(v) => v, | ||
| Err(_) => return false, | ||
| }; |
There was a problem hiding this comment.
Fixed in d1d5c9a — copilot_hook_registered now requires version == 1 before inspecting entries; missing or different versions no longer count as installed. Covered by test_copilot_hook_wrong_or_missing_version_not_registered.
| /// Seed the tracking database so `rtk gain` reaches its summary view | ||
| /// (and thus its hook-status warning) instead of "No tracking data yet". | ||
| fn seed_tracking_data(&self) { | ||
| let out = self.rtk(&["proxy", "echo", "ok"]); |
There was a problem hiding this comment.
Fixed in d1d5c9a — seeding now proxies the rtk binary itself (CARGO_BIN_EXE_rtk --version) instead of echo, so it is portable to Windows.
| let binary_name = binary.rsplit(['/', '\\']).next().unwrap_or(binary); | ||
|
|
||
| binary_name == "rtk" && hook == "hook" && claude == "claude" | ||
| binary_name == "rtk" && hook == "hook" && target == agent |
There was a problem hiding this comment.
Fixed in d1d5c9a — the matcher now parses the command string without shell unescaping (backslash paths survive), accepts a quoted binary path with spaces, and strips a trailing .exe case-insensitively. Added Windows-path tests incl. lookalike rejections (not-rtk.exe, rtk.exe.bak).
| .env("HOME", &self.home) | ||
| .env("USERPROFILE", &self.home) | ||
| .env("XDG_DATA_HOME", self.home.join(".local/share")) | ||
| .env("XDG_CONFIG_HOME", self.home.join(".config")) | ||
| .env("CLAUDE_CONFIG_DIR", &self.claude_dir) | ||
| .env("COPILOT_HOME", &self.copilot_home) |
There was a problem hiding this comment.
Fixed in d1d5c9a — the sandbox now pins RTK_DB_PATH inside the temp dir, so tests never touch the real tracking database and cannot race each other.
- require version == 1 in rtk-rewrite.json: Copilot rejects hook files with a missing or different version, so they never run and must not count as installed - make the rtk-hook-command matcher Windows-aware: parse the command string without shell unescaping so backslash paths survive, accept a quoted binary path with spaces, and strip a trailing .exe - isolate integration tests: pin RTK_DB_PATH inside the sandbox (it outranks XDG/home paths and is honored on Windows) and seed tracking by proxying the rtk binary itself instead of echo, which is a shell built-in on Windows Addresses Copilot review feedback on rtk-ai#3642. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/hooks/hook_check.rs:139
- The detector ignores the required hook-entry
type. A file containing{ "type": "prompt", "command": "rtk hook copilot" }is therefore reported as registered even though Copilot will not execute it as a command, which can reintroduce the false suppression this change is intended to fix. Filter fortype == "command"before matching the command string.
entries
.iter()
.filter_map(|entry| entry.get("command")?.as_str())
.any(is_copilot_hook_command)
| let inner = &trimmed[1..]; | ||
| let Some(end) = inner.find(quote) else { | ||
| return false; | ||
| }; | ||
| (&inner[..end], &inner[end + 1..]) | ||
| } |
There was a problem hiding this comment.
Fixed in 8f7e960 — the closing quote must now be followed by whitespace (or end the string); "rtk"hook copilot and similar glued forms are rejected. Covered by hook_command_rejects_quote_glued_to_next_token.
"rtk"hook copilot is not a registration of the rtk binary — the closing quote must end the token. Addresses Copilot review feedback on rtk-ai#3642. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
src/hooks/hook_check.rs:139
- The detector accepts any entry containing this command, even when its
typeis notcommand. A structurally invalid entry such as{"type":"prompt","command":"rtk hook copilot"}can therefore suppress the missing-hook warning and be shown as registered although Copilot will not execute it as a command hook. Requiretype == "command"before matchingcommand.
entries
.iter()
.filter_map(|entry| entry.get("command")?.as_str())
.any(is_copilot_hook_command)
tests/hook_status_copilot_test.rs:78
- The database is isolated, but seeding still invokes
rtk proxy, which runsmaybe_warn()before the proxy command. In the no-hook/invalid-hook cases this reads and writesdirs::data_local_dir()/rtk/.hook_warn_last; on Windows that location is not redirected byHOME,USERPROFILE, or the XDG variables here, so tests can mutate the real user's warning marker and race each other. Run the seed command with a separate nonexistent Claude config (so status isOk) or otherwise isolate the marker path.
// RTK_DB_PATH outranks the XDG/home paths and is honored on
// Windows too — pin the tracking DB inside the sandbox so tests
// never touch the developer's real database.
.env("RTK_DB_PATH", self.home.join("rtk-history.db"))
.env("CLAUDE_CONFIG_DIR", &self.claude_dir)
.env("COPILOT_HOME", &self.copilot_home)
src/hooks/mod.rs:53
- Lowercasing the basename on every platform accepts case-variant lookalikes such as
/tmp/RTK hook claudeon case-sensitive Unix systems. That can falsely mark a foreign hook as valid and can cause Claude init/uninstall logic to treat it as RTK's own entry. Apply case-insensitivertk/rtk.exematching only on Windows; retain exactrtkmatching elsewhere.
let binary_name = binary.rsplit(['/', '\\']).next().unwrap_or(binary);
let binary_name = binary_name.to_ascii_lowercase();
let binary_name = binary_name.strip_suffix(".exe").unwrap_or(&binary_name);
binary_name == "rtk" && hook == "hook" && target == agent
| Some(_) => { | ||
| let end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len()); | ||
| trimmed.split_at(end) | ||
| } |
There was a problem hiding this comment.
Fixed in 46cbcc5 — the matcher now accepts either interpretation: POSIX shell form via shell_split (restoring /opt/RTK\ Tools/rtk hook claude and quoted paths with spaces) or the raw Windows form (preserving C:\rtk\rtk.exe hook copilot). Added regression tests covering escaped Unix paths alongside the existing Windows-path tests.
The raw parser rejected valid POSIX shell-escaped executable paths such as /opt/RTK\ Tools/rtk hook claude, which shell_split previously handled. Match against both interpretations: POSIX shell form (via shell_split) or raw Windows form, so escaped Unix paths and backslash Windows paths are both recognized. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
[warn] No hook installed — run 'rtk init -g'fromrtk gain/rtk gain --historywhen the user-global GitHub Copilot hook ($COPILOT_HOME/hooks/rtk-rewrite.json, default~/.copilot) is the active integration. Root cause:hook_check::status()only inspected the Claude Code setup, so an existing-but-unconfigured~/.claudereportedMissingdespite a working Copilot rewrite hookhook_check::copilot_hook_registeredparses the hook JSON (not mere file existence) and requires aPreToolUsecommand entry invokingrtk hook copilot— bare or absolute rtk path, via the same shell-split matching as the Claude hook. Missing file, malformed JSON, emptyPreToolUse, or a foreign command never count as installedstatus()downgrades ClaudeMissingtoOkonly when a valid Copilot hook exists; no-integration and outdated-Claude warnings remain.rtk init --shownow reports GitHub Copilot hook status ([ok] registered/[warn] invalid or outdated/[--] not found) and lists the--copilotinit flagsTest plan
cargo fmt --all && cargo clippy --all-targets && cargo test(2727 passed, 8 ignored, 10 suites)hook_check.rs: stock config (incl. the exactCOPILOT_HOOK_JSONrtk installs), absolute rtk path, malformed JSON, emptyPreToolUse, wrong command, aggregate status matrix (Copilot-only OK with/without.claude, invalid Copilot never suppresses, outdated Claude not masked by Copilot) + matcher tests inhooks/mod.rstests/hook_status_copilot_test.rs(8 real-binary tests, sandboxedHOME/CLAUDE_CONFIG_DIR/COPILOT_HOME):rtk gainwarns/doesn't warn correctly per state;rtk init --showreports each Copilot state~/.copilothook + unconfigured~/.claude— released rtk 0.45.0 prints the false warning; this branch does not, andrtk init --showprints[ok] GitHub Copilot hook: registered (~/.copilot/hooks/rtk-rewrite.json)