use std::path::Path;
use super::super::{
mcp_server_quiet_mode, resolve_binary_path, resolve_hook_command_binary, should_register_mcp,
};
use crate::core::editor_registry::{
ConfigType, EditorTarget, WriteAction, WriteOptions, vibe_config_path,
write_config_with_options,
};
pub(crate) const VIBE_HOOK_NAME: &str = "lean-ctx-redirect";
pub(crate) const VIBE_HOOK_MATCH: &str = "re:(bash|read_file|grep)";
pub(crate) fn install_vibe_hook() {
if !should_register_mcp() {
return;
}
let home = crate::core::home::resolve_home_dir().unwrap_or_default();
install_vibe_mcp(&home);
install_vibe_hooks_toml(&home);
}
fn install_vibe_mcp(home: &Path) {
let binary = resolve_binary_path();
let display_path = "~/.vibe/config.toml";
let target = EditorTarget {
name: "Mistral Vibe",
agent_key: "vibe".to_string(),
config_path: vibe_config_path(home),
detect_path: home.join(".vibe"),
config_type: ConfigType::VibeToml,
};
match write_config_with_options(&target, &binary, WriteOptions::default()) {
Ok(result) => {
if mcp_server_quiet_mode() {
return;
}
match result.action {
WriteAction::Already => {
eprintln!("Vibe MCP already configured at {display_path}");
}
WriteAction::Created | WriteAction::Updated => {
eprintln!(" \x1b[32m✓\x1b[0m Vibe MCP configured at {display_path}");
}
}
}
Err(e) => {
tracing::error!("Failed to configure Vibe MCP: {e}");
if !mcp_server_quiet_mode() {
eprintln!(" \x1b[31m✗\x1b[0m Vibe MCP configuration failed: {e}");
}
}
}
}
fn install_vibe_hooks_toml(home: &Path) {
let hooks_path = home.join(".vibe/hooks.toml");
let display_path = "~/.vibe/hooks.toml";
let command = format!("{} hook vibe-pre-tool", resolve_hook_command_binary());
let doc_res: Result<toml_edit::DocumentMut, String> = match std::fs::read_to_string(&hooks_path)
{
Ok(existing) if !existing.trim().is_empty() => existing
.parse::<toml_edit::DocumentMut>()
.map_err(|e| e.to_string()),
_ => Ok(toml_edit::DocumentMut::new()),
};
let mut doc = match doc_res {
Ok(doc) => doc,
Err(e) => {
tracing::error!("Failed to parse Vibe hooks.toml: {e}");
if !mcp_server_quiet_mode() {
eprintln!(" \x1b[31m✗\x1b[0m Vibe hooks.toml is invalid TOML — left untouched");
}
return;
}
};
if !upsert_vibe_hook(&mut doc, &command) {
if !mcp_server_quiet_mode() {
eprintln!("Vibe hook already configured in {display_path}");
}
return;
}
if let Err(e) = crate::config_io::write_atomic_with_backup(&hooks_path, &doc.to_string()) {
tracing::error!("Failed to write Vibe hooks.toml: {e}");
if !mcp_server_quiet_mode() {
eprintln!(" \x1b[31m✗\x1b[0m Vibe hook configuration failed: {e}");
}
return;
}
if !mcp_server_quiet_mode() {
eprintln!(" \x1b[32m✓\x1b[0m Vibe pre_tool hook configured at {display_path}");
}
}
fn vibe_hook_entry(command: &str) -> toml_edit::Table {
let mut entry = toml_edit::Table::new();
entry.insert("name", toml_edit::value(VIBE_HOOK_NAME));
entry.insert("type", toml_edit::value("pre_tool"));
entry.insert("match", toml_edit::value(VIBE_HOOK_MATCH));
entry.insert("command", toml_edit::value(command));
entry.insert("timeout", toml_edit::value(60.0));
entry.insert(
"description",
toml_edit::value("Route native bash through lean-ctx; steer read_file/grep to ctx_* tools"),
);
entry
}
fn upsert_vibe_hook(doc: &mut toml_edit::DocumentMut, command: &str) -> bool {
if !matches!(doc.get("hooks"), Some(toml_edit::Item::ArrayOfTables(_))) {
doc.insert(
"hooks",
toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()),
);
}
let Some(toml_edit::Item::ArrayOfTables(hooks)) = doc.get_mut("hooks") else {
return false;
};
let entry = vibe_hook_entry(command);
for table in hooks.iter_mut() {
if table.get("name").and_then(|n| n.as_str()) == Some(VIBE_HOOK_NAME) {
if table.get("command").and_then(|c| c.as_str()) == Some(command)
&& table.get("match").and_then(|m| m.as_str()) == Some(VIBE_HOOK_MATCH)
{
return false;
}
*table = entry;
return true;
}
}
hooks.push(entry);
true
}
#[cfg(test)]
mod tests {
use super::*;
const CMD: &str = "/usr/local/bin/lean-ctx hook vibe-pre-tool";
#[test]
fn fresh_doc_gets_well_formed_hook_entry() {
let mut doc = toml_edit::DocumentMut::new();
assert!(upsert_vibe_hook(&mut doc, CMD));
let round: toml_edit::DocumentMut = doc.to_string().parse().unwrap();
let hooks = round.get("hooks").unwrap().as_array_of_tables().unwrap();
assert_eq!(hooks.len(), 1);
let t = hooks.get(0).unwrap();
assert_eq!(t.get("name").unwrap().as_str(), Some(VIBE_HOOK_NAME));
assert_eq!(t.get("type").unwrap().as_str(), Some("pre_tool"));
assert_eq!(t.get("match").unwrap().as_str(), Some(VIBE_HOOK_MATCH));
assert_eq!(t.get("command").unwrap().as_str(), Some(CMD));
assert_eq!(t.get("timeout").unwrap().as_float(), Some(60.0));
}
#[test]
fn rerun_is_idempotent() {
let mut doc = toml_edit::DocumentMut::new();
assert!(upsert_vibe_hook(&mut doc, CMD));
assert!(!upsert_vibe_hook(&mut doc, CMD));
let hooks = doc.get("hooks").unwrap().as_array_of_tables().unwrap();
assert_eq!(hooks.len(), 1, "must not append a duplicate");
}
#[test]
fn changed_command_updates_in_place() {
let mut doc = toml_edit::DocumentMut::new();
upsert_vibe_hook(&mut doc, CMD);
assert!(upsert_vibe_hook(
&mut doc,
"/new/path/lean-ctx hook vibe-pre-tool"
));
let hooks = doc.get("hooks").unwrap().as_array_of_tables().unwrap();
assert_eq!(hooks.len(), 1, "same name → update, not append");
assert_eq!(
hooks.get(0).unwrap().get("command").unwrap().as_str(),
Some("/new/path/lean-ctx hook vibe-pre-tool")
);
}
#[test]
fn preserves_unrelated_user_hooks() {
let existing = r#"[[hooks]]
name = "my-linter"
type = "post_tool"
match = "edit"
command = "eslint --quiet"
"#;
let mut doc: toml_edit::DocumentMut = existing.parse().unwrap();
assert!(upsert_vibe_hook(&mut doc, CMD));
let hooks = doc.get("hooks").unwrap().as_array_of_tables().unwrap();
assert_eq!(hooks.len(), 2, "user's own hook must survive");
let names: Vec<_> = hooks
.iter()
.filter_map(|t| t.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"my-linter"));
assert!(names.contains(&VIBE_HOOK_NAME));
}
}