feat(hooks): transparent pre_tool rewrite for Mistral Vibe CLI (closes #800) - #3391
Conversation
Add `rtk init -g --agent vibe` and `rtk hook vibe` to route bash tool calls through the RTK proxy via Vibe's newly-shipped pre_tool hook. Implementation follows the Gemini / Droid pattern: - Native binary hook (`rtk hook vibe`), no shell script dependency. - Global-only install (`~/.vibe/hooks.toml`); user-scope only. - Idempotent install: detects existing `name = "rtk-rewrite"` entry. - Uninstall is surgical: strips only the RTK `[[hooks]]` block and the `~/.vibe/prompts/rtk.md` prompt file, preserving any other user hooks byte-for-byte. Removes hooks.toml only when it becomes empty. - Hook response uses Vibe's documented `hook_specific_output.tool_input` rewrite contract with a `system_message` for UI visibility. Vibe hook API reference: https://docs.mistral.ai/vibe/code/cli/hooks Closes rtk-ai#800.
aeppling
left a comment
There was a problem hiding this comment.
Thanks for this — the code follows the Droid (#3211) pattern closely, the install/uninstall logic is well tested, and the transparent-rewrite design is right. Core wiring (constants, permissions, hook_cmd, init, main) is complete. The gaps are in docs, telemetry, and the runtime path:
Blocking
1. Documentation not updated where CONTRIBUTING requires it
docs/guide/getting-started/supported-agents.md— still says Vibe is planned: frontmatter description, intro (line 10), tier table (line 46), and the### Mistral Vibe (planned)section (line 200). Please add a full Vibe section modeled on Factory Droid's (install/uninstall, scope, permission semantics).hooks/README.md— "9 supported agents" (line 7), per-agent sections, and the hook-type comparison tables have no Vibe entry. CONTRIBUTING's documentation table explicitly lists this file for hook system changes.src/hooks/README.md— agent count (line 9) and the per-host ask-support table (line 85) need a Vibe row.README.md:384— "RTK supports 15 AI coding tools" should now read 16.
(Droid skipped the two hook READMEs too, so part of this is inherited debt — but since this PR flips Vibe from planned to supported, it's the right moment to pay it for Vibe at least.)
2. Runtime hook violates the exit-code contract on malformed JSON
run_vibe uses serde_json::from_str(&input).context(...)?, so a bad payload exits non-zero. src/hooks/README.md § Exit Code Contract is explicit: hook processors must return Ok(()) on every path — success, no-match, parse error, unexpected input. run_copilot, run_cursor, and run_droid all do this (stderr warning, return Ok(())); run_gemini doesn't, and hooks/README.md:223 already lists that as a known bug — please don't add a second instance. strict = false mitigates it for the RTK-installed entry, but manual installs may not set it.
3. No tests for the runtime hook path (run_vibe)
The 10 init/uninstall tests are solid, but the hook contract itself has zero coverage. run_droid is structured as a testable run_droid_inner(input) -> Option<String> with payload tests — please mirror that: extract run_vibe_inner(input) and cover the rewrite happy path, non-bash tool passthrough, empty command, and malformed JSON.
Should fix
4. Telemetry agent detection
src/core/telemetry.rs:362 maps installed hook files to an agent name (claude, gemini, cursor, copilot, …). Without a ~/.vibe/hooks.toml entry, Vibe sessions report as unknown. You offered this as a follow-up in the description — it's a small addition (one map entry plus the test enums at lines 579/591), so consider folding it in here.
5. Dead deny arm
Host::Vibe loads no rules, so the Deny verdict — and the {"decision":"deny"} response — can never fire, though the PR description advertises it. Add a short comment explaining the arm is defensive-only, or wire Vibe's native permission config if one exists. Related: does Vibe expose a denylist/allowlist? Droid steps aside on natively denylisted commands so the host's block fires on the original command — if Vibe has an equivalent, it deserves the same treatment.
6. Broken link in the skip-mode message
https://github.com/rtk-ai/rtk#mistral-vibe doesn't resolve — README has no per-agent headings. Suggest pointing at the supported-agents doc page. (Gemini's #gemini-cli link has the same problem; no need to fix that here.)
Nits (non-blocking)
- After
PatchMode::Skipor a declined prompt, the summary still prints "Mistral Vibe CLI hook installed (global)." — misleading, though Gemini shares the quirk. - Install requires
-gbut uninstall works without it — minor asymmetry. vibe_hooks_toml_has_rtkmatches exact spacingname = "rtk-rewrite"; a reformatted file (name="rtk-rewrite") defeats idempotency and causes a duplicate append. Acceptable string-level tradeoff, just noting it.- While in
src/hooks/README.md: the add-agent checklist's step (3) says to register the hook path inhook_check.rs, but productionhook_checkonly checks the Claude hook nowadays — stale instruction, worth a one-line fix.
…docs Addresses @aeppling's review on rtk-ai#3391: Blocking fixes: - run_vibe now returns Ok(()) on malformed JSON (matches run_droid / run_copilot / run_cursor pattern). Prior code violated the exit-code contract documented at src/hooks/README.md:100 — a bad payload exited non-zero and blocked the agent's command. Fixed via a match on serde_json::from_str with a stderr warning fallback. - Extract run_vibe_inner(input: &str) -> Option<String> from run_vibe so the hook contract is unit-testable (mirrors run_droid_inner). Public run_vibe becomes a thin stdin/stdout wrapper. - Add 6 runtime tests exercising the hook contract: bash rewrite happy path, non-bash tool passthrough, empty command passthrough, malformed JSON returns None, unknown binary passthrough, substitution defers. Should-fix: - Telemetry agent detection: add ~/.vibe/hooks.toml to detect_hook_type() checks in src/core/telemetry.rs, plus the two test enum arrays so Vibe sessions no longer report as 'unknown' in rtk gain history. - Dead deny arm: add a comment on Host::Vibe in permissions.rs documenting that the empty-rules branch is defensive scaffolding for when Vibe ships native denylist/allowlist config we can honor. - Broken link: patch_vibe_hooks_toml skip-message now points at https://www.rtk-ai.app/guide/getting-started/supported-agents#mistral-vibe instead of a fragment that doesn't resolve. Nits addressed: - Install summary no longer prints 'hook installed' when the user chose PatchMode::Skip or declined the interactive prompt. patch_vibe_hooks_toml now returns a VibeHookPatchOutcome enum (Installed / AlreadyPresent / Skipped) and the caller gates the summary on it. - Document the string-spacing tradeoff on vibe_hooks_toml_has_rtk: a reformatted 'name="rtk-rewrite"' would defeat idempotency, acceptable because we control the writer and toml_edit round-trip would clobber user comments. - Fix stale line in src/hooks/README.md 'Adding New Functionality': hook_check.rs::maybe_warn() only checks the Claude Code hook now, not every agent. Documentation: - docs/guide/getting-started/supported-agents.md: frontmatter now lists Mistral Vibe, drop 'planned' from the intro, tier table row flipped from 'Planned (rtk-ai#800)' to 'Rust binary (pre_tool) / Yes', replace the ### Mistral Vibe (planned) placeholder with a full user-facing section modeled on Factory Droid (install/uninstall commands, hook mechanism, permission semantics, idempotency contract). - hooks/README.md: agent count 9 -> 10, add Vibe entry to Directory Structure list, add Vibe row to Supported Agents table, add '### Mistral Vibe (Rust Binary)' entry to the JSON Formats section showing the pre_tool input shape and rewrite response shape. - src/hooks/README.md: agent count 5 -> 6, add Vibe row to per-host ask-support table. - README.md: '15 AI coding tools' -> '16'. No behavior change for existing agents.
Every other agent with a dedicated hook implementation carries a hooks/<agent>/README.md (see antigravity/cline/opencode/copilot/hermes for the shape). The initial Vibe commit skipped this, leaving Vibe as the odd one out in the hooks/ layout. - Add hooks/vibe/README.md following the Copilot template (Rust binary hook, no shell dependency). Documents the pre_tool hook location, input JSON shape, rewrite response, passthrough / deny behavior, and the belt-and-suspenders prompt fallback. - Fix hooks/README.md Directory Structure entry to point at vibe/README.md (previously claimed 'no dedicated subdirectory').
The comment was previously added in response to review point rtk-ai#5. Removed per follow-up feedback — the arm itself is self-explanatory in context alongside the other Host variants.
|
Thanks for the thorough review, @aeppling — every point landed. Pushed 1847b07, then folded in two follow-ups (94ae76b, 0430df4) covering all 3 blocking + all 3 should-fix + 3 of the 4 nits. Item-by-item: Blocking1. Documentation — updated all four files:
Follow-up 94ae76b also adds Agree on the inherited-debt call — flipping the hook READMEs for Vibe was the right moment to pay part of that debt down. 2. Exit-code contract — replaced 3. Runtime tests — extracted Should fix4. Telemetry — folded in as suggested. Added 5. Dead deny arm — the arm itself stays for the reason you noted: if Vibe ships a native denylist/allowlist later, wiring it becomes a one-line change in 6. Broken link — the skip-mode message now points at NitsN1 — install summary is now gated on a new N3 — extended the N4 — rewrote the N2 skipped — the install-requires- Follow-ups on top of 1847b07
Quality gates
Ready for another look. |
…paths The Semgrep security scan on PR rtk-ai#3391 flagged 2 new fs::remove_file calls in uninstall_vibe_at as blocking findings under the filesystem-deletion rule (WARNING severity, but the CI runs semgrep --error which promotes all findings). Both calls are legitimate uninstall behavior: - prompt file removal at src/hooks/init.rs:4697 — removes only ~/.vibe/prompts/rtk.md, which RTK installed itself. - hooks.toml removal at src/hooks/init.rs:4714 — removes the file only when it becomes empty after stripping the RTK entry, so no orphan empty file is left behind. Both suppressions follow the existing repo convention (`// nosemgrep: <rule-id> -- <justification>` on the line above the code), matching precedents in src/discover/lexer.rs and src/core/stream.rs.
|
Fixed the Semgrep failure in 4ff41bd — 2 Root cause: Both suppressions carry a short justification per the repo pattern:
All other CI jobs on the prior commit (0430df4) were already green: fmt, clippy, tests × 3 platforms, benchmark, Security Scan, doc review, test presence, CLA. Local CI needs a maintainer to click "Approve and run workflows" on https://github.com/rtk-ai/rtk/actions/runs/31018958220 — same external-fork gate as the earlier runs. |
Two follow-ups to rtk-ai#3391 (review): 1. Move the summary-verb mapping onto VibeHookPatchOutcome as summary_verb() -> Option<&'static str>, returning None for Skipped. The call site becomes 'else if let Some(v) = outcome.summary_verb()' which collapses the guard and the match into a single decision point and removes the unreachable!() branch. If a future variant is added, the compiler forces a decision in summary_verb() and the caller handles it naturally through the Option. 2. uninstall_vibe now prints a stderr warning when resolve_vibe_dir() fails instead of silently returning Ok(()). Users asking to uninstall no longer see an empty response when the home dir can't be resolved. uninstall_gemini has the same swallow-and-return-Ok pattern; leaving that untouched here to keep the diff scoped to Vibe, but the same improvement would apply as a follow-up. Both are quality improvements with no behavior change on the happy path.
|
Tested on mistral vibe, approved. @xavierpestel-ai Thanks for contributing to RTK with this new integration ! Follow-up: We should stop tracking all those agent count, this is useless maintenance and can just cause desync between docs -> #3457 |
Closes #800.
Why now
Issue #800 was blocked on mistralai/mistral-vibe#531 — Vibe CLI lacked a
BeforeTool-style hook to intercept and rewrite bash tool calls beforeexecution. That upstream is now delivered:
https://docs.mistral.ai/vibe/code/cli/hooks documents a
pre_toolhook thatreturns
hook_specific_output.tool_inputto fully replace the model's toolarguments. This is functionally equivalent to Claude Code's
PreToolUseandGemini's
BeforeTool, so RTK can now do a transparent rewrite in Vibe withthe same guarantees as those hosts.
What this PR does
Adds two things:
rtk init -g --agent vibe— installs apre_toolhook entry into~/.vibe/hooks.tomland (optionally) a system prompt fallback at~/.vibe/prompts/rtk.md. Idempotent, dry-run capable, uninstallable.rtk hook vibe— the native binary hook process referenced by theinstalled entry. Reads Vibe's
pre_toolJSON payload from stdin, decidesvia the shared
decide_hook_actionengine, and emits Vibe's rewriteresponse shape on stdout.
Result: every bash tool call Vibe makes (including compound
&&chains) isrewritten to
rtk <cmd>transparently, and the Vibe UI surfaces[rtk-rewrite] rtk: rewrote to \…`` for visibility.Files changed (6 files, +525 / −2)
src/hooks/constants.rsVIBE_DIR,VIBE_HOOKS_FILE,VIBE_HOOK_COMMAND,VIBE_HOOK_NAME, prompts subdir/file, bash match stringsrc/hooks/permissions.rsHost::Vibevariant — Vibe stores hooks inhooks.toml, not in a settings JSON with permission rules, so no rule loader is wiredsrc/hooks/hook_cmd.rspub fn run_vibe()— 56 lines, parses stdin, emits Vibe's rewrite / deny / passthrough responsesrc/hooks/init.rsrun_vibe_mode()+run_vibe_mode_at(),patch_vibe_hooks_toml(),uninstall_vibe()+uninstall_vibe_at(),strip_vibe_rtk_entry(), plus 10 unit testssrc/main.rsAgentTarget::Vibe,HookCommands::Vibe, dispatch throughCommands::Initand uninstall dispatchREADME.mdPlanned (#800) / Blocked on upstreamtortk init -g --agent vibe/pre_tool hook (hooks.toml)Design choices (matching existing integrations in this repo)
rtk hook vibe) rather than a shell script — samepattern as Claude Code / Cursor / Droid post-v0.37.2. Zero shell / bash /
jq runtime dependency, works on Windows out of the box.
rtk init -g --agent vibe) — same constraint asthe Gemini integration, since
~/.vibe/hooks.tomlis user-scoped.rtk-rewritein Vibe's registry,match = "bash",strict = false— a crash inrtk hook vibedegrades to a warning and apassthrough rather than denying the tool call.
rather than a
toml_editparse → serialize round-trip. Preserves anyuser comments and formatting in
hooks.toml. Justified because theoperation is genuinely append-only for install and single-block-removal
for uninstall — no in-place field mutation is needed.
name = "rtk-rewrite"beforeappending; re-running the installer is a no-op.
[[hooks]]block plus the~/.vibe/prompts/rtk.mdprompt file. Preserves any other user-declaredhook in the file byte-for-byte. Removes
hooks.tomlonly when the RTKentry was the sole content, so no orphan empty file is left behind.
~/.vibe/prompts/rtk.md) installed alongside thehook — belt-and-suspenders behavior described in feat: add transparent hook support for Mistral Vibe (BeforeTool) #800.
--hook-onlyskips it if the user doesn't want it.
Hook response contract
Verified against Vibe's docs. On a matching bash command:
{"hook_specific_output":{"tool_input":{"command":"rtk git status"}},"system_message":"rtk: rewrote to `rtk git status`"}(exit 0, empty stdout — Vibe's contract for "no opinion")
{"decision":"deny","reason":"..."}Slightly different from Gemini's contract: Vibe uses
hook_specific_output(snake_case) not
hookSpecificOutput, has noask_userdecision, and doesnot use an
allowdecision for the passthrough case. Therun_vibeimplementation is therefore separate from
run_geminirather than a sharedemitter.
Tests
10 new unit tests in
src/hooks/init.rs::tests, grouped under a// ── Vibe tests ────divider:test_vibe_detects_rtk_entry_by_name_fieldvibe_hooks_toml_has_rtktruth tabletest_vibe_hook_entry_shape_matches_docstest_vibe_strip_returns_none_when_entry_absenttest_vibe_strip_removes_only_rtk_entry[[hooks]]blocks preservedtest_vibe_install_creates_hook_and_prompttest_vibe_install_is_idempotenttest_vibe_install_preserves_existing_user_hooktest_vibe_hook_only_skips_prompt_file--hook-onlyflag honoredtest_vibe_uninstall_removes_only_rtk_entry_and_prompttest_vibe_uninstall_removes_hooks_file_when_no_other_hooksQuality gates
cargo fmt --all— cleancargo clippy --all-targets— no issuescargo test --all— 2572 passed / 0 failed in the unit suite; all 10 new_vibetests greenguard_integration_testfailures reproduce identically onunmodified
develop— a parallelgit inittempdir race in the testharness, unrelated to this change (verified by re-running the same test
set on
developbefore opening this PR)End-to-end manual verification against a real
~/.vibe/Verified live inside a Mistral Vibe session, with the installed hook pointing
at the freshly-built binary:
--dry-runprints the plan without touching disk[[hooks]]entry and~/.vibe/prompts/rtk.mdhooks.tomlwhen the RTK entry was the only content[rtk-rewrite] rtk: rewrote to \rtk git status`` andpasses the rewritten command to the tool execution path
&&chains rewrite per-subcommand (cd X && git status && git log ... && git branch→ each git subcommand becomesrtk git …while thechain structure is preserved) — the trickier case, worked first attempt
because RTK's shared
rewrite_commandalready handles thisHow to review
Suggested reading order:
src/hooks/hook_cmd.rs— therun_vibe()function (~50 lines). Thisis the whole runtime contract. Compare against
run_gemini()right aboveit to see the diff in Vibe's response shape.
src/hooks/init.rs— the install / patch / uninstall / stripquartet.
strip_vibe_rtk_entryis the only piece with non-trivial logic(walks
[[section]]boundaries in the raw TOML text to know what todelete without disturbing siblings) — its behavior is pinned by 4 tests.
src/hooks/init.rs::tests.src/hooks/constants.rs,src/hooks/permissions.rs,src/main.rs,README.md— all mechanical wiring, follow the patternof
AgentTarget::Droid/HookCommands::Droid/Host::Droidfrom fix(permissions): stop extra whitespace from evading deny rules #3211.Try it locally
Then ask Vibe to run any bash command (e.g.
run git status). You shouldsee
[rtk-rewrite] rtk: rewrote to \rtk git status`` in the Vibe UI andcompressed RTK output flowing back to the agent.
Uninstall symmetrically:
Out of scope for this PR (deliberately)
described in feat: add transparent hook support for Mistral Vibe (BeforeTool) #800 as "already implemented" was never actually landed;
rtk init --agent vibedidn't exist before this PR, only the README tablerow referring to feat: add transparent hook support for Mistral Vibe (BeforeTool) #800 did).
follow-up if you'd like Vibe to appear in
rtk gain --historybreakdowns.same way it does on Claude Code / Droid, no extra work needed.
Related