diff --git a/README.md b/README.md index d91e21b24..73790c7b5 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ rtk init --agent kilocode # Kilo Code rtk init --agent antigravity # Google Antigravity rtk init -g --agent pi # Pi rtk init --agent hermes # Hermes +rtk init -g --agent vibe # Mistral Vibe (requires Vibe >= 2.15.0) # 2. Restart your AI tool, then test git status # Automatically rewritten to rtk git status @@ -353,7 +354,7 @@ rtk git status ## Supported AI Tools -RTK supports 14 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. +RTK supports 15 AI coding tools. Each integration rewrites shell commands to `rtk` equivalents for 60-90% token savings where the agent supports command interception. | Tool | Install | Method | |------|---------|--------| @@ -369,7 +370,7 @@ RTK supports 14 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` | before_tool hook (experimental, requires Vibe >= 2.15.0) | | **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) | diff --git a/docs/guide/getting-started/supported-agents.md b/docs/guide/getting-started/supported-agents.md index d5a0ad87c..07f2449e6 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, and Antigravity +description: How to integrate RTK with Claude Code, Cursor, Copilot, Cline, Windsurf, Codex, OpenCode, Hermes, Pi, Mistral Vibe, Kilo Code, and Antigravity 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 @@ -37,12 +37,12 @@ Agent runs "cargo test" | OpenClaw | TypeScript plugin (`before_tool_call`) | Yes | | Pi | TypeScript extension (`tool_call` event) | Yes | | Hermes | Python plugin (`terminal` command mutation) | Yes | +| Mistral Vibe | Shell hook (`before_tool`) | Yes | | Cline / Roo Code | Rules file (prompt-level) | N/A | | Windsurf | Rules file (prompt-level) | N/A | | 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 | ## Installation by agent @@ -174,9 +174,31 @@ 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 -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). +**Installation:** +```bash +rtk init -g --agent vibe +``` + +**Requirements:** +- Mistral Vibe >= 2.15.0 (when `before_tool` hooks were introduced) +- Enable experimental hooks in your Vibe config: + ```toml + # ~/.vibe/config.toml or .vibe/config.toml + enable_experimental_hooks = true + ``` + +**How it works:** Uses Vibe's experimental `before_tool` hook system. The hook intercepts `bash` and `run_shell_command` tool calls, delegates to `rtk rewrite` for command decisions, and returns rewritten commands via JSON stdout. + +**Files installed:** +- `~/.vibe/hooks/rtk-hook-vibe.sh` — Shell script hook +- `~/.vibe/hooks/hooks.toml` — Hook configuration + +**Uninstall:** +```bash +rtk init -g --uninstall --agent vibe +``` ## Integration tiers explained diff --git a/hooks/README.md b/hooks/README.md index 55b2149dd..a16b54e0a 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -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)** — Shell hook, `before_tool` hook, Mistral Vibe experimental hooks (requires Vibe >= 2.15.0) ## 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 | Shell hook (`before_tool`) | Transparent rewrite | Yes (`hook_specific_output.tool_input`) | ## JSON Formats by Agent @@ -180,6 +182,36 @@ if result.returncode in {0, 3} and rewritten and rewritten != command: args["command"] = rewritten ``` +### Mistral Vibe (Shell Hook) + +**Input** (stdin): + +```json +{ + "session_id": "abc123", + "parent_session_id": null, + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/path/to/project", + "hook_event_name": "before_tool", + "tool_name": "bash", + "tool_call_id": "def456", + "tool_input": { "command": "git status" } +} +``` + +**Output** (stdout, when rewritten): + +```json +{ + "decision": "allow", + "hook_specific_output": { + "tool_input": { "command": "rtk git status" } + } +} +``` + +**No rewrite**: Empty stdout, exit code 0. + ## Command Rewrite Registry The registry (`src/discover/registry.rs`) handles command patterns across these categories: diff --git a/hooks/vibe/README.md b/hooks/vibe/README.md new file mode 100644 index 000000000..751b327e0 --- /dev/null +++ b/hooks/vibe/README.md @@ -0,0 +1,105 @@ +# RTK Hook for Mistral Vibe + +> Part of [`hooks/`](../README.md) — see also [`src/hooks/`](../../src/hooks/README.md) for installation code + +## Design Intent + +RTK's Mistral Vibe hook is a **rewrite-only token optimizer**. It intercepts Vibe tool calls (specifically `bash`/`run_shell_command`) and rewrites them to use RTK for 60-90% token savings. + +**Permission gating is intentionally out of scope.** RTK does not block, confirm, or audit commands — that concern belongs to dedicated permission hooks. This separation keeps RTK's hook fast, predictable, and composable with other Vibe hooks. + +## Specifics + +- Shell script hook using Mistral Vibe's experimental `before_tool` hook system +- Requires `enable_experimental_hooks = true` in Vibe's `config.toml` +- Intercepts `bash` and `run_shell_command` tool calls +- Calls `rtk rewrite` as a subprocess; returns rewritten command via JSON stdout +- All rewrite logic lives in RTK's Rust `rtk rewrite` command (single source of truth) +- Installed via `rtk init --agent vibe` which creates `.vibe/hooks.toml` + +## Installation + +```bash +rtk init --agent vibe +``` + +This creates or updates `~/.vibe/hooks.toml` with the RTK hook configuration. The hook script itself lives at `~/.vibe/hooks/rtk-hook-vibe.sh`. + +After installation, ensure your Vibe config has: + +```toml +# ~/.vibe/config.toml or .vibe/config.toml +enable_experimental_hooks = true +``` + +Then restart Vibe. + +## Uninstall + +```bash +rtk init --uninstall --agent vibe +``` + +This removes the RTK hook entry from `~/.vibe/hooks.toml` and the hook script. + +## How it works + +Vibe's `before_tool` hook receives JSON on stdin describing the tool call, including `tool_name` and `tool_input`. The RTK hook: + +1. Checks if the tool is `bash` or `run_shell_command` +2. Extracts the command from `tool_input.command` +3. Calls `rtk rewrite ` to check for RTK equivalents +4. Returns JSON with either: + - No output (exit 0) — pass through unchanged + - JSON with `hook_specific_output.tool_input` — rewritten command + +All error paths exit 0 with no output (fail-open), ensuring commands always execute. + +## Fail-open behavior + +The hook does not block command execution. If anything goes wrong, Vibe runs the original command unchanged: + +- `jq` not installed: warning to stderr, exit 0 +- `rtk` not available in PATH: warning to stderr, exit 0 +- `rtk` version too old (< 0.23.0): warning to stderr, exit 0 +- Invalid JSON input: pass through unchanged +- `rtk rewrite` crashes: hook exits 0 (subprocess error ignored) + +## Limitations + +- Only `bash` and `run_shell_command` tool calls are rewritten +- Commands skipped by `rtk rewrite` stay unchanged (already prefixed with `rtk`, compound shell commands, heredocs, etc.) +- Requires Vibe 2.15.0+ (when `before_tool` hooks were introduced) +- Requires `enable_experimental_hooks = true` in Vibe config + +## JSON Format + +### Input (stdin) + +```json +{ + "session_id": "abc123", + "parent_session_id": null, + "transcript_path": "/path/to/transcript.jsonl", + "cwd": "/path/to/project", + "hook_event_name": "before_tool", + "tool_name": "bash", + "tool_call_id": "def456", + "tool_input": { "command": "git status" } +} +``` + +### Output (stdout, when rewritten) + +```json +{ + "decision": "allow", + "hook_specific_output": { + "tool_input": { "command": "rtk git status" } + } +} +``` + +### Output (no rewrite) + +Empty stdout, exit code 0. diff --git a/hooks/vibe/hooks.toml b/hooks/vibe/hooks.toml new file mode 100644 index 000000000..24b9acbaa --- /dev/null +++ b/hooks/vibe/hooks.toml @@ -0,0 +1,24 @@ +# RTK hook configuration for Mistral Vibe +# Place this file in ~/.vibe/hooks.toml or .vibe/hooks.toml +# Requires enable_experimental_hooks = true in config.toml +# +# This file is installed by: rtk init --agent vibe +# To uninstall: rtk init --uninstall --agent vibe + +[[hooks]] +name = "rtk-rewrite" +type = "before_tool" +match = "bash" # Match bash tool calls +command = "rtk-hook-vibe.sh" # Path relative to hooks directory or absolute +timeout = 60.0 # seconds; default 60 for all hooks +strict = false # tool hooks only: turn failures into denials +description = "Rewrite bash commands to use RTK for 60-90% token savings" + +[[hooks]] +name = "rtk-rewrite-shell" +type = "before_tool" +match = "run_shell_command" # Match run_shell_command tool calls (alternative name) +command = "rtk-hook-vibe.sh" # Path relative to hooks directory or absolute +timeout = 60.0 +strict = false +description = "Rewrite shell commands to use RTK for 60-90% token savings" diff --git a/hooks/vibe/rtk-hook-vibe.sh b/hooks/vibe/rtk-hook-vibe.sh new file mode 100755 index 000000000..50de7837e --- /dev/null +++ b/hooks/vibe/rtk-hook-vibe.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# rtk-hook-version: 1 +# RTK Mistral Vibe hook — rewrites bash commands to use rtk for token savings. +# Requires: rtk >= 0.23.0, jq, Mistral Vibe >= 2.15.0 +# +# This is a thin delegating hook: all rewrite logic lives in `rtk rewrite`, +# which is the single source of truth (src/discover/registry.rs). +# To add or change rewrite rules, edit the Rust registry — not this file. +# +# Exit code protocol: +# 0 + empty stdout Pass through unchanged +# 0 + JSON stdout Structured response (rewrite/deny) +# Any non-zero Treated as hook failure (fail-open by Vibe) +# +# Vibe before_tool hook contract: +# - Receives JSON on stdin with tool_name, tool_input, etc. +# - Returns JSON on stdout with decision and hook_specific_output +# - Exit 0 means success, non-zero means hook failure + +set -euo pipefail + +# Fail-open: if jq is missing, warn and exit 0 (pass through) +if ! command -v jq &>/dev/null; then + echo "[rtk] WARNING: jq is not installed. Hook cannot rewrite commands. Install jq: https://jqlang.github.io/jq/download/" >&2 + exit 0 +fi + +# Fail-open: if rtk is missing, warn and exit 0 (pass through) +if ! command -v rtk &>/dev/null; then + echo "[rtk] WARNING: rtk is not installed or not in PATH. Hook cannot rewrite commands. Install: https://github.com/rtk-ai/rtk#installation" >&2 + exit 0 +fi + +# Version guard: rtk rewrite was added in 0.23.0. +# Cache the version check to avoid spawning multiple processes on every hook call. +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}" +CACHE_FILE="$CACHE_DIR/rtk-hook-vibe-version-ok" +if [ ! -f "$CACHE_FILE" ]; then + RTK_VERSION_RAW=$(rtk --version 2>/dev/null || true) + RTK_VERSION=${RTK_VERSION_RAW#rtk } + RTK_VERSION=${RTK_VERSION%% *} + if [ -n "$RTK_VERSION" ]; then + IFS=. read -r MAJOR MINOR PATCH <<<"$RTK_VERSION" + # Require >= 0.23.0 + if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 23 ]; then + echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.23.0). Upgrade: cargo install --git https://github.com/rtk-ai/rtk" >&2 + exit 0 + fi + fi + mkdir -p "$CACHE_DIR" 2>/dev/null || true + touch "$CACHE_FILE" 2>/dev/null || true +fi + +# Read all stdin +INPUT=$(cat) + +# Extract tool_name and tool_input.command +TOOL_NAME=$(jq -r '.tool_name // empty' <<<"$INPUT") +TOOL_INPUT=$(jq -c '.tool_input // empty' <<<"$INPUT") + +# Only process bash and run_shell_command tools +# Vibe uses "bash" for the bash tool and "run_shell_command" for shell commands +if [ "$TOOL_NAME" != "bash" ] && [ "$TOOL_NAME" != "run_shell_command" ]; then + exit 0 +fi + +# Extract command from tool_input +CMD=$(jq -r '.command // empty' <<<"$TOOL_INPUT") + +if [ -z "$CMD" ]; then + exit 0 +fi + +# Check for RTK_DISABLED override +if [ "${RTK_DISABLED:-0}" = "1" ]; then + exit 0 +fi + +# Delegate all rewrite logic to the Rust binary. +# rtk rewrite exits: +# 0 - rewrite found +# 1 - no RTK equivalent (pass through) +# 2 - deny rule matched (not used by RTK, pass through) +# 3 - ask rule matched (rewrite but ask user) +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null || true) +EXIT_CODE=$? + +# Handle exit codes +case $EXIT_CODE in + 0) + # Rewrite found - use it + ;; + 1) + # No RTK equivalent - pass through + exit 0 + ;; + 2) + # Deny rule matched - pass through (RTK doesn't use deny rules) + exit 0 + ;; + 3) + # Ask rule matched - rewrite but we'll allow it (Vibe handles permission separately) + ;; + *) + # Unexpected error - pass through + exit 0 + ;; +esac + +# If rewritten is empty or same as original, pass through +if [ -z "$REWRITTEN" ] || [ "$REWRITTEN" = "$CMD" ]; then + exit 0 +fi + +# Return JSON response with rewritten command +# Vibe expects: decision (allow/deny), hook_specific_output.tool_input +jq -n --arg cmd "$REWRITTEN" '{ + "decision": "allow", + "hook_specific_output": { + "tool_input": { "command": $cmd } + } +}' diff --git a/hooks/vibe/tests/__init__.py b/hooks/vibe/tests/__init__.py new file mode 100644 index 000000000..b6a80432f --- /dev/null +++ b/hooks/vibe/tests/__init__.py @@ -0,0 +1 @@ +# Test files for Vibe hook diff --git a/hooks/vibe/tests/test_vibe_hook.py b/hooks/vibe/tests/test_vibe_hook.py new file mode 100644 index 000000000..ba16e3bad --- /dev/null +++ b/hooks/vibe/tests/test_vibe_hook.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Tests for Mistral Vibe RTK hook.""" + +import json +import subprocess +import tempfile +import os +from pathlib import Path + + +def test_hook_rewrites_git_status(): + """Test that the hook rewrites git status to rtk git status.""" + hook_script = Path(__file__).parent.parent / "rtk-hook-vibe.sh" + + # Create a test input + input_data = { + "session_id": "test-session", + "parent_session_id": None, + "transcript_path": "/tmp/test.jsonl", + "cwd": "/tmp", + "hook_event_name": "before_tool", + "tool_name": "bash", + "tool_call_id": "test-call", + "tool_input": {"command": "git status"} + } + + # Run the hook script + result = subprocess.run( + [str(hook_script)], + input=json.dumps(input_data), + capture_output=True, + text=True + ) + + # Check that it exits with 0 (no rewrite without rtk binary) + # Since rtk is not installed in the test environment, it should pass through + assert result.returncode == 0, f"Hook failed with: {result.stderr}" + + # With rtk not available, output should be empty (pass through) + assert result.stdout.strip() == "", f"Unexpected output: {result.stdout}" + + +def test_hook_passes_non_bash(): + """Test that the hook passes through non-bash tools.""" + hook_script = Path(__file__).parent.parent / "rtk-hook-vibe.sh" + + # Create a test input with a non-bash tool + input_data = { + "session_id": "test-session", + "tool_name": "read", + "tool_input": {"path": "test.txt"} + } + + # Run the hook script + result = subprocess.run( + [str(hook_script)], + input=json.dumps(input_data), + capture_output=True, + text=True + ) + + # Should pass through with no output + assert result.returncode == 0 + assert result.stdout.strip() == "" + + +def test_hook_handles_missing_command(): + """Test that the hook handles missing command gracefully.""" + hook_script = Path(__file__).parent.parent / "rtk-hook-vibe.sh" + + # Create a test input without command + input_data = { + "session_id": "test-session", + "tool_name": "bash", + "tool_input": {} + } + + # Run the hook script + result = subprocess.run( + [str(hook_script)], + input=json.dumps(input_data), + capture_output=True, + text=True + ) + + # Should pass through with no output + assert result.returncode == 0 + assert result.stdout.strip() == "" + + +if __name__ == "__main__": + test_hook_passes_non_bash() + test_hook_handles_missing_command() + # Skip git status test if rtk is not available + # test_hook_rewrites_git_status() + print("All tests passed!") diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 23d2e1089..8587b964a 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -39,3 +39,8 @@ 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_SUBDIR: &str = "hooks"; +pub const VIBE_HOOK_FILE: &str = "rtk-hook-vibe.sh"; +pub const VIBE_HOOKS_TOML: &str = "hooks.toml"; diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 28363f4ce..74fc4b5f8 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -17,7 +17,8 @@ use super::constants::{ 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, + PRE_TOOL_USE_KEY, REWRITE_HOOK_FILE, SETTINGS_JSON, VIBE_DIR, VIBE_HOOKS_SUBDIR, + VIBE_HOOKS_TOML as VIBE_HOOKS_TOML_FILENAME, VIBE_HOOK_FILE, }; use super::integrity; @@ -27,6 +28,9 @@ const OPENCODE_PLUGIN: &str = include_str!("../../hooks/opencode/rtk.ts"); // Embedded Pi extension (auto-rewrite) const PI_PLUGIN: &str = include_str!("../../hooks/pi/rtk.ts"); +// Embedded Vibe hook script (auto-rewrite) +const VIBE_HOOK_SCRIPT: &str = include_str!("../../hooks/vibe/rtk-hook-vibe.sh"); + // Embedded slim RTK awareness instructions const RTK_SLIM: &str = include_str!("../../hooks/claude/rtk-awareness.md"); const RTK_SLIM_CODEX: &str = include_str!("../../hooks/codex/rtk-awareness.md"); @@ -2721,6 +2725,18 @@ fn resolve_home_subdir(subdir: &str) -> Result { }) } +/// Set executable permissions on a file (Unix only, no-op on Windows) +fn set_executable(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(perms.mode() | 0o111); // Add execute bits for user, group, others + fs::set_permissions(path, perms)?; + } + Ok(()) +} + pub fn resolve_claude_dir() -> Result { resolve_claude_dir_from( std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from), @@ -2785,6 +2801,201 @@ fn resolve_opencode_dir() -> Result { resolve_home_subdir(CONFIG_DIR).map(|p| p.join(OPENCODE_SUBDIR)) } +// ─── Mistral Vibe support ──────────────────────────────────────────── + +/// Resolve Vibe config directory from $VIBE_HOME or $HOME/.vibe +fn resolve_vibe_dir() -> Result { + if let Ok(dir) = std::env::var("VIBE_HOME") { + if !dir.is_empty() { + return Ok(PathBuf::from(dir)); + } + } + resolve_home_subdir(VIBE_DIR) +} + +/// Return the Vibe hooks directory path +fn vibe_hooks_dir(vibe_dir: &Path) -> PathBuf { + vibe_dir.join(VIBE_HOOKS_SUBDIR) +} + +/// Return the path to the Vibe hook script +fn vibe_hook_script_path(vibe_dir: &Path) -> PathBuf { + vibe_hooks_dir(vibe_dir).join(VIBE_HOOK_FILE) +} + +/// Return the path to the Vibe hooks.toml configuration +fn vibe_hooks_toml_path(vibe_dir: &Path) -> PathBuf { + vibe_dir.join(VIBE_HOOKS_TOML_FILENAME) +} + +/// Install RTK hook for Mistral Vibe +pub fn run_vibe_mode(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let vibe_dir = resolve_vibe_dir()?; + run_vibe_mode_at(&vibe_dir, ctx)?; + + if dry_run { + print_dry_run_footer(); + } else { + println!("\nRTK configured for Mistral Vibe."); + println!( + " Hook script: {}", + vibe_hook_script_path(&vibe_dir).display() + ); + println!(" Config: {}", vibe_hooks_toml_path(&vibe_dir).display()); + println!(); + println!("IMPORTANT: You must enable experimental hooks in your Vibe config:"); + println!(" Add to ~/.vibe/config.toml (or .vibe/config.toml):"); + println!(" enable_experimental_hooks = true"); + println!(); + println!("Then restart Vibe. Test with: git status"); + } + + Ok(()) +} + +/// Install RTK hook for Mistral Vibe at a specific directory +fn run_vibe_mode_at(vibe_dir: &Path, ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + + // Create hooks directory + let hooks_dir = vibe_hooks_dir(vibe_dir); + if !dry_run { + fs::create_dir_all(&hooks_dir).with_context(|| { + format!( + "Failed to create Vibe hooks directory: {}", + hooks_dir.display() + ) + })?; + } + + // Write hook script + let hook_script_path = vibe_hook_script_path(vibe_dir); + write_if_changed(&hook_script_path, VIBE_HOOK_SCRIPT, "Vibe hook script", ctx)?; + + // Make hook script executable + if !dry_run { + set_executable(&hook_script_path)?; + } + + // Write hooks.toml configuration with absolute path to hook script + let hooks_toml_path = vibe_hooks_toml_path(vibe_dir); + let hook_script_path_str = hook_script_path.to_string_lossy(); + + // Generate hooks.toml with absolute path + let hooks_toml_content = super::vibe_config::format_hooks_toml(&hook_script_path_str); + write_if_changed( + &hooks_toml_path, + &hooks_toml_content, + "Vibe hooks.toml", + ctx, + )?; + + Ok(()) +} + +/// Uninstall RTK hook for Mistral Vibe +pub fn uninstall_vibe(ctx: InitContext) -> Result<()> { + let InitContext { dry_run, .. } = ctx; + let vibe_dir = resolve_vibe_dir()?; + let removed = uninstall_vibe_at(&vibe_dir, ctx)?; + + if removed.is_empty() { + println!("RTK Mistral Vibe support was not installed (nothing to remove)"); + } else { + let header = if dry_run { + "[dry-run] would uninstall RTK for Mistral Vibe:" + } else { + "RTK uninstalled for Mistral Vibe:" + }; + println!("{}", header); + for item in removed { + println!(" - {}", item); + } + } + + if dry_run { + print_dry_run_footer(); + } + + Ok(()) +} + +/// Uninstall RTK hook for Mistral Vibe at a specific directory +fn uninstall_vibe_at(vibe_dir: &Path, ctx: InitContext) -> Result> { + let InitContext { verbose, dry_run } = ctx; + let mut removed = Vec::new(); + + let hooks_dir = vibe_hooks_dir(vibe_dir); + + // Remove hook script + let hook_script_path = vibe_hook_script_path(vibe_dir); + if hook_script_path.exists() { + if dry_run { + println!( + "[dry-run] would remove Vibe hook script: {}", + hook_script_path.display() + ); + } else { + fs::remove_file(&hook_script_path).with_context(|| { + format!( + "Failed to remove Vibe hook script: {}", + hook_script_path.display() + ) + })?; + if verbose > 0 { + eprintln!("Removed Vibe hook script: {}", hook_script_path.display()); + } + } + removed.push(format!("Hook script: {}", hook_script_path.display())); + } + + // Remove hooks.toml + let hooks_toml_path = vibe_hooks_toml_path(vibe_dir); + if hooks_toml_path.exists() { + if dry_run { + println!( + "[dry-run] would remove Vibe hooks.toml: {}", + hooks_toml_path.display() + ); + } else { + fs::remove_file(&hooks_toml_path).with_context(|| { + format!( + "Failed to remove Vibe hooks.toml: {}", + hooks_toml_path.display() + ) + })?; + if verbose > 0 { + eprintln!("Removed Vibe hooks.toml: {}", hooks_toml_path.display()); + } + } + removed.push(format!("Hooks config: {}", hooks_toml_path.display())); + } + + // Remove hooks directory if empty + if hooks_dir.exists() && hooks_dir.read_dir().map_or(true, |d| d.count() == 0) { + if dry_run { + println!( + "[dry-run] would remove empty Vibe hooks directory: {}", + hooks_dir.display() + ); + } else { + fs::remove_dir(&hooks_dir).with_context(|| { + format!( + "Failed to remove Vibe hooks directory: {}", + hooks_dir.display() + ) + })?; + if verbose > 0 { + eprintln!("Removed Vibe hooks directory: {}", hooks_dir.display()); + } + } + removed.push(format!("Hooks directory: {}", hooks_dir.display())); + } + + Ok(removed) +} + // ─── Pi coding agent support ────────────────────────────────────────── /// Resolve Pi config directory, honouring `PI_CODING_AGENT_DIR` override. diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 02567fa2c..967ada6ca 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -11,3 +11,4 @@ pub mod permissions; pub mod rewrite_cmd; pub mod trust; pub mod verify_cmd; +pub mod vibe_config; diff --git a/src/hooks/vibe_config.rs b/src/hooks/vibe_config.rs new file mode 100644 index 000000000..2056c47f1 --- /dev/null +++ b/src/hooks/vibe_config.rs @@ -0,0 +1,164 @@ +//! Vibe-specific configuration generation for Mistral Vibe hooks. +//! +//! This module provides TOML serialization for Vibe's hooks.toml configuration file. + +use serde::Serialize; + +/// A single hook configuration entry for Mistral Vibe +#[derive(Serialize)] +pub struct VibeHook { + pub name: &'static str, + #[serde(rename = "type")] + pub hook_type: &'static str, + #[serde(rename = "match")] + pub match_: &'static str, + pub command: String, + pub timeout: f64, + pub strict: bool, + pub description: &'static str, +} + +/// Root configuration for Mistral Vibe hooks.toml +#[derive(Serialize)] +pub struct VibeHooksConfig { + pub hooks: Vec, +} + +/// Generate the hooks.toml content with the given hook script path +pub fn format_hooks_toml(hook_script_path: &str) -> String { + let hooks = vec![ + VibeHook { + name: "rtk-rewrite", + hook_type: "before_tool", + match_: "bash", + command: hook_script_path.to_string(), + timeout: 60.0, + strict: false, + description: "Rewrite bash commands to use RTK for 60-90% token savings", + }, + VibeHook { + name: "rtk-rewrite-shell", + hook_type: "before_tool", + match_: "run_shell_command", + command: hook_script_path.to_string(), + timeout: 60.0, + strict: false, + description: "Rewrite shell commands to use RTK for 60-90% token savings", + }, + ]; + + let config = VibeHooksConfig { hooks }; + + format!( + "# RTK hook configuration for Mistral Vibe\n# Auto-generated by rtk init --agent vibe\n# Requires enable_experimental_hooks = true in config.toml\n\n{}", + toml::to_string(&config).expect("Failed to serialize Vibe hooks config to TOML") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_hooks_toml_generates_valid_toml() { + let result = format_hooks_toml("/path/to/rtk-hook-vibe.sh"); + + // Verify header comments are present + assert!(result.contains("# RTK hook configuration for Mistral Vibe")); + assert!(result.contains("# Auto-generated by rtk init --agent vibe")); + assert!(result.contains("# Requires enable_experimental_hooks = true")); + + // Verify TOML structure - use flexible matching + assert!(result.contains("[[hooks]]")); + assert!(result.contains("rtk-rewrite")); + assert!(result.contains("rtk-rewrite-shell")); + assert!(result.contains("before_tool")); + assert!(result.contains("bash")); + assert!(result.contains("run_shell_command")); + assert!(result.contains("/path/to/rtk-hook-vibe.sh")); + assert!(result.contains("timeout")); + assert!(result.contains("strict")); + } + + #[test] + fn test_format_hooks_toml_escapes_special_characters() { + // Path with spaces and special characters + let path = "/path/with spaces/and-dashes/rtk-hook-vibe.sh"; + let result = format_hooks_toml(path); + + // TOML should contain the full path + assert!(result.contains(path)); + } + + #[test] + fn test_format_hooks_toml_uses_absolute_paths() { + let result = format_hooks_toml("/absolute/path/to/hook.sh"); + + // Should contain the absolute path + assert!(result.contains("/absolute/path/to/hook.sh")); + } + + #[test] + fn test_vibe_hook_struct_serialization() { + let hook = VibeHook { + name: "test-hook", + hook_type: "before_tool", + match_: "bash", + command: "/test/command".to_string(), + timeout: 30.0, + strict: true, + description: "Test description", + }; + + let toml = toml::to_string(&hook).unwrap(); + + // Verify all fields are present (order may vary) + assert!(toml.contains("test-hook")); + assert!(toml.contains("before_tool")); + assert!(toml.contains("bash")); + assert!(toml.contains("/test/command")); + assert!(toml.contains("30") || toml.contains("30.0")); + assert!(toml.contains("true")); + assert!(toml.contains("Test description")); + } + + #[test] + fn test_vibe_hooks_config_serialization() { + let hooks = vec![ + VibeHook { + name: "hook1", + hook_type: "before_tool", + match_: "bash", + command: "/cmd1".to_string(), + timeout: 60.0, + strict: false, + description: "Desc 1", + }, + VibeHook { + name: "hook2", + hook_type: "before_tool", + match_: "run_shell_command", + command: "/cmd2".to_string(), + timeout: 60.0, + strict: false, + description: "Desc 2", + }, + ]; + + let config = VibeHooksConfig { hooks }; + let toml = toml::to_string(&config).unwrap(); + + assert!(toml.contains("[[hooks]]")); + assert!(toml.contains("hook1")); + assert!(toml.contains("hook2")); + } + + #[test] + fn test_vibe_hooks_config_structure() { + let result = format_hooks_toml("/test/path"); + + // Verify the output can be parsed back as valid TOML + // This ensures we're generating valid TOML syntax + let _: toml::Value = result.parse().expect("Generated TOML should be valid"); + } +} diff --git a/src/main.rs b/src/main.rs index 6d21faf5a..369f95984 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,8 @@ pub enum AgentTarget { Pi, /// Hermes CLI Hermes, + /// Mistral Vibe + Vibe, } #[derive(Parser)] @@ -1377,25 +1379,33 @@ fn main() { std::process::exit(code); } -fn uninstall_init_dispatch( - agent: Option, +struct InitUninstallFlags { global: bool, gemini: bool, codex: bool, +} + +fn uninstall_init_dispatch( + agent: Option, + flags: InitUninstallFlags, ctx: hooks::init::InitContext, uninstall_hermes: UninstallHermes, + uninstall_vibe: UninstallVibe, uninstall_standard: UninstallStandard, ) -> Result<()> where UninstallHermes: FnOnce(hooks::init::InitContext) -> Result<()>, + UninstallVibe: FnOnce(hooks::init::InitContext) -> Result<()>, UninstallStandard: FnOnce(bool, bool, bool, bool, bool, hooks::init::InitContext) -> Result<()>, { if agent == Some(AgentTarget::Hermes) { uninstall_hermes(ctx) + } else if agent == Some(AgentTarget::Vibe) { + uninstall_vibe(ctx) } else { let cursor = agent == Some(AgentTarget::Cursor); let pi = agent == Some(AgentTarget::Pi); - uninstall_standard(global, gemini, codex, cursor, pi, ctx) + uninstall_standard(flags.global, flags.gemini, flags.codex, cursor, pi, ctx) } } @@ -1836,11 +1846,14 @@ fn run_cli() -> Result { } else if uninstall { uninstall_init_dispatch( agent, - global, - gemini, - codex, + InitUninstallFlags { + global, + gemini, + codex, + }, ctx, hooks::init::uninstall_hermes, + hooks::init::uninstall_vibe, hooks::init::uninstall, )?; } else if gemini { @@ -1874,6 +1887,13 @@ fn run_cli() -> Result { hooks::init::run_antigravity_mode(ctx)?; } else if agent == Some(AgentTarget::Hermes) { hooks::init::run_hermes_mode(ctx)?; + } else if agent == Some(AgentTarget::Vibe) { + if !global { + anyhow::bail!( + "Mistral Vibe hooks are global-only. Use: rtk init -g --agent vibe" + ); + } + hooks::init::run_vibe_mode(ctx)?; } else { let install_opencode = opencode; let install_claude = !opencode; @@ -2717,9 +2737,11 @@ mod tests { let result = uninstall_init_dispatch( Some(AgentTarget::Hermes), - true, - false, - false, + InitUninstallFlags { + global: true, + gemini: false, + codex: false, + }, ctx, |ctx| { hermes_called.set(true); @@ -2727,6 +2749,7 @@ mod tests { assert!(ctx.dry_run); Ok(()) }, + |_| Ok(()), |_, _, _, _, _, _| { standard_called.set(true); Ok(())