diff --git a/README.md b/README.md index e019e80e82..420a237c2e 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ rtk init -g ## Supported AI Tools -RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents, reducing the bash output the agent reads where the agent supports command interception. +RTK supports 16 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents, reducing the bash output the agent reads where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -397,7 +397,7 @@ RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rt | **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) | | **Pi** | `rtk init -g --agent pi` (global) | TypeScript extension (tool_call) | | **Hermes** | `rtk init --agent hermes` | Python plugin adapter (terminal command mutation via `rtk rewrite`) | -| **Mistral Vibe** | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Blocked on upstream | +| **Mistral Vibe** | `rtk init -g --agent vibe` | `pre_tool` hook (hooks.toml) | | **Kilo Code** | `rtk init --agent kilocode` | .kilocode/rules/rtk-rules.md (project-scoped) | | **Google Antigravity** | `rtk init --agent antigravity` | .agents/rules/antigravity-rtk-rules.md (project-scoped) | | **Kimi AI** | `rtk init --agent kimi` | AGENTS.md (project-scoped) | diff --git a/docs/contributing/TECHNICAL.md b/docs/contributing/TECHNICAL.md index ec114cb633..54f15422ae 100644 --- a/docs/contributing/TECHNICAL.md +++ b/docs/contributing/TECHNICAL.md @@ -314,7 +314,7 @@ Start here, then drill down into each README for file-level details. |-----------|-------|-------------------------------| | [`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 | -| [`copilot/`](../hooks/copilot/README.md) | GitHub Copilot | Rust binary hook, VS Code Chat vs Copilot CLI dual format | +| [`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) | | [`windsurf/`](../hooks/windsurf/README.md) | Windsurf / Cascade | Rules file (workspace-scoped) | @@ -331,7 +331,7 @@ RTK supports the following LLM agents through hook integrations: |-------|-----------|-----------|---------------------| | Claude Code | Shell hook | `PreToolUse` in `settings.json` | 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 | No (deny + suggestion) | +| GitHub Copilot CLI | Rust binary | `rtk hook copilot` reads JSON | Yes (`updatedInput`) | | Cursor | Rust binary | `rtk hook cursor` reads JSON | Yes (`updated_input`) | | Gemini CLI | Rust binary | `rtk hook gemini` reads JSON | Yes (`hookSpecificOutput`) | | Cline/Roo Code | Rules file | Prompt-level guidance | N/A (prompt) | diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index adb0e8bd17..6af4995e51 100644 --- a/docs/guide/getting-started/supported-agents.md +++ b/docs/guide/getting-started/supported-agents.md @@ -1,13 +1,13 @@ --- title: Supported Agents -description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, Antigravity, and Factory Droid +description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Kilo Code, Antigravity, Factory Droid, and Mistral Vibe sidebar: order: 3 --- # Supported Agents -RTK supports all major AI coding agents across 3 integration tiers. Mistral Vibe support is planned. +RTK supports all major AI coding agents across 3 integration tiers. ## How it works @@ -30,7 +30,7 @@ Agent runs "cargo test" |-------|-----------------|---------------------------| | Claude Code | Shell hook (`PreToolUse`) | Yes | | VS Code Copilot Chat | Shell hook (`PreToolUse`) | Yes | -| GitHub Copilot CLI | Shell hook (`preToolUse` `modifiedArgs`) | Yes | +| GitHub Copilot CLI | Shell hook (`PreToolUse`) | Yes | | Cursor | Shell hook (`preToolUse`) | Yes | | Gemini CLI | Rust binary (`BeforeTool`) | Yes | | OpenCode | TypeScript plugin (`tool.execute.before`) | Yes | @@ -43,7 +43,7 @@ Agent runs "cargo test" | Codex CLI | AGENTS.md instructions | N/A | | Kilo Code | Rules file (prompt-level) | N/A | | Google Antigravity | Rules file (prompt-level) | N/A | -| Mistral Vibe | Planned ([#800](https://github.com/rtk-ai/rtk/issues/800)) | Pending upstream | +| Mistral Vibe | Rust binary (`pre_tool`) | Yes | ## Installation by agent @@ -74,7 +74,9 @@ rtk init --copilot # project-scoped (.github/hooks/) rtk init --global --copilot # user-scoped (~/.copilot/hooks/, respects $COPILOT_HOME) ``` -Project-scoped writes `.github/hooks/rtk-rewrite.json` (both hosts get transparent rewrite — VS Code Chat via `updatedInput`, Copilot CLI via `modifiedArgs`) plus the RTK block in `.github/copilot-instructions.md`. User-scoped writes the same hook config to `~/.copilot/hooks/rtk-rewrite.json` and the RTK block to `~/.copilot/copilot-instructions.md` (both respect `$COPILOT_HOME` if set). +Project-scoped writes `.github/hooks/rtk-rewrite.json` — a single `PreToolUse` entry shared by both hosts, each getting transparent rewrite via `updatedInput` — plus the RTK block in `.github/copilot-instructions.md`. User-scoped writes the same hook config to `~/.copilot/hooks/rtk-rewrite.json` and the RTK block to `~/.copilot/copilot-instructions.md` (both respect `$COPILOT_HOME` if set). + +Earlier `rtk` versions also registered a second, camelCase `preToolUse` entry for Copilot CLI's native schema. Copilot CLI treats `PreToolUse`/`preToolUse` as independent hooks and runs both sequentially for the same tool call — a redundant process spawn with no behavioral benefit, since Copilot CLI honors the single `PreToolUse` schema on its own. Re-run `rtk init --copilot` (or `--global --copilot`) to upgrade an existing install to the single-hook config. Uninstall: @@ -195,9 +197,28 @@ rtk init --agent antigravity # creates .agents/rules/antigravity-rtk-rules.md Antigravity reads `.agents/rules/` as custom instructions. RTK adds guidance telling Antigravity to prefer `rtk ` over raw commands. -### Mistral Vibe (planned) +### Mistral Vibe + +```bash +rtk init -g --agent vibe # user-scoped (~/.vibe/hooks.toml) +rtk init -g --agent vibe --hook-only # skip the ~/.vibe/prompts/rtk.md prompt file +``` + +Installs a `pre_tool` hook entry (`match = "bash"`, `command = "rtk hook vibe"`, `strict = false`) into `~/.vibe/hooks.toml`, following the contract at [docs.mistral.ai/vibe/code/cli/hooks](https://docs.mistral.ai/vibe/code/cli/hooks). Vibe invokes the native `rtk hook vibe` binary before every bash tool call; RTK reads Vibe's stdin JSON payload and emits `{"hook_specific_output": {"tool_input": {"command": "rtk ..."}}}` to rewrite the command in place. The Vibe UI surfaces `[rtk-rewrite] rtk: rewrote to \`…\`` via RTK's `system_message` field so the rewrite is visible. + +Unlike Droid, Vibe does not yet expose a denylist / allowlist surface in `hooks.toml` for RTK to honor. RTK therefore rewrites every bash command it knows how to compress and defers to Vibe's own permission prompt on the rewritten command; commands RTK doesn't handle pass through unchanged. `strict = false` ensures a hook crash degrades to a warning rather than blocking the tool call. + +Alongside the hook, RTK drops a system prompt at `~/.vibe/prompts/rtk.md` describing the RTK conventions to Vibe as a belt-and-suspenders fallback. Use `--hook-only` to skip it. + +Install is global-only (Vibe's hook registry is user-scoped). Re-running the installer is a no-op; the RTK entry is detected by its `name = "rtk-rewrite"` field and never duplicated. + +Uninstall: + +```bash +rtk init -g --agent vibe --uninstall +``` -Support is blocked on upstream `BeforeToolCallback` ([mistral-vibe#531](https://github.com/mistralai/mistral-vibe/issues/531)). Tracked in [#800](https://github.com/rtk-ai/rtk/issues/800). +Strips only RTK's `[[hooks]]` block and the `~/.vibe/prompts/rtk.md` file. Any other user-declared hooks in `hooks.toml` are preserved byte-for-byte. `hooks.toml` is removed only when the RTK entry was the sole content. ## Integration tiers explained diff --git a/hooks/README.md b/hooks/README.md index a79f64e17d..6e9cd01a2a 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -4,7 +4,7 @@ **Deployed hook artifacts** — the actual files installed on user machines by `rtk init`. These are shell scripts, TypeScript plugins, and rules files that run outside the Rust binary. They are **thin delegates**: parse agent-specific JSON, call `rtk rewrite` as a subprocess, format agent-specific response. Zero filtering logic lives here. -Owns: per-agent hook scripts and configuration files for 9 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi). +Owns: per-agent hook scripts and configuration files for 10 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode, Hermes, Pi, Mistral Vibe). Does **not** own: hook installation/uninstallation (that's `src/hooks/init.rs`), the rewrite pattern registry (that's `discover/registry`), or integrity verification (that's `src/hooks/integrity.rs`). @@ -42,6 +42,7 @@ Each agent subdirectory has its own README with hook-specific details: - **[`opencode/`](opencode/README.md)** — TypeScript plugin, `zx` library, `tool.execute.before` event, in-place mutation - **[`pi/`](pi/README.md)** — TypeScript extension, `tool_call` event, `isToolCallEventType` guard, in-place mutation, `~/.pi/agent/extensions/` - **[`hermes/`](hermes/README.md)** — Python plugin, `pre_tool_call` hook, in-place terminal command mutation +- **[`vibe/`](vibe/README.md)** — Rust binary hook (`rtk hook vibe`), `pre_tool` entry in `~/.vibe/hooks.toml`, `hook_specific_output.tool_input` rewrite plus `system_message` for UI visibility ## Supported Agents @@ -58,6 +59,7 @@ Each agent subdirectory has its own README with hook-specific details: | OpenCode | TypeScript plugin (`tool.execute.before`) | In-place mutation | Yes | | Pi | TypeScript extension (`tool_call` event) | In-place mutation | Yes | | Hermes | Python plugin (`pre_tool_call`) | In-place mutation | Yes | +| Mistral Vibe | Rust binary (`rtk hook vibe`) | Transparent rewrite | Yes (`hook_specific_output.tool_input`) | ## JSON Formats by Agent @@ -157,6 +159,32 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). **No rewrite**: `{"decision": "allow"}` +### Mistral Vibe (Rust Binary) + +**Input** (stdin): + +```json +{ + "tool_name": "bash", + "tool_input": { "command": "git status" }, + "hook_event_name": "pre_tool", + "session_id": "..." +} +``` + +**Output** (when rewritten): + +```json +{ + "hook_specific_output": { + "tool_input": { "command": "rtk git status" } + }, + "system_message": "rtk: rewrote to `rtk git status`" +} +``` + +**No rewrite**: exit 0 with empty stdout (Vibe's contract for "no opinion" from a `pre_tool` hook). + ### OpenCode (TypeScript Plugin) Mutates `args.command` in-place via the zx library: diff --git a/hooks/vibe/README.md b/hooks/vibe/README.md new file mode 100644 index 0000000000..d38cb73326 --- /dev/null +++ b/hooks/vibe/README.md @@ -0,0 +1,19 @@ +# Mistral Vibe Hooks + +> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code + +## Specifics + +- Uses the `rtk hook vibe` Rust binary (not a shell script) -- no `jq` dependency +- `pre_tool` hook declared in `~/.vibe/hooks.toml` (user-scoped) with `match = "bash"` and `strict = false` +- Reads Vibe's stdin JSON payload (`tool_name`, `tool_input.command`, `hook_event_name`, `session_id`) +- Returns `hook_specific_output.tool_input.command` for transparent rewrite plus a `system_message` for UI visibility +- Non-bash tool / empty command / malformed JSON / RTK-unknown command → passthrough (exit 0, empty stdout) +- RTK permission deny → `{"decision":"deny","reason":"..."}` +- Alongside the hook, a system prompt at `~/.vibe/prompts/rtk.md` is installed as a belt-and-suspenders fallback (skip with `--hook-only`) +- Installed globally via `rtk init -g --agent vibe`; there is no project-scoped variant + +## Notes + +- This directory intentionally holds only this README — the hook is a subcommand of the RTK binary (`rtk hook vibe`), not a standalone script or plugin file, so nothing is deployed here +- Vibe hook contract reference: https://docs.mistral.ai/vibe/code/cli/hooks diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs index acf3e10493..08776c7b35 100644 --- a/src/core/telemetry.rs +++ b/src/core/telemetry.rs @@ -362,6 +362,7 @@ fn detect_hook_type() -> String { (home.join(".gemini/hooks/rtk-hook.sh"), "gemini"), (home.join(".codex/AGENTS.md"), "codex"), (home.join(".cursor/hooks/rtk-rewrite.json"), "cursor"), + (home.join(".vibe/hooks.toml"), "vibe"), ]; for (path, name) in &checks { @@ -576,7 +577,7 @@ mod tests { assert!(stats.low_savings_commands.len() <= 5); assert!((0.0..=100.0).contains(&stats.avg_savings_per_command)); assert!( - ["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"] + ["claude", "gemini", "codex", "cursor", "copilot", "vibe", "none", "unknown"] .iter() .any(|&h| stats.hook_type.starts_with(h)), "Unexpected hook type: {}", @@ -588,7 +589,7 @@ mod tests { fn test_detect_hook_type_returns_known() { let ht = detect_hook_type(); assert!( - ["claude", "gemini", "codex", "cursor", "copilot", "none", "unknown"] + ["claude", "gemini", "codex", "cursor", "copilot", "vibe", "none", "unknown"] .contains(&ht.as_str()), "Unexpected hook type: {}", ht diff --git a/src/discover/lexer.rs b/src/discover/lexer.rs index 6cca2a39e1..9a3805599d 100644 --- a/src/discover/lexer.rs +++ b/src/discover/lexer.rs @@ -26,6 +26,13 @@ pub fn tokenize(input: &str) -> Vec { tokenize_inner(input, false) } +/// Like [`tokenize`] but emits a `\n` operator token for each newline that +/// sits outside quotes. Newlines inside quoted strings stay part of their +/// argument, so callers can use the emitted offsets as safe line-split points. +pub fn tokenize_with_newlines(input: &str) -> Vec { + tokenize_inner(input, true) +} + fn tokenize_inner(input: &str, emit_newline: bool) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); @@ -1342,4 +1349,17 @@ mod tests { assert!(split_for_permissions("").is_empty()); assert!(split_for_permissions(" ").is_empty()); } + + #[test] + fn test_tokenize_with_newlines_emits_operator_outside_quotes_only() { + let newline_ops = |input: &str| { + tokenize_with_newlines(input) + .iter() + .filter(|t| t.kind == TokenKind::Operator && t.value == "\n") + .count() + }; + assert_eq!(newline_ops("git status\ngit log"), 1); + assert_eq!(newline_ops("echo 'line1\nline2'"), 0); + assert_eq!(newline_ops("git status\r\ngit log"), 2); + } } diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 81b170785a..6469b178d8 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -5,7 +5,10 @@ use regex::{Regex, RegexSet}; use std::path::Path; use std::sync::LazyLock; -use super::lexer::{shell_split, split_on_operators, tokenize, ParsedToken, PipeKind, TokenKind}; +use super::lexer::{ + shell_split, split_on_operators, tokenize, tokenize_with_newlines, ParsedToken, PipeKind, + TokenKind, +}; use super::rules::{IGNORED_EXACT, IGNORED_PREFIXES, RULES}; const PHP_TOOL_NAMES: [&str; 6] = ["phpunit", "phpstan", "ecs", "pest", "paratest", "pint"]; @@ -535,6 +538,8 @@ fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { static LINE_CONTINUATION_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?m)[ \t\x0B\x0C]*\\\r?\n[ \t\x0B\x0C]*").unwrap()); +static BASH_JOIN_RE: LazyLock = LazyLock::new(|| Regex::new(r"\\\r?\n").unwrap()); + /// Replace every bash line continuation with a single space, mirroring what /// bash does before dispatching the command. Returns a borrowed `&str` when the /// input contains no continuations, so the common fast path allocates nothing. @@ -566,6 +571,15 @@ pub fn rewrite_command( excluded: &[String], transparent_prefixes: &[String], ) -> Option { + // Bash joins `\` with nothing, so `<<` or `$((` can arrive split across + // a continuation; the space-join below would erase them (#3188 review). + if cmd.contains('\\') { + let joined = BASH_JOIN_RE.replace_all(cmd, ""); + if has_heredoc(&joined) || joined.contains("$((") { + return None; + } + } + // Bash line continuations (`\`, `\`) and the leading whitespace that // follows are syntactically equivalent to a single space, but `cmd.trim()` does // not unwrap them so a leading backslash-newline used to defeat the whole matcher. @@ -583,6 +597,19 @@ pub fn rewrite_command( let compiled = compile_exclude_patterns(excluded); let normalized_prefixes = normalize_transparent_prefixes(transparent_prefixes); + if trimmed.contains('\n') { + return rewrite_multiline_block(trimmed, &compiled, &normalized_prefixes); + } + + rewrite_single(trimmed, &compiled, &normalized_prefixes) +} + +/// Rewrite one logical command line (no unquoted newlines). +fn rewrite_single( + trimmed: &str, + excluded: &[ExcludePattern], + transparent_prefixes: &[String], +) -> Option { // Simple (non-compound) already-RTK command — return as-is. // For compound commands that start with "rtk" (e.g. "rtk git add . && cargo test"), // fall through to rewrite_compound so the remaining segments get rewritten. @@ -595,7 +622,324 @@ pub fn rewrite_command( return Some(trimmed.to_string()); } - rewrite_compound(trimmed, &compiled, &normalized_prefixes) + rewrite_compound(trimmed, excluded, transparent_prefixes) +} + +/// Shell keywords that open or close a multi-line construct. A line inside a +/// loop, conditional, case arm, function body, or group is not an independent +/// command, so the whole block passes through untouched. +const BLOCK_KEYWORDS: &[&str] = &[ + "for", "while", "until", "if", "then", "else", "elif", "fi", "do", "done", "case", "esac", + "select", "function", "coproc", "{", "}", "(", ")", +]; + +/// Shared quote-state byte walker used by all line scanners. Yields +/// `(offset, byte, in_single_before, in_double_before)`, skipping backslash +/// escape pairs outside single quotes and toggling quote state — the same +/// model the lexer applies. +struct QuoteScan<'a> { + bytes: &'a [u8], + i: usize, + in_single: bool, + in_double: bool, +} + +impl<'a> QuoteScan<'a> { + fn new(s: &'a str) -> Self { + Self { + bytes: s.as_bytes(), + i: 0, + in_single: false, + in_double: false, + } + } + + fn balanced(&self) -> bool { + !self.in_single && !self.in_double + } +} + +impl Iterator for QuoteScan<'_> { + type Item = (usize, u8, bool, bool); + + fn next(&mut self) -> Option { + while self.i < self.bytes.len() { + let i = self.i; + let b = self.bytes[i]; + if b == b'\\' && !self.in_single { + self.i += 2; + continue; + } + let item = (i, b, self.in_single, self.in_double); + match b { + b'\'' if !self.in_double => self.in_single = !self.in_single, + b'"' if !self.in_single => self.in_double = !self.in_double, + _ => {} + } + self.i += 1; + return Some(item); + } + None + } +} + +/// Byte offset where an unquoted `#` at the start of a word begins a trailing +/// comment, if any. The lexer has no comment state, so the independence checks +/// must ignore comment text themselves: `git log | # keep pipeline` continues +/// the pipeline across the newline even though the line ends in comment text. +fn comment_start(line: &str) -> Option { + let bytes = line.as_bytes(); + // `#` starts a comment at any word start, incl. after an operator + // byte — but not after `{`: `${#var}` is an expansion (#3188 review). + QuoteScan::new(line).find_map(|(i, b, in_single, in_double)| { + (b == b'#' + && !in_single + && !in_double + && (i == 0 + || bytes[i - 1].is_ascii_whitespace() + || matches!(bytes[i - 1], b'|' | b'&' | b';' | b'(' | b')'))) + .then_some(i) + }) +} + +/// Unquoted `(`/`)` or `{`/`}` that don't balance within the line: an array +/// literal (`arr=(one`), function body (`foo() {`), or group spans lines, so +/// the lines around it are not independent commands. +fn line_has_unbalanced_grouping(code: &str) -> bool { + let mut paren = 0i32; + let mut brace = 0i32; + for (_, b, in_single, in_double) in QuoteScan::new(code) { + if in_single || in_double { + continue; + } + match b { + b'(' => paren += 1, + b')' => paren -= 1, + b'{' => brace += 1, + b'}' => brace -= 1, + _ => {} + } + if paren < 0 || brace < 0 { + return true; + } + } + paren != 0 || brace != 0 +} + +/// Unquoted `[[` / `]]` words that don't balance within the line: bash allows +/// a conditional expression to span lines (`[[ -f a &&` / `-f b ]]`), so the +/// surrounding lines are not independent commands. +fn line_has_unbalanced_test_brackets(code: &str) -> bool { + let bytes = code.as_bytes(); + let mut depth = 0i32; + for (i, b, in_single, in_double) in QuoteScan::new(code) { + if in_single || in_double || !matches!(b, b'[' | b']') { + continue; + } + let word_start = i == 0 || bytes[i - 1].is_ascii_whitespace(); + let word_end = bytes.get(i + 2).is_none_or(|c| c.is_ascii_whitespace()); + if bytes.get(i + 1) == Some(&b) && word_start && word_end { + depth += if b == b'[' { 1 } else { -1 }; + if depth < 0 { + return true; + } + } + } + depth != 0 +} + +// Only `\'` inside `$'…'` diverges: bash keeps the string open, the lexer +// closes it — an extra split point the newline-count check can't see (#3188). +fn ansi_c_quote_defeats_lexer(cmd: &str) -> bool { + let bytes = cmd.as_bytes(); + let mut ansi_span = false; + let mut backslash_run = 0u32; + for (i, b, in_single, in_double) in QuoteScan::new(cmd) { + if b == b'\'' && !in_double { + if !in_single { + ansi_span = i > 0 && bytes[i - 1] == b'$'; + backslash_run = 0; + } else if ansi_span && backslash_run % 2 == 1 { + return true; + } + } else if in_single { + if b == b'\\' { + backslash_run += 1; + } else { + backslash_run = 0; + } + } + } + false +} + +fn quotes_balanced(cmd: &str) -> bool { + let mut scan = QuoteScan::new(cmd); + scan.by_ref().for_each(drop); + scan.balanced() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LineRole { + Passive, + Independent, + ContinuesNext, + Unsafe, +} + +fn classify_line(line: &str) -> LineRole { + if line.is_empty() || line.starts_with('#') { + return LineRole::Passive; + } + let comment = comment_start(line); + let code = comment.map_or(line, |i| line[..i].trim_end()); + let first = code.split_whitespace().next().unwrap_or(""); + if BLOCK_KEYWORDS.contains(&first) { + return LineRole::Unsafe; + } + const CONTINUATION_OPS: [&str; 4] = ["&&", "||", "|&", "|"]; + if CONTINUATION_OPS.iter().any(|op| code.starts_with(op)) + || code.starts_with("((") + || code.ends_with("))") + || line_has_unbalanced_grouping(code) + || line_has_unbalanced_test_brackets(code) + { + return LineRole::Unsafe; + } + if CONTINUATION_OPS.iter().any(|op| code.ends_with(op)) { + // An operator behind a trailing comment can't be joined textually: + // the comment-blind tokenizer would read the comment as command words. + return if comment.is_some() { + LineRole::Unsafe + } else { + LineRole::ContinuesNext + }; + } + LineRole::Independent +} + +/// Rewrite each line of a multi-line block independently (issue #1243). +/// +/// Split points are the newline tokens the quote-aware lexer emits, so a +/// newline inside a quoted string (e.g. a multi-line commit message) never +/// becomes a boundary. Lines continued by a trailing `&&`/`||`/`|`/`|&` are +/// joined and rewritten as one logical command through the single-line path — +/// joining is not byte-preserving: separators inside a joined unit collapse +/// to single spaces (see `test_blank_line_inside_continuation_joins`); +/// any line [`classify_line`] marks unsafe passes the whole block through. +/// Blank lines and comment lines are preserved verbatim, as is indentation +/// and the original separator bytes (`\n` vs `\r\n`). +/// +/// If any newline byte was swallowed by quote state, the block passes through +/// untouched. The lexer has no comment awareness, so an apostrophe in a `#` +/// comment opens quote state and hides the rest of the block — rewriting (or +/// prefixing) such a block would act on lines no permission verdict was +/// computed for. Passthrough hands the original command to the agent's native +/// permission handling instead. Genuine quoted newlines (multi-line commit +/// messages) also land here; forgoing that rewrite is the safe trade. +fn rewrite_multiline_block( + cmd: &str, + excluded: &[ExcludePattern], + transparent_prefixes: &[String], +) -> Option { + let newline_offsets: Vec = tokenize_with_newlines(cmd) + .iter() + .filter(|t| t.kind == TokenKind::Operator && t.value == "\n") + .map(|t| t.offset) + .collect(); + + if ansi_c_quote_defeats_lexer(cmd) { + return None; + } + + // The lexer emits one newline token per `\r` and per `\n` (CRLF = two + // tokens), so the parity check must count both bytes individually. + let raw_breaks = cmd.chars().filter(|c| matches!(c, '\n' | '\r')).count(); + if raw_breaks != newline_offsets.len() { + // Every newline swallowed by quote state with quotes balanced at EOF + // is one logical command (a multi-line commit message), not a hidden + // extra line; rewrite it whole, as develop always did (#3319 fuzz). + if newline_offsets.is_empty() && quotes_balanced(cmd) { + return rewrite_single(cmd, excluded, transparent_prefixes); + } + return None; + } + + let mut segments = Vec::with_capacity(newline_offsets.len() + 1); + let mut start = 0; + for &off in &newline_offsets { + segments.push((start, &cmd[start..off])); + start = off + 1; + } + segments.push((start, &cmd[start..])); + + let roles: Vec = segments + .iter() + .map(|(_, seg)| classify_line(seg.trim())) + .collect(); + if roles.contains(&LineRole::Unsafe) { + return None; + } + + let mut any_changed = false; + let mut result = String::with_capacity(cmd.len() + 32); + let mut i = 0; + while i < segments.len() { + if i > 0 { + let off = newline_offsets[i - 1]; + result.push_str(&cmd[off..off + 1]); + } + let (seg_off, seg) = segments[i]; + + if roles[i] == LineRole::Passive { + result.push_str(seg); + i += 1; + continue; + } + + let mut end = i; + while roles[end] == LineRole::ContinuesNext { + let mut next = end + 1; + while next < segments.len() && segments[next].1.trim().is_empty() { + next += 1; + } + if next >= segments.len() { + break; + } + if roles[next] == LineRole::Passive { + // Comment line inside a continuation: the comment-blind + // tokenizer would join it as command words (#3188 review). + return None; + } + end = next; + } + + // A joined unit is rebuilt through the single-line path: interior + // newlines and blank lines collapse to single spaces, not preserved. + let unit = if end == i { + seg + } else { + let (last_off, last_seg) = segments[end]; + &cmd[seg_off..last_off + last_seg.len()] + }; + let line = unit.trim(); + match rewrite_single(line, excluded, transparent_prefixes) { + Some(rewritten) if rewritten != line => { + any_changed = true; + let indent = &seg[..seg.len() - seg.trim_start().len()]; + result.push_str(indent); + result.push_str(&rewritten); + } + _ => result.push_str(unit), + } + i = end + 1; + } + + if any_changed { + Some(result) + } else { + None + } } /// Pipeline boundaries used to rewrite its final stage. @@ -1159,6 +1503,323 @@ mod tests { super::rewrite_command(cmd, excluded, &[]) } + mod multiline_blocks { + use super::rewrite_command_no_prefixes; + + #[test] + fn test_rewrites_each_line() { + assert_eq!( + rewrite_command_no_prefixes("git status\ngit log --oneline -3", &[]), + Some("rtk git status\nrtk git log --oneline -3".into()) + ); + } + + #[test] + fn test_preserves_blank_lines_comments_and_indentation() { + assert_eq!( + rewrite_command_no_prefixes("git status\n\n# check history\n git log -3", &[]), + Some("rtk git status\n\n# check history\n rtk git log -3".into()) + ); + } + + #[test] + fn test_compound_line_inside_block() { + assert_eq!( + rewrite_command_no_prefixes("cd /tmp && git status\ngrep -rn foo src", &[]), + Some("cd /tmp && rtk git status\nrtk grep -rn foo src".into()) + ); + } + + #[test] + fn test_crlf_separators_preserved() { + assert_eq!( + rewrite_command_no_prefixes("git status\r\ngit log -3", &[]), + Some("rtk git status\r\nrtk git log -3".into()) + ); + } + + #[test] + fn test_newline_inside_quotes_rewrites_as_one_command() { + // The quoted body is never treated as a command line of its own; + // the whole thing is one logical command and gets one prefix. + assert_eq!( + rewrite_command_no_prefixes("git commit -m \"subject\ngit status in body\"", &[]), + Some("rtk git commit -m \"subject\ngit status in body\"".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("git commit -m 'multi\nline\nmessage'", &[]), + Some("rtk git commit -m 'multi\nline\nmessage'".into()) + ); + } + + #[test] + fn test_unbalanced_swallowed_newline_passes_through() { + assert_eq!( + rewrite_command_no_prefixes("git commit -m \"subject\ngit status", &[]), + None + ); + } + + #[test] + fn test_comment_apostrophe_swallowing_newline_passes_through() { + // The lexer has no comment state: the apostrophe in `don't` opens + // a quote that swallows the newline and hides the next line. The + // block must pass through so native permission handling sees the + // original command — never a partially rewritten one. + assert_eq!( + rewrite_command_no_prefixes("git status # don't\nrm -rf /tmp/x", &[]), + None + ); + } + + #[test] + fn test_comment_apostrophe_hidden_in_later_segment_passes_through() { + // Same hazard when a clean split point precedes the contaminated + // line: the swallowed-newline check is global, not per-segment. + assert_eq!( + rewrite_command_no_prefixes("git log -3\ngit status # don't\nrm -rf /tmp/x", &[]), + None + ); + } + + #[test] + fn test_comment_with_balanced_quotes_still_rewrites() { + // Both apostrophes close before the newline, so the split is safe + // and the trailing comment rides along untouched. + assert_eq!( + rewrite_command_no_prefixes( + "git status # isn't it what's expected\ngit log -3", + &[] + ), + Some("rtk git status # isn't it what's expected\nrtk git log -3".into()) + ); + } + + #[test] + fn test_arithmetic_spanning_lines_passes_through() { + // `(( x = ls ))` is arithmetic evaluation; injecting `rtk` before + // `ls` would splice a command into arithmetic context. + assert_eq!(rewrite_command_no_prefixes("(( x =\nls ))", &[]), None); + } + + #[test] + fn test_array_assignment_spanning_lines_passes_through() { + // The inner line is an array element, not a command; rewriting it + // would mutate the array's contents. + assert_eq!( + rewrite_command_no_prefixes("arr=(one\ngit status\ntwo)", &[]), + None + ); + } + + #[test] + fn test_function_definition_spanning_lines_passes_through() { + assert_eq!( + rewrite_command_no_prefixes("foo() {\n git status\n}", &[]), + None + ); + } + + #[test] + fn test_continuation_operator_behind_comment_passes_through() { + // Bash continues the pipeline across the newline even though the + // line ends in comment text; the next line is a pipeline stage, + // not an independent command. + assert_eq!( + rewrite_command_no_prefixes("git log | # keep pipeline\ngrep -f patterns.txt", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("git status && # continue\ngit log -3", &[]), + None + ); + } + + #[test] + fn test_ansi_c_escaped_quote_passes_through() { + // Inside $'...' bash treats \' as a literal quote that does not + // close the string, so the second line is string content — the + // lexer can't see that, so the block forgoes the rewrite. + assert_eq!( + rewrite_command_no_prefixes("x=$'foo\\'\ngit status\n'", &[]), + None + ); + } + + #[test] + fn test_ansi_c_without_escaped_quote_still_rewrites() { + assert_eq!( + rewrite_command_no_prefixes("echo $'a\\tb'\ngit status", &[]), + Some("echo $'a\\tb'\nrtk git status".into()) + ); + } + + #[test] + fn test_balanced_grouping_within_a_line_still_rewrites() { + // `${HOME}` braces (quoted or not) must not trip the + // unbalanced-grouping bail. + assert_eq!( + rewrite_command_no_prefixes("echo ${HOME}\ngit status", &[]), + Some("echo ${HOME}\nrtk git status".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("echo \"${HOME}\"\ngit status", &[]), + Some("echo \"${HOME}\"\nrtk git status".into()) + ); + } + + #[test] + fn test_no_rewritable_line_passes_through() { + assert_eq!(rewrite_command_no_prefixes("echo one\necho two", &[]), None); + } + + #[test] + fn test_already_rtk_lines_count_as_unchanged() { + assert_eq!( + rewrite_command_no_prefixes("rtk git status\necho done", &[]), + None + ); + } + + #[test] + fn test_mixed_rtk_and_rewritable_line() { + assert_eq!( + rewrite_command_no_prefixes("rtk git status\ngit log -3", &[]), + Some("rtk git status\nrtk git log -3".into()) + ); + } + + #[test] + fn test_for_loop_block_passes_through() { + assert_eq!( + rewrite_command_no_prefixes("for f in a b; do\n grep -n foo $f\ndone", &[]), + None + ); + } + + #[test] + fn test_if_block_passes_through() { + assert_eq!( + rewrite_command_no_prefixes("if [ -d src ]; then\n git status\nfi", &[]), + None + ); + } + + #[test] + fn test_cross_line_and_list_joins_and_rewrites() { + assert_eq!( + rewrite_command_no_prefixes("git status &&\ngit log -3", &[]), + Some("rtk git status && rtk git log -3".into()) + ); + } + + #[test] + fn test_cross_line_pipeline_joins_and_rewrites() { + assert_eq!( + rewrite_command_no_prefixes("git log |\ngrep feat", &[]), + Some("git log | rtk grep feat".into()) + ); + assert_eq!( + rewrite_command_no_prefixes("cargo test |&\ngrep FAILED", &[]), + None + ); + } + + #[test] + fn test_cross_line_pipeline_unsafe_final_stage_passes_through() { + assert_eq!( + rewrite_command_no_prefixes("git log |\ngrep -f patterns.txt", &[]), + None + ); + } + + #[test] + fn test_mixed_independent_and_continued_lines() { + assert_eq!( + rewrite_command_no_prefixes("grep -rn foo src\ngit status &&\ngit log -3", &[]), + Some("rtk grep -rn foo src\nrtk git status && rtk git log -3".into()) + ); + } + + #[test] + fn test_blank_line_inside_continuation_joins() { + assert_eq!( + rewrite_command_no_prefixes("git status &&\n\ngit log -3", &[]), + Some("rtk git status && rtk git log -3".into()) + ); + } + + #[test] + fn test_comment_line_inside_continuation_passes_through() { + assert_eq!( + rewrite_command_no_prefixes("git status &&\n# note\ngit log -3", &[]), + None + ); + } + + #[test] + fn test_comment_directly_after_operator_passes_through() { + assert_eq!( + rewrite_command_no_prefixes("git log |# keep pipeline\ngrep -f patterns.txt", &[]), + None + ); + } + + #[test] + fn test_conditional_expression_spanning_lines_passes_through() { + assert_eq!( + rewrite_command_no_prefixes("[[ -f a &&\n-f b ]]\ngit status", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("git status\n[[\n-f a ]]", &[]), + None + ); + } + + #[test] + fn test_balanced_conditional_line_still_rewrites() { + assert_eq!( + rewrite_command_no_prefixes("[[ -x foo ]] &&\ngit status", &[]), + Some("[[ -x foo ]] && rtk git status".into()) + ); + } + + #[test] + fn test_subshell_spanning_lines_passes_through() { + assert_eq!(rewrite_command_no_prefixes("(\n git status\n)", &[]), None); + } + + #[test] + fn test_group_spanning_lines_passes_through() { + assert_eq!(rewrite_command_no_prefixes("{\n git status\n}", &[]), None); + } + + #[test] + fn test_heredoc_block_passes_through() { + assert_eq!( + rewrite_command_no_prefixes("git status\ncat < PipelineAnalysis { let tokens = tokenize(cmd); let first_pipe_offset = tokens diff --git a/src/hooks/README.md b/src/hooks/README.md index 67a0bf3cc0..586105c8ae 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -6,7 +6,7 @@ The **lifecycle management** layer for LLM agent hooks: install, uninstall, verify integrity, audit usage, and manage trust. This component creates and maintains the hook artifacts that live in `hooks/` (root), but does **not** execute rewrite logic itself — that lives in `discover/registry`. -Owns: `rtk init` installation flows (5 agents via `AgentTarget` enum + 3 special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management. +Owns: `rtk init` installation flows (6 agents via `AgentTarget` enum, now including Mistral Vibe + 3 special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management. Does **not** own: the deployed hook scripts themselves (that's `hooks/`), the rewrite pattern registry (that's `discover/`), or command filtering (that's `cmds/`). @@ -90,6 +90,7 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i | Gemini CLI (rtk hook gemini) | No (allow/deny only) | allow (limitation — no ask mode in Gemini) | | Copilot CLI (rtk hook copilot) | No updatedInput | deny-with-suggestion (unchanged) | | Codex | ask parsed but no-op | allow (limitation — fails open) | +| Mistral Vibe (rtk hook vibe) | No native ask surface | passthrough — Vibe's own approval prompt fires on the rewritten command | ### Implementation @@ -102,4 +103,4 @@ Rules are loaded from all Claude Code `settings.json` files (project + global, i Hook processors in `hook_cmd.rs` must return `Ok(())` on every path — success, no-match, parse error, and unexpected input. Returning `Err` propagates to `main()` and exits non-zero, which blocks the agent's command from executing. This violates the non-blocking guarantee documented in `hooks/README.md`. ## Adding New Functionality -To add support for a new AI coding agent: (1) add the hook installation logic to `init.rs` following the existing agent patterns, (2) if the agent requires a custom hook protocol (like Gemini's `BeforeTool`), add a processor function in `hook_cmd.rs`, (3) add the agent's hook file path to `hook_check.rs` for validation, and (4) update `integrity.rs` with the expected hash for the new hook file. Test by running `rtk init` in a fresh environment and verifying the hook rewrites commands correctly in the target agent. +To add support for a new AI coding agent: (1) add the hook installation logic to `init.rs` following the existing agent patterns, (2) if the agent requires a custom hook protocol (like Gemini's `BeforeTool` or Vibe's `pre_tool`), add a processor function in `hook_cmd.rs` and a matching `HookCommands::` variant + `AgentTarget::` enum entry in `main.rs`, (3) if the agent has installable permission surfaces (denylist / allowlist), wire them into `permissions.rs::check_command_for` via a new `Host::` variant, and (4) update `integrity.rs` with the expected hash for the new hook file. Note that `hook_check.rs::maybe_warn()` only checks the Claude Code hook — other agents don't have an outdated-hook warning path. Test by running `rtk init` in a fresh environment and verifying the hook rewrites commands correctly in the target agent. diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 4caaf94473..700e9798f4 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -14,6 +14,8 @@ pub const CLAUDE_HOOK_COMMAND: &str = "rtk hook claude"; pub const CURSOR_HOOK_COMMAND: &str = "rtk hook cursor"; /// Native Rust hook command for Factory Droid. pub const DROID_HOOK_COMMAND: &str = "rtk hook droid"; +/// Native Rust hook command for Mistral Vibe. +pub const VIBE_HOOK_COMMAND: &str = "rtk hook vibe"; pub const CONFIG_DIR: &str = ".config"; pub const OPENCODE_SUBDIR: &str = "opencode"; @@ -58,3 +60,10 @@ pub const HERMES_PLUGINS_SUBDIR: &str = "plugins"; pub const HERMES_PLUGIN_NAME: &str = "rtk-rewrite"; pub const HERMES_PLUGIN_INIT_FILE: &str = "__init__.py"; pub const HERMES_PLUGIN_MANIFEST_FILE: &str = "plugin.yaml"; + +pub const VIBE_DIR: &str = ".vibe"; +pub const VIBE_HOOKS_FILE: &str = "hooks.toml"; +pub const VIBE_PROMPTS_SUBDIR: &str = "prompts"; +pub const VIBE_PROMPT_FILE: &str = "rtk.md"; +pub const VIBE_HOOK_NAME: &str = "rtk-rewrite"; +pub const VIBE_BASH_MATCH: &str = "bash"; diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 574e8beddd..3cd58f018c 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -30,8 +30,22 @@ fn read_stdin_limited() -> Result { /// Format detected from the preToolUse JSON input. enum HookFormat { /// VS Code Copilot Chat / Claude Code: `tool_name` + `tool_input.command`, supports `updatedInput`. + /// If using the PreToolUse pascal case form, Copilot CLI also remaps its native `bash`/`powershell` + /// runtime tool to `tool_name: "Bash"` for this schema and honors its `updatedInput`, live-verified + /// on Linux+Windows 11 with Copilot CLI 1.0.73+ by rewriting a marker command end-to-end + /// see . VsCode { command: String }, - /// GitHub Copilot CLI: camelCase `toolName` + `toolArgs` (JSON string), supports `modifiedArgs` for transparent rewrite. + /// GitHub Copilot CLI's native schema: camelCase `toolName` + `toolArgs` (JSON string), + /// supports `modifiedArgs` for transparent rewrite. `rtk init --copilot` no longer + /// registers this schema (Copilot CLI honors the PascalCase `VsCode` schema on its + /// own — registering both caused a redundant second hook invocation per tool call, + /// see git history). Kept for installs that haven't re-run `rtk init --copilot` since + /// upgrading, and as the schema JetBrains/IntelliJ's Copilot plugin uses under a + /// different `toolName` value (`run_in_terminal`, not `bash` — see #2443/#3093). + /// On Windows, Copilot CLI reports this schema's `toolName` as the unmapped runtime + /// name `"powershell"` (#3178/#3179) — but since the `VsCode` schema above already + /// works standalone there, that arm is legacy-only: relevant for un-upgraded installs, + /// not exercised by a fresh `rtk init --copilot` on any platform. /// Carries the full parsed `toolArgs` object so we can rewrite `command` while preserving /// host-supplied metadata (description, initial_wait, mode, …) the tool requires. CopilotCli { command: String, args: Value }, @@ -64,16 +78,30 @@ pub fn run_copilot() -> Result<()> { match detect_format(&v) { HookFormat::VsCode { command } => handle_vscode(&command), - HookFormat::CopilotCli { command, args } => handle_copilot_cli(&command, &args), + HookFormat::CopilotCli { command, args } => { + for path in heal_legacy_copilot_configs() { + audit_log("self_heal", &path.display().to_string(), ""); + } + handle_copilot_cli(&command, &args) + } HookFormat::CopilotIde { command } => handle_copilot_ide(&command), HookFormat::PassThrough => Ok(()), } } fn detect_format(v: &Value) -> HookFormat { - // VS Code Copilot Chat / Claude Code: snake_case keys + // VS Code Copilot Chat / Claude Code: snake_case keys. + // "run_in_terminal" is VS Code Copilot Chat's actual terminal tool name + // (confirmed via live payload capture) — without it, detect_format falls + // through to PassThrough and the hook never fires for VS Code Copilot Chat. + // No separate Windows/"powershell" case is needed: Copilot CLI remaps both + // `bash` and `powershell` to `tool_name: "Bash"` for this schema — already + // handled below, live-confirmed (see the VsCode variant doc). if let Some(tool_name) = v.get("tool_name").and_then(|t| t.as_str()) { - if matches!(tool_name, "runTerminalCommand" | "Bash" | "bash") { + if matches!( + tool_name, + "runTerminalCommand" | "run_in_terminal" | "Bash" | "bash" + ) { if let Some(cmd) = v .pointer("/tool_input/command") .and_then(|c| c.as_str()) @@ -87,8 +115,12 @@ fn detect_format(v: &Value) -> HookFormat { return HookFormat::PassThrough; } - // Copilot CLI: camelCase keys, toolArgs is a JSON-encoded string. + // Copilot CLI's native camelCase schema: toolName + toolArgs (JSON-encoded string). // The shell tool is "bash" on Unix and "powershell" on Windows. + // Only reachable today via a not-yet-upgraded install's leftover camelCase + // preToolUse registration (see the CopilotCli variant doc) or a host that + // registers this schema itself, like JetBrains/IntelliJ's Copilot plugin + // (toolName "run_in_terminal"). if let Some(tool_name) = v.get("toolName").and_then(|t| t.as_str()) { if matches!(tool_name, "bash" | "powershell" | "run_in_terminal") { if let Some(tool_args_str) = v.get("toolArgs").and_then(|t| t.as_str()) { @@ -118,6 +150,87 @@ fn detect_format(v: &Value) -> HookFormat { HookFormat::PassThrough } +fn heal_legacy_copilot_configs() -> Vec { + use super::constants::{COPILOT_HOOK_FILE, GITHUB_DIR, HOOKS_SUBDIR}; + + let mut healed = Vec::new(); + let project = std::path::Path::new(GITHUB_DIR) + .join(HOOKS_SUBDIR) + .join(COPILOT_HOOK_FILE); + if heal_legacy_hook_file(&project) { + healed.push(project); + } + if let Ok(dir) = super::init::copilot_user_dir() { + let global = dir.join(HOOKS_SUBDIR).join(COPILOT_HOOK_FILE); + if heal_legacy_hook_file(&global) { + healed.push(global); + } + } + healed +} + +// Exact camelCase entry written by pre-b754b85 `rtk init --copilot`; that +// stale registration is the only thing routing invocations into the +// CopilotCli arm above. Only this entry is removed — user additions stay. +fn legacy_camelcase_entry() -> Value { + json!([{ + "type": "command", + "bash": "rtk hook copilot", + "powershell": "rtk hook copilot", + "cwd": ".", + "timeoutSec": 5 + }]) +} + +fn heal_legacy_hook_file(path: &std::path::Path) -> bool { + let Ok(raw) = std::fs::read_to_string(path) else { + return false; + }; + let Ok(mut config) = serde_json::from_str::(&raw) else { + return false; + }; + let Some(hooks) = config.get("hooks").and_then(|h| h.as_object()) else { + return false; + }; + if hooks.get("preToolUse") != Some(&legacy_camelcase_entry()) { + return false; + } + let pascalcase_still_registered = hooks + .get("PreToolUse") + .and_then(|p| p.as_array()) + .is_some_and(|entries| { + entries + .iter() + .any(|e| e.get("command").and_then(|c| c.as_str()) == Some("rtk hook copilot")) + }); + if !pascalcase_still_registered { + return false; + } + let Some(hooks) = config.get_mut("hooks").and_then(|h| h.as_object_mut()) else { + return false; + }; + hooks.shift_remove("preToolUse"); + + let stock = serde_json::from_str::(super::init::COPILOT_HOOK_JSON).ok(); + let content = if stock.is_some_and(|s| s == config) { + super::init::COPILOT_HOOK_JSON.to_string() + } else { + let Ok(mut pretty) = serde_json::to_string_pretty(&config) else { + return false; + }; + pretty.push('\n'); + pretty + }; + let tmp = path.with_extension(format!("heal.{}", std::process::id())); + std::fs::write(&tmp, content) + .and_then(|()| std::fs::rename(&tmp, path)) + .map_err(|_| { + // Cleanup of our own temp file after a failed atomic write. + let _ = std::fs::remove_file(&tmp); // nosemgrep: filesystem-deletion + }) + .is_ok() +} + fn get_rewritten(cmd: &str) -> Option { if has_heredoc(cmd) { return None; @@ -138,7 +251,7 @@ fn get_rewritten(cmd: &str) -> Option { enum HookDecision { AllowRewrite(String), - AskRewrite { rewritten: String, explicit: bool }, + AskRewrite(String), Defer, Deny, } @@ -152,10 +265,7 @@ fn decide_from_verdict(cmd: &str, verdict: PermissionVerdict) -> HookDecision { } match get_rewritten(cmd) { Some(r) if verdict == PermissionVerdict::Allow => HookDecision::AllowRewrite(r), - Some(r) => HookDecision::AskRewrite { - rewritten: r, - explicit: verdict == PermissionVerdict::Ask, - }, + Some(r) => HookDecision::AskRewrite(r), None => HookDecision::Defer, } } @@ -165,28 +275,46 @@ fn decide_hook_action(cmd: &str, host: permissions::Host) -> HookDecision { } fn handle_vscode(cmd: &str) -> Result<()> { - let (decision, rewritten) = match decide_hook_action(cmd, permissions::Host::Claude) { + if let Some(output) = vscode_response(cmd) { + let _ = writeln!(io::stdout(), "{output}"); + } + Ok(()) +} + +fn vscode_response(cmd: &str) -> Option { + vscode_response_from_decision(decide_hook_action(cmd, permissions::Host::Claude), cmd) +} + +/// Build the VS Code Copilot Chat / Copilot CLI (PascalCase compat) hook response. +/// +/// Mirrors `process_claude_payload`: `permissionDecision: "allow"` is only ever +/// asserted for an explicit, user-configured Allow rule. Every other rewrite +/// (Default verdict or an explicit Ask rule) omits the field entirely, leaving +/// the host's own native prompt/allowlist flow in control — see #3037, where +/// asserting `"ask"` here made Copilot CLI 1.0.66+ force a blocking dialog with +/// no "remember" option on every rewritten command. +fn vscode_response_from_decision(decision: HookDecision, cmd: &str) -> Option { + let (rewritten, allow) = match decision { HookDecision::Deny => { audit_log("deny", cmd, ""); - return Ok(()); + return None; } - HookDecision::Defer => return Ok(()), - HookDecision::AllowRewrite(r) => ("allow", r), - HookDecision::AskRewrite { rewritten: r, .. } => ("ask", r), + HookDecision::Defer => return None, + HookDecision::AllowRewrite(r) => (r, true), + HookDecision::AskRewrite(r) => (r, false), }; audit_log("rewrite", cmd, &rewritten); - let output = json!({ - "hookSpecificOutput": { - "hookEventName": PRE_TOOL_USE_KEY, - "permissionDecision": decision, - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": { "command": rewritten } - } + let mut hook_output = json!({ + "hookEventName": PRE_TOOL_USE_KEY, + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { "command": rewritten } }); - let _ = writeln!(io::stdout(), "{output}"); - Ok(()) + if allow { + hook_output["permissionDecision"] = json!("allow"); + } + Some(json!({ "hookSpecificOutput": hook_output })) } fn handle_copilot_cli(cmd: &str, args: &Value) -> Result<()> { @@ -212,7 +340,7 @@ fn copilot_ide_response_from_decision(decision: HookDecision, cmd: &str) -> Opti audit_log("deny", cmd, ""); "Blocked by RTK permission rule".to_string() } - HookDecision::AllowRewrite(rewritten) | HookDecision::AskRewrite { rewritten, .. } => { + HookDecision::AllowRewrite(rewritten) | HookDecision::AskRewrite(rewritten) => { audit_log("rewrite", cmd, &rewritten); format!("RTK token optimization: re-run this command as `{rewritten}` instead.") } @@ -244,13 +372,7 @@ fn copilot_cli_response_from_decision( } HookDecision::Defer => return None, HookDecision::AllowRewrite(r) => (r, true), - HookDecision::AskRewrite { - rewritten: r, - explicit, - } => { - let is_simple = crate::discover::lexer::split_for_permissions(cmd).len() <= 1; - (r, !explicit && is_simple) - } + HookDecision::AskRewrite(r) => (r, false), }; audit_log("rewrite", cmd, &rewritten); @@ -306,7 +428,7 @@ pub fn run_gemini() -> Result<()> { audit_log("rewrite", cmd, rewritten); print_gemini("allow", Some(rewritten)); } - HookDecision::AskRewrite { ref rewritten, .. } => { + HookDecision::AskRewrite(ref rewritten) => { audit_log("ask", cmd, rewritten); print_gemini("ask_user", Some(rewritten)); } @@ -316,6 +438,68 @@ pub fn run_gemini() -> Result<()> { Ok(()) } +// ── Vibe hook ───────────────────────────────────────────────── + +/// Run the Mistral Vibe CLI pre_tool hook. +/// +/// Vibe hook contract (https://docs.mistral.ai/vibe/code/cli/hooks): +/// - stdin: JSON with `tool_name`, `tool_input`, `hook_event_name`, etc. +/// - Passthrough: exit 0 with empty stdout. +/// - Rewrite: emit `{"hook_specific_output": {"tool_input": {"command": "..."}}}`. +/// - Deny: emit `{"decision": "deny", "reason": "..."}`. +pub fn run_vibe() -> Result<()> { + let input = read_stdin_limited()?; + if let Some(output) = run_vibe_inner(&input) { + let _ = writeln!(io::stdout(), "{output}"); + } + Ok(()) +} + +fn run_vibe_inner(input: &str) -> Option { + let json: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + let _ = writeln!(io::stderr(), "[rtk hook] Failed to parse JSON input: {e}"); + return None; + } + }; + + let tool_name = json.get("tool_name").and_then(|v| v.as_str()).unwrap_or(""); + if tool_name != "bash" { + return None; + } + + let cmd = json + .pointer("/tool_input/command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if cmd.is_empty() { + return None; + } + + match decide_hook_action(cmd, permissions::Host::Vibe) { + HookDecision::Deny => { + audit_log("deny", cmd, ""); + Some(r#"{"decision":"deny","reason":"Blocked by RTK permission rule"}"#.to_string()) + } + HookDecision::AllowRewrite(ref rewritten) | HookDecision::AskRewrite(ref rewritten) => { + audit_log("rewrite", cmd, rewritten); + Some(vibe_rewrite_json(rewritten)) + } + HookDecision::Defer => None, + } +} + +fn vibe_rewrite_json(rewritten: &str) -> String { + serde_json::json!({ + "hook_specific_output": { + "tool_input": { "command": rewritten } + }, + "system_message": format!("rtk: rewrote to `{}`", rewritten), + }) + .to_string() +} + fn print_allow() { let _ = writeln!(io::stdout(), r#"{{"decision":"allow"}}"#); } @@ -411,7 +595,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction { } } HookDecision::AllowRewrite(r) => (r, true), - HookDecision::AskRewrite { rewritten: r, .. } => (r, false), + HookDecision::AskRewrite(r) => (r, false), }; let updated_input = { @@ -535,7 +719,7 @@ pub fn run_cursor() -> Result<()> { audit_log("rewrite", &cmd, &rewritten); cursor_allow(&rewritten) } - HookDecision::AskRewrite { rewritten, .. } => { + HookDecision::AskRewrite(rewritten) => { audit_log("ask", &cmd, &rewritten); cursor_ask(&rewritten) } @@ -598,7 +782,7 @@ fn run_cursor_inner_with_rules( let verdict = permissions::check_command_with_rules(&cmd, deny_rules, ask_rules, allow_rules); match decide_from_verdict(&cmd, verdict) { HookDecision::AllowRewrite(rewritten) => cursor_allow(&rewritten), - HookDecision::AskRewrite { rewritten, .. } => cursor_ask(&rewritten), + HookDecision::AskRewrite(rewritten) => cursor_ask(&rewritten), _ => "{}".to_string(), } } @@ -644,7 +828,7 @@ fn droid_response_from_decision(v: &Value, cmd: &str, decision: HookDecision) -> return None; } HookDecision::Defer => return None, - HookDecision::AllowRewrite(r) | HookDecision::AskRewrite { rewritten: r, .. } => r, + HookDecision::AllowRewrite(r) | HookDecision::AskRewrite(r) => r, }; audit_log("rewrite", cmd, &rewritten); @@ -756,6 +940,16 @@ mod tests { )); } + #[test] + fn test_detect_vscode_run_in_terminal() { + // VS Code Copilot Chat's actual terminal tool name, confirmed via + // live payload capture — distinct from "runTerminalCommand". + assert!(matches!( + detect_format(&vscode_input("run_in_terminal", "cargo test")), + HookFormat::VsCode { .. } + )); + } + #[test] fn test_detect_copilot_cli_bash() { assert!(matches!( @@ -834,44 +1028,82 @@ mod tests { assert!(get_rewritten("cat <<'EOF'\nhello\nEOF").is_none()); } - // --- Copilot CLI handler: transparent rewrite via modifiedArgs --- + // --- VS Code Copilot Chat / Copilot CLI (PascalCase) handler --- + // Serves both VS Code Copilot Chat's PreToolUse hook and Copilot CLI's + // PascalCase-compat entry (#3037): the same `rtk hook copilot` call + // answers both from one JSON schema. - fn cli_args(cmd: &str) -> Value { - json!({ "command": cmd }) + #[test] + fn test_vscode_allow_rewrite_sets_permission_allow() { + let r = vscode_response_from_decision( + HookDecision::AllowRewrite("rtk git status".into()), + "git status", + ) + .unwrap(); + assert_eq!(r["hookSpecificOutput"]["permissionDecision"], "allow"); + assert_eq!( + r["hookSpecificOutput"]["updatedInput"]["command"], + "rtk git status" + ); } #[test] - fn test_copilot_cli_default_ask_rewrite_sets_permission_allow() { - let r = copilot_cli_response_from_decision( - &cli_args("cargo test"), - HookDecision::AskRewrite { - rewritten: "rtk cargo test".into(), - explicit: false, - }, + fn test_vscode_ask_rewrite_omits_permission_decision() { + // Default (unconfigured) and explicit-Ask verdicts both land here as + // AskRewrite — neither must assert a decision, matching Claude's own + // hook (process_claude_payload). Asserting "ask" is what caused #3037: + // Copilot CLI 1.0.66+ treats it as authoritative and forces a blocking + // dialog with no "remember" option on every rewritten command. + let r = vscode_response_from_decision( + HookDecision::AskRewrite("rtk cargo test".into()), "cargo test", ) .unwrap(); + assert!( + r["hookSpecificOutput"] + .as_object() + .unwrap() + .get("permissionDecision") + .is_none(), + "AskRewrite must NOT set permissionDecision" + ); assert_eq!( - r["permissionDecision"], "allow", - "Default AskRewrite must set permissionDecision to allow — Copilot CLI 1.0.66+ prompts on every command without it" + r["hookSpecificOutput"]["updatedInput"]["command"], + "rtk cargo test" ); - assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } #[test] - fn test_copilot_cli_explicit_ask_rewrite_omits_permission_decision() { + fn test_vscode_deny_returns_none() { + assert!(vscode_response_from_decision(HookDecision::Deny, "cargo test").is_none()); + } + + #[test] + fn test_vscode_defer_returns_none() { + assert!(vscode_response_from_decision(HookDecision::Defer, "cargo test").is_none()); + } + + // --- Copilot CLI handler: transparent rewrite via modifiedArgs --- + + fn cli_args(cmd: &str) -> Value { + json!({ "command": cmd }) + } + + #[test] + fn test_copilot_cli_ask_rewrite_omits_permission_decision() { + // Whether the Ask verdict came from an explicit rule or the Default + // (unconfigured) fallback, RTK must never assert a decision here — + // matches Claude's own hook (process_claude_payload) and avoids the + // Copilot CLI 1.0.66+ forced-prompt bug from #3037. let r = copilot_cli_response_from_decision( &cli_args("cargo test"), - HookDecision::AskRewrite { - rewritten: "rtk cargo test".into(), - explicit: true, - }, + HookDecision::AskRewrite("rtk cargo test".into()), "cargo test", ) .unwrap(); assert!( r.get("permissionDecision").is_none(), - "Explicit AskRewrite must NOT auto-allow — user deliberately configured ask for this command" + "AskRewrite must NOT set permissionDecision — the host's native prompt/allowlist stays in control" ); assert_eq!(r["modifiedArgs"]["command"], "rtk cargo test"); } @@ -913,10 +1145,7 @@ mod tests { #[test] fn test_copilot_ide_rewrite_returns_deny_with_suggestion() { let response = copilot_ide_response_from_decision( - HookDecision::AskRewrite { - rewritten: "rtk git status".into(), - explicit: false, - }, + HookDecision::AskRewrite("rtk git status".into()), "git status", ) .unwrap(); @@ -1000,10 +1229,7 @@ mod tests { }); let r = copilot_cli_response_from_decision( &args, - HookDecision::AskRewrite { - rewritten: "rtk cargo install ripgrep".into(), - explicit: false, - }, + HookDecision::AskRewrite("rtk cargo install ripgrep".into()), "cargo install ripgrep", ) .unwrap(); @@ -1577,7 +1803,7 @@ mod tests { fn test_decide_ask_for_default_verdict() { assert!(matches!( decide_with_rules("git status", &[], &[], &[]), - HookDecision::AskRewrite { .. } + HookDecision::AskRewrite(_) )); } @@ -1635,7 +1861,7 @@ mod tests { r#"{"decision":"deny","reason":"Blocked by RTK permission rule"}"#.to_string() } HookDecision::AllowRewrite(r) => gemini_json("allow", Some(&r)), - HookDecision::AskRewrite { rewritten: r, .. } => gemini_json("ask_user", Some(&r)), + HookDecision::AskRewrite(r) => gemini_json("ask_user", Some(&r)), HookDecision::Defer => gemini_json("ask_user", None), } } @@ -1895,4 +2121,63 @@ mod tests { let input = droid_input("Execute", "definitely-not-a-real-binary --foo"); assert!(run_droid_inner(&input).is_none()); } + + fn vibe_input(tool: &str, cmd: &str) -> String { + json!({ + "session_id": "abc123", + "hook_event_name": "pre_tool", + "tool_name": tool, + "tool_input": { "command": cmd } + }) + .to_string() + } + + #[test] + fn test_vibe_rewrites_bash_command() { + let input = vibe_input("bash", "git status"); + let out = run_vibe_inner(&input).expect("rewrite expected"); + let v: Value = serde_json::from_str(&out).unwrap(); + let rewritten = v + .pointer("/hook_specific_output/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap_or(""); + assert!( + rewritten.starts_with("rtk "), + "expected rtk-prefixed rewrite, got `{rewritten}`" + ); + assert!( + v.get("system_message").is_some(), + "expected system_message for UI visibility" + ); + } + + #[test] + fn test_vibe_ignores_non_bash_tool() { + let input = vibe_input("read_file", "irrelevant"); + assert!(run_vibe_inner(&input).is_none()); + } + + #[test] + fn test_vibe_empty_command_passthrough() { + let input = vibe_input("bash", ""); + assert!(run_vibe_inner(&input).is_none()); + } + + #[test] + fn test_vibe_malformed_json_returns_none() { + assert!(run_vibe_inner("not json at all").is_none()); + assert!(run_vibe_inner("{ unterminated").is_none()); + } + + #[test] + fn test_vibe_unknown_binary_passthrough() { + let input = vibe_input("bash", "definitely-not-a-real-binary --foo"); + assert!(run_vibe_inner(&input).is_none()); + } + + #[test] + fn test_vibe_substitution_defers() { + let input = vibe_input("bash", "echo $(rm -rf /)"); + assert!(run_vibe_inner(&input).is_none()); + } } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index b71c6288c7..bc7b443283 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -18,7 +18,8 @@ use super::constants::{ 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, + 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; @@ -4446,10 +4447,357 @@ fn uninstall_gemini(ctx: InitContext) -> Result> { Ok(removed) } +// ── Vibe integration ──────────────────────────────────────── + +fn resolve_vibe_dir() -> Result { + resolve_home_subdir(VIBE_DIR) +} + +/// Entry point for `rtk init -g --agent vibe`. +/// +/// Installs a `pre_tool` hook into `~/.vibe/hooks.toml` (Vibe CLI's hook +/// registry, see https://docs.mistral.ai/vibe/code/cli/hooks) that routes +/// bash tool calls through the native `rtk hook vibe` binary. When not +/// `hook_only`, also drops an `~/.vibe/prompts/rtk.md` system prompt file +/// as a belt-and-suspenders fallback if the hook is disabled. +pub fn run_vibe_mode( + global: bool, + hook_only: bool, + patch_mode: PatchMode, + ctx: InitContext, +) -> Result<()> { + if !global { + anyhow::bail!("Vibe support is global-only. Use: rtk init -g --agent vibe"); + } + let vibe_dir = resolve_vibe_dir()?; + run_vibe_mode_at(&vibe_dir, hook_only, patch_mode, ctx) +} + +fn run_vibe_mode_at( + vibe_dir: &Path, + hook_only: bool, + patch_mode: PatchMode, + ctx: InitContext, +) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + if !dry_run { + fs::create_dir_all(vibe_dir) + .with_context(|| format!("Failed to create Vibe config dir: {}", vibe_dir.display()))?; + } + + let hooks_path = vibe_dir.join(VIBE_HOOKS_FILE); + let hook_outcome = patch_vibe_hooks_toml(&hooks_path, patch_mode, ctx)?; + + if !hook_only { + let prompts_dir = vibe_dir.join(VIBE_PROMPTS_SUBDIR); + if !dry_run { + fs::create_dir_all(&prompts_dir).with_context(|| { + format!("Failed to create prompts dir: {}", prompts_dir.display()) + })?; + } + let prompt_path = prompts_dir.join(VIBE_PROMPT_FILE); + write_if_changed(&prompt_path, RTK_SLIM, VIBE_PROMPT_FILE, ctx)?; + } + + if dry_run { + print_dry_run_footer(); + } else if let Some(summary_verb) = hook_outcome.summary_verb() { + println!("\nMistral Vibe CLI hook {summary_verb} (global).\n"); + println!(" Hook registry: {}", hooks_path.display()); + if !hook_only { + println!( + " Prompt: {}", + vibe_dir + .join(VIBE_PROMPTS_SUBDIR) + .join(VIBE_PROMPT_FILE) + .display() + ); + } + println!(" Restart Vibe. Test with: git status\n"); + } + Ok(()) +} + +/// Outcome of `patch_vibe_hooks_toml`. Distinguishes installed / already-present / +/// skipped so the caller can decide whether the "installed" summary is truthful. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VibeHookPatchOutcome { + Installed, + AlreadyPresent, + Skipped, +} + +impl VibeHookPatchOutcome { + fn summary_verb(self) -> Option<&'static str> { + match self { + Self::Installed => Some("installed"), + Self::AlreadyPresent => Some("already present"), + Self::Skipped => None, + } + } +} + +/// Append the RTK `[[hooks]]` entry to `~/.vibe/hooks.toml` if not already present. +/// +/// Uses append-based patching (string level) rather than parse-serialize round-trip +/// to preserve any user comments and formatting in the file. +fn patch_vibe_hooks_toml( + hooks_path: &Path, + patch_mode: PatchMode, + ctx: InitContext, +) -> Result { + let InitContext { verbose, dry_run } = ctx; + + let existing = if hooks_path.exists() { + fs::read_to_string(hooks_path) + .with_context(|| format!("Failed to read {}", hooks_path.display()))? + } else { + String::new() + }; + + if vibe_hooks_toml_has_rtk(&existing) { + if verbose > 0 { + eprintln!("Vibe hooks.toml already has RTK hook"); + } + return Ok(VibeHookPatchOutcome::AlreadyPresent); + } + + if patch_mode == PatchMode::Skip { + println!( + "\nManual setup needed: add RTK hook to {}\n\ + See: https://www.rtk-ai.app/guide/getting-started/supported-agents#mistral-vibe", + hooks_path.display() + ); + return Ok(VibeHookPatchOutcome::Skipped); + } + + if patch_mode == PatchMode::Ask { + if dry_run { + println!( + "[dry-run] would prompt before patching {}", + hooks_path.display() + ); + } else { + print!("Patch {} with RTK hook? [y/N] ", hooks_path.display()); + std::io::stdout().flush().ok(); + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer).ok(); + if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") { + println!( + "Skipped. Re-run with --auto-patch, or add the hook manually to {}", + hooks_path.display() + ); + return Ok(VibeHookPatchOutcome::Skipped); + } + } + } + + let entry = vibe_hook_entry(); + let new_content = if existing.is_empty() { + entry.clone() + } else if existing.ends_with("\n\n") { + format!("{existing}{entry}") + } else if existing.ends_with('\n') { + format!("{existing}\n{entry}") + } else { + format!("{existing}\n\n{entry}") + }; + + if dry_run { + println!( + "[dry-run] would patch Vibe hooks.toml: {}", + hooks_path.display() + ); + if verbose > 0 { + println!("[dry-run] appended entry:\n{entry}"); + } + } else { + atomic_write(hooks_path, &new_content) + .with_context(|| format!("Failed to write {}", hooks_path.display()))?; + } + Ok(VibeHookPatchOutcome::Installed) +} + +/// TOML entry emitted for the Vibe pre_tool hook. Mirrors the shape documented +/// at https://docs.mistral.ai/vibe/code/cli/hooks. +fn vibe_hook_entry() -> String { + format!( + r#"[[hooks]] +name = "{name}" +type = "pre_tool" +match = "{match_glob}" +command = "{command}" +timeout = 10.0 +strict = false +description = "Rewrite bash commands through the rtk proxy to save tokens." +"#, + name = VIBE_HOOK_NAME, + match_glob = VIBE_BASH_MATCH, + command = VIBE_HOOK_COMMAND, + ) +} + +/// Detect an existing RTK entry by looking for the hook `name` field. Scanning +/// the raw string is enough because `name` is required by Vibe and must be +/// unique, so a substring match is both necessary and sufficient. +/// +/// Tradeoff: matches the exact spacing `name = "rtk-rewrite"`. A reformatted +/// file (`name="rtk-rewrite"` or extra whitespace) would defeat idempotency +/// and cause a duplicate append on re-install. Acceptable because our own +/// installer only ever writes the canonical spacing, and the alternative +/// (parse-serialize round-trip via toml_edit) would clobber user comments +/// and formatting in the file. +fn vibe_hooks_toml_has_rtk(content: &str) -> bool { + let needle = format!(r#"name = "{VIBE_HOOK_NAME}""#); + content.contains(&needle) +} + +/// Public entry point for `rtk init -g --agent vibe --uninstall`. +pub fn uninstall_vibe(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let vibe_dir = match resolve_vibe_dir() { + Ok(d) => d, + Err(e) => { + eprintln!("RTK Vibe uninstall skipped: could not resolve ~/.vibe/ ({e})"); + return Ok(()); + } + }; + let removed = uninstall_vibe_at(&vibe_dir, ctx)?; + + if removed.is_empty() { + println!("RTK Vibe support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK for Mistral Vibe CLI:" + } else { + "RTK uninstalled for Mistral Vibe CLI:" + }; + println!("{}", header); + for item in removed { + println!(" - {}", item); + } + if !dry_run { + println!("\nRestart Vibe CLI to apply changes."); + } + } + + if dry_run { + print_dry_run_footer(); + } + Ok(()) +} + +/// Remove the RTK hook entry (and, when non-empty, the surrounding blank +/// lines) from `~/.vibe/hooks.toml` and the sibling `~/.vibe/prompts/rtk.md` +/// prompt file. Leaves any other user-declared hooks intact. +fn uninstall_vibe_at(vibe_dir: &Path, ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; + let mut removed = Vec::new(); + + let prompt_path = vibe_dir.join(VIBE_PROMPTS_SUBDIR).join(VIBE_PROMPT_FILE); + if prompt_path.exists() { + if dry_run { + println!( + "[dry-run] would remove Vibe RTK prompt: {}", + prompt_path.display() + ); + } else { + // nosemgrep: filesystem-deletion -- uninstall path removes only RTK's own prompt file + fs::remove_file(&prompt_path) + .with_context(|| format!("Failed to remove {}", prompt_path.display()))?; + } + removed.push(format!("Vibe prompt: {}", prompt_path.display())); + } + + let hooks_path = vibe_dir.join(VIBE_HOOKS_FILE); + if hooks_path.exists() { + let content = fs::read_to_string(&hooks_path) + .with_context(|| format!("Failed to read {}", hooks_path.display()))?; + if let Some(new_content) = strip_vibe_rtk_entry(&content) { + if dry_run { + println!( + "[dry-run] would remove RTK hook from Vibe hooks.toml: {}", + hooks_path.display() + ); + } else if new_content.trim().is_empty() { + // nosemgrep: filesystem-deletion -- uninstall removes hooks.toml only when it becomes empty after stripping the RTK entry + fs::remove_file(&hooks_path) + .with_context(|| format!("Failed to remove {}", hooks_path.display()))?; + } else { + atomic_write(&hooks_path, &new_content) + .with_context(|| format!("Failed to write {}", hooks_path.display()))?; + } + removed.push(format!( + "Vibe hooks.toml: removed RTK entry ({})", + hooks_path.display() + )); + } + } + + if verbose > 0 && !removed.is_empty() { + eprintln!("Vibe artifacts removed"); + } + + Ok(removed) +} + +/// Extract and drop the `[[hooks]]` block whose `name = "rtk-rewrite"` field +/// is set. Returns `None` when the entry is absent, `Some(new_content)` after +/// removal (with surrounding blank lines collapsed). The scan walks `[[hooks]]` +/// section boundaries — anything else in the file is preserved verbatim. +fn strip_vibe_rtk_entry(content: &str) -> Option { + let needle = format!(r#"name = "{VIBE_HOOK_NAME}""#); + if !content.contains(&needle) { + return None; + } + + let lines: Vec<&str> = content.lines().collect(); + let mut sections: Vec<(usize, usize)> = Vec::new(); + let mut current_start: Option = None; + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with("[[hooks]]") || trimmed.starts_with('[') { + if let Some(start) = current_start.take() { + sections.push((start, i)); + } + if trimmed.starts_with("[[hooks]]") { + current_start = Some(i); + } + } + } + if let Some(start) = current_start { + sections.push((start, lines.len())); + } + + let target = sections + .iter() + .find(|(start, end)| lines[*start..*end].iter().any(|l| l.contains(&needle)))?; + + let mut kept: Vec<&str> = Vec::with_capacity(lines.len()); + kept.extend(&lines[..target.0]); + kept.extend(&lines[target.1..]); + + let mut out = kept.join("\n"); + while out.contains("\n\n\n") { + out = out.replace("\n\n\n", "\n\n"); + } + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + Some(out) +} + // ── Copilot integration ───────────────────────────────────── -// PreToolUse = VS Code schema, preToolUse = Copilot CLI schema (same file, both hosts). -const COPILOT_HOOK_JSON: &str = r#"{ +// Single PascalCase `PreToolUse` entry, shared by VS Code Copilot Chat and +// Copilot CLI. Previously this file also declared a camelCase `preToolUse` +// entry for Copilot CLI's native schema, but Copilot CLI registers BOTH keys +// as independent hooks and runs them sequentially, chaining the camelCase +// hook's rewrite into the PascalCase hook's input — a redundant second +// process spawn per tool call for no behavioral benefit (confirmed live: +// Copilot CLI honors the PascalCase-only schema on its own, receiving the +// same `tool_name`/`tool_input.command` shape either way). +pub(crate) const COPILOT_HOOK_JSON: &str = r#"{ "version": 1, "hooks": { "PreToolUse": [ @@ -4459,15 +4807,6 @@ const COPILOT_HOOK_JSON: &str = r#"{ "cwd": ".", "timeout": 5 } - ], - "preToolUse": [ - { - "type": "command", - "bash": "rtk hook copilot", - "powershell": "rtk hook copilot", - "cwd": ".", - "timeoutSec": 5 - } ] } } @@ -4630,7 +4969,7 @@ fn uninstall_copilot_at(base: &Path, ctx: InitContext) -> Result> { Ok(removed) } -fn copilot_user_dir() -> Result { +pub(crate) fn copilot_user_dir() -> Result { if let Ok(custom) = std::env::var(COPILOT_HOME_ENV) { return Ok(PathBuf::from(custom)); } @@ -7534,25 +7873,23 @@ mod tests { } #[test] - fn test_copilot_hook_json_serves_both_vscode_and_cli_schemas() { + fn test_copilot_hook_json_serves_single_pascalcase_schema() { let v: serde_json::Value = serde_json::from_str(COPILOT_HOOK_JSON).unwrap(); let vscode = &v["hooks"]["PreToolUse"][0]; assert_eq!(vscode["command"], "rtk hook copilot"); assert!(vscode["timeout"].is_number(), "VS Code uses `timeout`"); + assert_eq!(v["version"], 1); - assert_eq!(v["version"], 1, "Copilot CLI requires top-level version"); - let cli = &v["hooks"]["preToolUse"][0]; - assert_eq!(cli["bash"], "rtk hook copilot"); - assert_eq!(cli["powershell"], "rtk hook copilot"); assert!( - cli["timeoutSec"].is_number(), - "Copilot CLI uses `timeoutSec`" + v["hooks"].get("preToolUse").is_none(), + "must not register a second, redundant camelCase hook — Copilot CLI treats \ + PreToolUse and preToolUse as independent hooks and runs both sequentially" ); } #[test] - fn test_copilot_init_writes_dual_schema_to_disk() { + fn test_copilot_init_writes_single_schema_to_disk() { let temp = TempDir::new().unwrap(); run_copilot_at(temp.path(), InitContext::default()).unwrap(); @@ -7566,7 +7903,44 @@ mod tests { assert_eq!(v["hooks"]["PreToolUse"][0]["command"], "rtk hook copilot"); assert_eq!(v["version"], 1); - assert_eq!(v["hooks"]["preToolUse"][0]["bash"], "rtk hook copilot"); + assert!(v["hooks"].get("preToolUse").is_none()); + } + + #[test] + fn test_copilot_init_upgrades_old_dual_schema_install() { + // Simulates a pre-existing install from before this fix, which wrote + // both a PascalCase PreToolUse and a camelCase preToolUse entry. + // Re-running `rtk init --copilot` must overwrite it with the current + // single-schema config, not leave the stale camelCase entry in place. + let old_dual_schema_json = r#"{ + "version": 1, + "hooks": { + "PreToolUse": [ + { "type": "command", "command": "rtk hook copilot", "cwd": ".", "timeout": 5 } + ], + "preToolUse": [ + { "type": "command", "bash": "rtk hook copilot", "powershell": "rtk hook copilot", "cwd": ".", "timeoutSec": 5 } + ] + } +} +"#; + + let temp = TempDir::new().unwrap(); + let hooks_dir = temp.path().join(".github").join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + let hook_path = hooks_dir.join("rtk-rewrite.json"); + fs::write(&hook_path, old_dual_schema_json).unwrap(); + + run_copilot_at(temp.path(), InitContext::default()).unwrap(); + + let v: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hook_path).unwrap()).unwrap(); + assert_eq!(v["hooks"]["PreToolUse"][0]["command"], "rtk hook copilot"); + assert!( + v["hooks"].get("preToolUse").is_none(), + "re-running init must upgrade an old dual-schema install, dropping the \ + redundant camelCase preToolUse entry" + ); } #[test] @@ -7694,7 +8068,39 @@ mod tests { serde_json::from_str(&fs::read_to_string(&hook_path).unwrap()).unwrap(); assert_eq!(v["version"], 1); assert_eq!(v["hooks"]["PreToolUse"][0]["command"], "rtk hook copilot"); - assert_eq!(v["hooks"]["preToolUse"][0]["bash"], "rtk hook copilot"); + assert!(v["hooks"].get("preToolUse").is_none()); + } + + #[test] + fn test_copilot_global_install_upgrades_old_dual_schema_install() { + let old_dual_schema_json = r#"{ + "version": 1, + "hooks": { + "PreToolUse": [ + { "type": "command", "command": "rtk hook copilot", "cwd": ".", "timeout": 5 } + ], + "preToolUse": [ + { "type": "command", "bash": "rtk hook copilot", "powershell": "rtk hook copilot", "cwd": ".", "timeoutSec": 5 } + ] + } +} +"#; + + let temp = TempDir::new().unwrap(); + let hooks_dir = temp.path().join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + let hook_path = hooks_dir.join("rtk-rewrite.json"); + fs::write(&hook_path, old_dual_schema_json).unwrap(); + + run_copilot_global_at(temp.path(), InitContext::default()).unwrap(); + + let v: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&hook_path).unwrap()).unwrap(); + assert_eq!(v["hooks"]["PreToolUse"][0]["command"], "rtk hook copilot"); + assert!( + v["hooks"].get("preToolUse").is_none(), + "re-running global init must upgrade an old dual-schema install" + ); } #[test] @@ -7847,4 +8253,135 @@ mod tests { hook_path.display() ); } + + // ── Vibe tests ──────────────────────────────────────────── + + #[test] + fn test_vibe_detects_rtk_entry_by_name_field() { + assert!(!vibe_hooks_toml_has_rtk("")); + assert!(!vibe_hooks_toml_has_rtk("[[hooks]]\nname = \"other\"\n")); + assert!(vibe_hooks_toml_has_rtk( + "[[hooks]]\nname = \"rtk-rewrite\"\n" + )); + } + + #[test] + fn test_vibe_hook_entry_shape_matches_docs() { + let entry = vibe_hook_entry(); + assert!(entry.contains("[[hooks]]")); + assert!(entry.contains(r#"name = "rtk-rewrite""#)); + assert!(entry.contains(r#"type = "pre_tool""#)); + assert!(entry.contains(r#"match = "bash""#)); + assert!(entry.contains(r#"command = "rtk hook vibe""#)); + assert!(entry.contains("strict = false")); + } + + #[test] + fn test_vibe_strip_returns_none_when_entry_absent() { + let content = "[[hooks]]\nname = \"other\"\ntype = \"post_tool\"\n"; + assert!(strip_vibe_rtk_entry(content).is_none()); + } + + #[test] + fn test_vibe_strip_removes_only_rtk_entry() { + let content = "[[hooks]]\nname = \"user-audit\"\ntype = \"post_tool\"\nmatch = \"*\"\ncommand = \"audit.py\"\n\n[[hooks]]\nname = \"rtk-rewrite\"\ntype = \"pre_tool\"\nmatch = \"bash\"\ncommand = \"rtk hook vibe\"\n"; + let stripped = strip_vibe_rtk_entry(content).expect("expected removal"); + assert!(stripped.contains(r#"name = "user-audit""#)); + assert!(!stripped.contains(r#"name = "rtk-rewrite""#)); + assert!(!stripped.contains("rtk hook vibe")); + } + + #[test] + fn test_vibe_install_creates_hook_and_prompt() { + let temp = TempDir::new().unwrap(); + let vibe_dir = temp.path().join(".vibe"); + run_vibe_mode_at(&vibe_dir, false, PatchMode::Auto, InitContext::default()).unwrap(); + + let hooks_content = fs::read_to_string(vibe_dir.join(VIBE_HOOKS_FILE)).unwrap(); + assert!(hooks_content.contains(r#"name = "rtk-rewrite""#)); + assert!(hooks_content.contains(r#"command = "rtk hook vibe""#)); + + let prompt_path = vibe_dir.join(VIBE_PROMPTS_SUBDIR).join(VIBE_PROMPT_FILE); + assert!(prompt_path.exists()); + } + + #[test] + fn test_vibe_install_is_idempotent() { + let temp = TempDir::new().unwrap(); + let vibe_dir = temp.path().join(".vibe"); + run_vibe_mode_at(&vibe_dir, false, PatchMode::Auto, InitContext::default()).unwrap(); + run_vibe_mode_at(&vibe_dir, false, PatchMode::Auto, InitContext::default()).unwrap(); + + let hooks_content = fs::read_to_string(vibe_dir.join(VIBE_HOOKS_FILE)).unwrap(); + assert_eq!(hooks_content.matches("rtk-rewrite").count(), 1); + } + + #[test] + fn test_vibe_install_preserves_existing_user_hook() { + let temp = TempDir::new().unwrap(); + let vibe_dir = temp.path().join(".vibe"); + fs::create_dir_all(&vibe_dir).unwrap(); + let user_hook = "[[hooks]]\nname = \"user-audit\"\ntype = \"post_tool\"\nmatch = \"*\"\ncommand = \"audit.py\"\n"; + fs::write(vibe_dir.join(VIBE_HOOKS_FILE), user_hook).unwrap(); + + run_vibe_mode_at(&vibe_dir, false, PatchMode::Auto, InitContext::default()).unwrap(); + + let hooks_content = fs::read_to_string(vibe_dir.join(VIBE_HOOKS_FILE)).unwrap(); + assert!(hooks_content.contains(r#"name = "user-audit""#)); + assert!(hooks_content.contains(r#"name = "rtk-rewrite""#)); + } + + #[test] + fn test_vibe_hook_only_skips_prompt_file() { + let temp = TempDir::new().unwrap(); + let vibe_dir = temp.path().join(".vibe"); + run_vibe_mode_at(&vibe_dir, true, PatchMode::Auto, InitContext::default()).unwrap(); + + assert!(vibe_dir.join(VIBE_HOOKS_FILE).exists()); + assert!(!vibe_dir + .join(VIBE_PROMPTS_SUBDIR) + .join(VIBE_PROMPT_FILE) + .exists()); + } + + #[test] + fn test_vibe_uninstall_removes_only_rtk_entry_and_prompt() { + let temp = TempDir::new().unwrap(); + let vibe_dir = temp.path().join(".vibe"); + fs::create_dir_all(&vibe_dir).unwrap(); + let user_hook = "[[hooks]]\nname = \"user-audit\"\ntype = \"post_tool\"\nmatch = \"*\"\ncommand = \"audit.py\"\n"; + fs::write(vibe_dir.join(VIBE_HOOKS_FILE), user_hook).unwrap(); + + run_vibe_mode_at(&vibe_dir, false, PatchMode::Auto, InitContext::default()).unwrap(); + assert!(vibe_dir + .join(VIBE_PROMPTS_SUBDIR) + .join(VIBE_PROMPT_FILE) + .exists()); + + let removed_first = uninstall_vibe_at(&vibe_dir, InitContext::default()).unwrap(); + let removed_second = uninstall_vibe_at(&vibe_dir, InitContext::default()).unwrap(); + + assert_eq!(removed_first.len(), 2); + assert!(removed_second.is_empty()); + assert!(!vibe_dir + .join(VIBE_PROMPTS_SUBDIR) + .join(VIBE_PROMPT_FILE) + .exists()); + + let remaining = fs::read_to_string(vibe_dir.join(VIBE_HOOKS_FILE)).unwrap(); + assert!(remaining.contains(r#"name = "user-audit""#)); + assert!(!remaining.contains(r#"name = "rtk-rewrite""#)); + } + + #[test] + fn test_vibe_uninstall_removes_hooks_file_when_no_other_hooks() { + let temp = TempDir::new().unwrap(); + let vibe_dir = temp.path().join(".vibe"); + run_vibe_mode_at(&vibe_dir, false, PatchMode::Auto, InitContext::default()).unwrap(); + assert!(vibe_dir.join(VIBE_HOOKS_FILE).exists()); + + uninstall_vibe_at(&vibe_dir, InitContext::default()).unwrap(); + + assert!(!vibe_dir.join(VIBE_HOOKS_FILE).exists()); + } } diff --git a/src/hooks/permissions.rs b/src/hooks/permissions.rs index 516952bab0..a23fd2d307 100644 --- a/src/hooks/permissions.rs +++ b/src/hooks/permissions.rs @@ -36,6 +36,7 @@ pub enum Host { Cursor, Gemini, Droid, + Vibe, } pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { @@ -44,6 +45,7 @@ pub fn check_command_for(cmd: &str, host: Host) -> PermissionVerdict { Host::Cursor => load_cursor_rules(), Host::Gemini => load_gemini_rules(), Host::Droid => load_droid_rules(), + Host::Vibe => (Vec::new(), Vec::new(), Vec::new()), }; check_command_with_rules(cmd, &deny_rules, &ask_rules, &allow_rules) } diff --git a/src/main.rs b/src/main.rs index d1e0269f5a..b29cf0769b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,8 @@ pub enum AgentTarget { Hermes, /// Factory Droid CLI Droid, + /// Mistral Vibe CLI + Vibe, } #[derive(Parser)] @@ -866,6 +868,8 @@ enum HookCommands { Copilot, /// Process Factory Droid PreToolUse hook (reads JSON from stdin) Droid, + /// Process Mistral Vibe CLI pre_tool hook (reads JSON from stdin) + Vibe, /// Check how a command would be rewritten by the hook engine (dry-run) Check { /// Target agent @@ -1566,6 +1570,8 @@ where uninstall_hermes(ctx) } else if agent == Some(AgentTarget::Droid) { hooks::init::uninstall_droid(global, ctx) + } else if agent == Some(AgentTarget::Vibe) { + hooks::init::uninstall_vibe(ctx) } else { let cursor = agent == Some(AgentTarget::Cursor); let pi = agent == Some(AgentTarget::Pi); @@ -2068,6 +2074,15 @@ fn run_cli() -> Result { hooks::init::run_hermes_mode(ctx)?; } else if agent == Some(AgentTarget::Droid) { hooks::init::run_droid_mode(global, ctx)?; + } else if agent == Some(AgentTarget::Vibe) { + let patch_mode = if auto_patch { + hooks::init::PatchMode::Auto + } else if no_patch { + hooks::init::PatchMode::Skip + } else { + hooks::init::PatchMode::Ask + }; + hooks::init::run_vibe_mode(global, hook_only, patch_mode, ctx)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -2438,6 +2453,10 @@ fn run_cli() -> Result { hooks::hook_cmd::run_droid()?; 0 } + HookCommands::Vibe => { + hooks::hook_cmd::run_vibe()?; + 0 + } HookCommands::Check { agent: _, command } => { use crate::discover::registry::rewrite_command; let raw = command.join(" "); diff --git a/tests/copilot_selfheal_test.rs b/tests/copilot_selfheal_test.rs new file mode 100644 index 0000000000..f2e519eb5f --- /dev/null +++ b/tests/copilot_selfheal_test.rs @@ -0,0 +1,445 @@ +//! End-to-end tests for the Copilot legacy hook config self-heal. +//! +//! Runs the real `rtk hook copilot` binary against crafted configs in +//! sandboxed HOME/COPILOT_HOME and asserts the hook protocol is never +//! broken: correct responses, exit 0, silent stderr, and configs only +//! ever modified when they contain rtk's own stale camelCase entry. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +const LEGACY_STOCK: &str = r#"{ + "version": 1, + "hooks": { + "PreToolUse": [ + { "type": "command", "command": "rtk hook copilot", "cwd": ".", "timeout": 5 } + ], + "preToolUse": [ + { "type": "command", "bash": "rtk hook copilot", "powershell": "rtk hook copilot", "cwd": ".", "timeoutSec": 5 } + ] + } +} +"#; + +const CURRENT_STOCK: &str = r#"{ + "version": 1, + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "rtk hook copilot", + "cwd": ".", + "timeout": 5 + } + ] + } +} +"#; + +const LEGACY_PAYLOAD: &str = r#"{"toolName":"bash","toolArgs":"{\"command\":\"git status\"}"}"#; +const LEGACY_PAYLOAD_PS: &str = + r#"{"toolName":"powershell","toolArgs":"{\"command\":\"git status\"}"}"#; +const JETBRAINS_PAYLOAD: &str = + r#"{"toolName":"run_in_terminal","toolArgs":"{\"command\":\"git status\"}"}"#; +const PASCAL_PAYLOAD: &str = r#"{"tool_name":"Bash","tool_input":{"command":"git status"}}"#; +const UNKNOWN_TOOL_PAYLOAD: &str = r#"{"tool_name":"Edit","tool_input":{"command":"git status"}}"#; + +struct Sandbox { + _root: TempDir, + home: PathBuf, + copilot_home: PathBuf, + project: PathBuf, +} + +impl Sandbox { + fn new() -> Self { + let root = TempDir::new().expect("tempdir"); + let home = root.path().join("home"); + let copilot_home = root.path().join("copilot-home"); + let project = root.path().join("project"); + std::fs::create_dir_all(&home).expect("mkdir home"); + std::fs::create_dir_all(copilot_home.join("hooks")).expect("mkdir copilot hooks"); + std::fs::create_dir_all(project.join(".github/hooks")).expect("mkdir project hooks"); + Self { + _root: root, + home, + copilot_home, + project, + } + } + + fn project_config(&self) -> PathBuf { + self.project.join(".github/hooks/rtk-rewrite.json") + } + + fn global_config(&self) -> PathBuf { + self.copilot_home.join("hooks/rtk-rewrite.json") + } + + fn write_project(&self, content: &str) { + std::fs::write(self.project_config(), content).expect("write project config"); + } + + fn write_global(&self, content: &str) { + std::fs::write(self.global_config(), content).expect("write global config"); + } + + fn run_hook(&self, payload: &str) -> (String, String, Option) { + let mut child = Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(["hook", "copilot"]) + .current_dir(&self.project) + .env("HOME", &self.home) + .env("COPILOT_HOME", &self.copilot_home) + .env("LC_ALL", "C") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn rtk"); + child + .stdin + .take() + .expect("stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + let out = child.wait_with_output().expect("wait rtk"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.code(), + ) + } +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read config") +} + +fn has_camel(path: &Path) -> bool { + read(path).contains("\"preToolUse\"") +} + +fn assert_hook_ok(payload: &str, stdout: &str, stderr: &str, code: Option) { + assert_eq!(code, Some(0), "hook must exit 0 for payload {payload}"); + assert!( + stderr.is_empty(), + "stderr must stay silent (protocol safety) for payload {payload}, got: {stderr}" + ); + if !stdout.trim().is_empty() { + serde_json::from_str::(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout must be valid JSON for {payload}: {e}\n{stdout}")); + } +} + +// ── Heal correctness ───────────────────────────────────────── + +#[test] +fn legacy_invocation_heals_project_and_global_to_current_stock() { + let sb = Sandbox::new(); + sb.write_project(LEGACY_STOCK); + sb.write_global(LEGACY_STOCK); + + let (stdout, stderr, code) = sb.run_hook(LEGACY_PAYLOAD); + + assert_hook_ok(LEGACY_PAYLOAD, &stdout, &stderr, code); + assert!( + stdout.contains("rtk git status"), + "rewrite must still work during heal: {stdout}" + ); + assert_eq!(read(&sb.project_config()), CURRENT_STOCK); + assert_eq!(read(&sb.global_config()), CURRENT_STOCK); +} + +#[test] +fn powershell_legacy_invocation_also_heals() { + let sb = Sandbox::new(); + sb.write_project(LEGACY_STOCK); + + let (stdout, stderr, code) = sb.run_hook(LEGACY_PAYLOAD_PS); + + assert_hook_ok(LEGACY_PAYLOAD_PS, &stdout, &stderr, code); + assert!(!has_camel(&sb.project_config())); +} + +#[test] +fn heal_preserves_user_hooks_and_key_order() { + let extended = r#"{ + "version": 1, + "customTopLevel": { "keep": true }, + "hooks": { + "sessionStart": [ + { "type": "command", "command": "echo hi" } + ], + "PreToolUse": [ + { "type": "command", "command": "rtk hook copilot", "cwd": ".", "timeout": 5 } + ], + "preToolUse": [ + { "type": "command", "bash": "rtk hook copilot", "powershell": "rtk hook copilot", "cwd": ".", "timeoutSec": 5 } + ] + } +} +"#; + let sb = Sandbox::new(); + sb.write_project(extended); + + let (stdout, stderr, code) = sb.run_hook(LEGACY_PAYLOAD); + + assert_hook_ok(LEGACY_PAYLOAD, &stdout, &stderr, code); + let healed: serde_json::Value = + serde_json::from_str(&read(&sb.project_config())).expect("healed config valid JSON"); + assert!(healed["hooks"].get("preToolUse").is_none()); + assert_eq!(healed["customTopLevel"]["keep"], true); + assert_eq!(healed["hooks"]["sessionStart"][0]["command"], "echo hi"); + assert_eq!( + healed["hooks"]["PreToolUse"][0]["command"], + "rtk hook copilot" + ); + let keys: Vec<&str> = healed["hooks"] + .as_object() + .expect("hooks object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!(keys, ["sessionStart", "PreToolUse"], "key order preserved"); +} + +#[test] +fn heal_is_idempotent_across_invocations() { + let sb = Sandbox::new(); + sb.write_project(LEGACY_STOCK); + sb.write_global(LEGACY_STOCK); + + sb.run_hook(LEGACY_PAYLOAD); + let project_after = read(&sb.project_config()); + let global_after = read(&sb.global_config()); + + for _ in 0..3 { + let (stdout, stderr, code) = sb.run_hook(LEGACY_PAYLOAD); + assert_hook_ok(LEGACY_PAYLOAD, &stdout, &stderr, code); + } + assert_eq!(read(&sb.project_config()), project_after); + assert_eq!(read(&sb.global_config()), global_after); +} + +// ── Response integrity: heal never changes hook behavior ───── + +#[test] +fn response_is_byte_identical_before_and_after_heal() { + let sb = Sandbox::new(); + sb.write_project(LEGACY_STOCK); + + let (before, _, _) = sb.run_hook(LEGACY_PAYLOAD); + assert!(!has_camel(&sb.project_config()), "first run must heal"); + let (after, _, _) = sb.run_hook(LEGACY_PAYLOAD); + + assert_eq!(before, after, "heal must not alter the hook response"); +} + +#[test] +fn pascalcase_invocation_works_and_never_touches_configs() { + let sb = Sandbox::new(); + sb.write_project(LEGACY_STOCK); + sb.write_global(LEGACY_STOCK); + + let (stdout, stderr, code) = sb.run_hook(PASCAL_PAYLOAD); + + assert_hook_ok(PASCAL_PAYLOAD, &stdout, &stderr, code); + assert!( + stdout.contains("rtk git status"), + "PascalCase rewrite must work: {stdout}" + ); + assert_eq!(read(&sb.project_config()), LEGACY_STOCK); + assert_eq!(read(&sb.global_config()), LEGACY_STOCK); +} + +#[test] +fn jetbrains_invocation_works_and_never_touches_configs() { + let sb = Sandbox::new(); + sb.write_project(LEGACY_STOCK); + + let (stdout, stderr, code) = sb.run_hook(JETBRAINS_PAYLOAD); + + assert_hook_ok(JETBRAINS_PAYLOAD, &stdout, &stderr, code); + assert!( + stdout.contains("rtk git status"), + "JetBrains deny-with-suggestion must carry the rewrite: {stdout}" + ); + assert_eq!(read(&sb.project_config()), LEGACY_STOCK); +} + +#[test] +fn non_shell_tool_passes_through_and_never_touches_configs() { + let sb = Sandbox::new(); + sb.write_project(LEGACY_STOCK); + + let (stdout, stderr, code) = sb.run_hook(UNKNOWN_TOOL_PAYLOAD); + + assert_hook_ok(UNKNOWN_TOOL_PAYLOAD, &stdout, &stderr, code); + assert!(stdout.trim().is_empty(), "pass-through must stay silent"); + assert_eq!(read(&sb.project_config()), LEGACY_STOCK); +} + +#[test] +fn garbage_and_empty_stdin_exit_zero_without_touching_configs() { + let sb = Sandbox::new(); + for payload in ["", "not json at all", "{\"toolName\":\"bash\"}"] { + sb.write_project(LEGACY_STOCK); + let (_, _, code) = sb.run_hook(payload); + assert_eq!(code, Some(0), "payload {payload:?} must exit 0"); + assert_eq!( + read(&sb.project_config()), + LEGACY_STOCK, + "payload {payload:?} must not modify configs" + ); + } +} + +// ── Refusal matrix: configs never wrongly modified ─────────── + +#[test] +fn non_stock_configs_are_never_modified_by_legacy_invocations() { + let customized = LEGACY_STOCK.replace( + r#""bash": "rtk hook copilot""#, + r#""bash": "my-wrapper.sh""#, + ); + let extra_field = LEGACY_STOCK.replace(r#""timeoutSec": 5"#, r#""timeoutSec": 5, "x": 1"#); + let two_entries = LEGACY_STOCK.replace( + r#""preToolUse": [ + {"#, + r#""preToolUse": [ + { "type": "command", "bash": "rtk hook copilot", "powershell": "rtk hook copilot", "cwd": ".", "timeoutSec": 5 }, + {"#, + ); + let missing_pascal = LEGACY_STOCK.replace( + r#""PreToolUse": [ + { "type": "command", "command": "rtk hook copilot", "cwd": ".", "timeout": 5 } + ], + "#, + "", + ); + let foreign_pascal = LEGACY_STOCK.replace( + r#""command": "rtk hook copilot""#, + r#""command": "other-tool --hook""#, + ); + for (label, content) in [ + ("customized camelCase", customized.as_str()), + ("extra field in entry", extra_field.as_str()), + ("two camelCase entries", two_entries.as_str()), + ("missing PascalCase", missing_pascal.as_str()), + ("foreign PascalCase", foreign_pascal.as_str()), + ("current stock", CURRENT_STOCK), + ("malformed", "{ not json"), + ("empty object", "{}"), + ("empty file", ""), + ("array root", "[1, 2]"), + ] { + let sb = Sandbox::new(); + sb.write_project(content); + sb.write_global(content); + + let (stdout, stderr, code) = sb.run_hook(LEGACY_PAYLOAD); + + assert_hook_ok(LEGACY_PAYLOAD, &stdout, &stderr, code); + assert!( + stdout.contains("rtk git status"), + "{label}: hook must keep rewriting: {stdout}" + ); + assert_eq!( + read(&sb.project_config()), + content, + "{label}: project modified" + ); + assert_eq!( + read(&sb.global_config()), + content, + "{label}: global modified" + ); + } +} + +#[test] +fn missing_configs_are_never_created() { + let sb = Sandbox::new(); + + let (stdout, stderr, code) = sb.run_hook(LEGACY_PAYLOAD); + + assert_hook_ok(LEGACY_PAYLOAD, &stdout, &stderr, code); + assert!(!sb.project_config().exists()); + assert!(!sb.global_config().exists()); +} + +// ── Robustness ─────────────────────────────────────────────── + +#[test] +fn concurrent_legacy_invocations_leave_valid_healed_configs() { + let sb = Sandbox::new(); + sb.write_project(LEGACY_STOCK); + sb.write_global(LEGACY_STOCK); + + let sb_ref = &sb; + std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| scope.spawn(move || sb_ref.run_hook(LEGACY_PAYLOAD))) + .collect(); + for handle in handles { + let (stdout, stderr, code) = handle.join().expect("thread"); + assert_hook_ok(LEGACY_PAYLOAD, &stdout, &stderr, code); + } + }); + + assert_eq!(read(&sb.project_config()), CURRENT_STOCK); + assert_eq!(read(&sb.global_config()), CURRENT_STOCK); + let stray: Vec<_> = walk(&sb.project) + .into_iter() + .chain(walk(&sb.copilot_home)) + .filter(|p| p.to_string_lossy().contains(".heal.")) + .collect(); + assert!(stray.is_empty(), "temp files left behind: {stray:?}"); +} + +#[cfg(unix)] +#[test] +fn unwritable_hooks_dir_never_breaks_the_hook() { + use std::os::unix::fs::PermissionsExt; + + let sb = Sandbox::new(); + sb.write_project(LEGACY_STOCK); + let hooks_dir = sb.project.join(".github/hooks"); + let mut perms = std::fs::metadata(&hooks_dir).expect("meta").permissions(); + perms.set_mode(0o555); + std::fs::set_permissions(&hooks_dir, perms.clone()).expect("chmod"); + + let (stdout, stderr, code) = sb.run_hook(LEGACY_PAYLOAD); + + perms.set_mode(0o755); + std::fs::set_permissions(&hooks_dir, perms).expect("chmod back"); + + assert_hook_ok(LEGACY_PAYLOAD, &stdout, &stderr, code); + assert!( + stdout.contains("rtk git status"), + "rewrite must survive write failure: {stdout}" + ); + assert_eq!( + read(&sb.project_config()), + LEGACY_STOCK, + "config must stay intact" + ); +} + +fn walk(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + out.extend(walk(&path)); + } else { + out.push(path); + } + } + out +}