use std::path::PathBuf;
use super::super::{
make_executable, mcp_server_quiet_mode, resolve_binary_path, write_file, HookMode,
};
use super::shared::install_standard_hook_scripts;
fn ensure_pretooluse_hook(
pre: &mut Vec<serde_json::Value>,
matcher_variants: &[&str],
desired_matcher: &str,
desired_command: &str,
) {
if let Some(existing) = pre.iter_mut().find(|v| {
v.get("matcher")
.and_then(|m| m.as_str())
.is_some_and(|m| matcher_variants.contains(&m))
}) {
if let Some(obj) = existing.as_object_mut() {
obj.insert(
"matcher".to_string(),
serde_json::Value::String(desired_matcher.to_string()),
);
obj.insert(
"command".to_string(),
serde_json::Value::String(desired_command.to_string()),
);
}
return;
}
pre.push(serde_json::json!({
"matcher": desired_matcher,
"command": desired_command
}));
}
fn merge_cursor_hooks(existing: &mut serde_json::Value, rewrite_cmd: &str, redirect_cmd: &str) {
let root = existing.as_object_mut().unwrap();
root.insert("version".to_string(), serde_json::json!(1));
let hooks = root
.entry("hooks".to_string())
.or_insert_with(|| serde_json::json!({}));
let hooks_obj = hooks.as_object_mut().unwrap();
let pre = hooks_obj
.entry("preToolUse".to_string())
.or_insert_with(|| serde_json::json!([]));
let pre_arr = pre.as_array_mut().unwrap();
ensure_pretooluse_hook(pre_arr, &["Shell"], "Shell", rewrite_cmd);
ensure_pretooluse_hook(
pre_arr,
&["Read|Grep", "Read", "Grep"],
"Read|Grep",
redirect_cmd,
);
}
pub fn install_cursor_hook(global: bool) {
let Some(home) = crate::core::home::resolve_home_dir() else {
tracing::error!("Cannot resolve home directory");
return;
};
install_cursor_hook_scripts(&home);
install_cursor_hook_config(&home);
let scope = crate::core::config::Config::load().rules_scope_effective();
let skip_project = global || scope == crate::core::config::RulesScope::Global;
if skip_project {
if !mcp_server_quiet_mode() {
eprintln!(
"Global mode: skipping project-local .cursor/rules/ (use without --global in a project)."
);
}
} else {
let rules_dir = PathBuf::from(".cursor").join("rules");
let _ = std::fs::create_dir_all(&rules_dir);
let rule_path = rules_dir.join("lean-ctx.mdc");
if rule_path.exists() {
if !mcp_server_quiet_mode() {
eprintln!("Cursor rule already exists.");
}
} else {
let rule_content = include_str!("../../templates/lean-ctx.mdc");
write_file(&rule_path, rule_content);
if !mcp_server_quiet_mode() {
eprintln!("Created .cursor/rules/lean-ctx.mdc in current project.");
}
}
}
if !mcp_server_quiet_mode() {
eprintln!("Restart Cursor to activate.");
}
}
pub(crate) fn install_cursor_hook_with_mode(global: bool, mode: HookMode) {
match mode {
HookMode::Mcp => install_cursor_hook(global),
HookMode::CliRedirect | HookMode::Hybrid => {
install_cursor_hook(global);
install_cursor_rules_for_mode(global, mode);
}
}
}
fn install_cursor_rules_for_mode(global: bool, mode: HookMode) {
let content = cursor_mdc_for_mode(mode);
let mode_name = match mode {
HookMode::CliRedirect => "cli-redirect",
HookMode::Hybrid => "hybrid",
HookMode::Mcp => "mcp",
};
if global {
if let Some(home) = crate::core::home::resolve_home_dir() {
let global_rules_dir = home.join(".cursor").join("rules");
let _ = std::fs::create_dir_all(&global_rules_dir);
let global_path = global_rules_dir.join("lean-ctx.mdc");
write_file(&global_path, &content);
if !mcp_server_quiet_mode() {
eprintln!(
"Installed Cursor rules in {mode_name} mode at {}",
global_path.display()
);
}
}
} else {
let rules_dir = PathBuf::from(".cursor").join("rules");
let _ = std::fs::create_dir_all(&rules_dir);
let rule_path = rules_dir.join("lean-ctx.mdc");
write_file(&rule_path, &content);
if !mcp_server_quiet_mode() {
eprintln!("Installed Cursor rules in {mode_name} mode at .cursor/rules/lean-ctx.mdc");
}
}
}
fn cursor_mdc_for_mode(mode: HookMode) -> String {
match mode {
HookMode::CliRedirect => {
include_str!("../../templates/lean-ctx-cli-redirect.mdc").to_string()
}
HookMode::Hybrid => include_str!("../../templates/lean-ctx-hybrid.mdc").to_string(),
HookMode::Mcp => include_str!("../../templates/lean-ctx.mdc").to_string(),
}
}
pub(crate) fn install_cursor_hook_scripts(home: &std::path::Path) {
let hooks_dir = home.join(".cursor").join("hooks");
install_standard_hook_scripts(&hooks_dir, "lean-ctx-rewrite.sh", "lean-ctx-redirect.sh");
let native_binary = resolve_binary_path();
let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
write_file(
&rewrite_native,
&format!("#!/bin/sh\nexec {native_binary} hook rewrite\n"),
);
make_executable(&rewrite_native);
let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
write_file(
&redirect_native,
&format!("#!/bin/sh\nexec {native_binary} hook redirect\n"),
);
make_executable(&redirect_native);
}
pub(crate) fn install_cursor_hook_config(home: &std::path::Path) {
let binary = resolve_binary_path();
let rewrite_cmd = format!("{binary} hook rewrite");
let redirect_cmd = format!("{binary} hook redirect");
let hooks_json = home.join(".cursor").join("hooks.json");
let content = if hooks_json.exists() {
std::fs::read_to_string(&hooks_json).unwrap_or_default()
} else {
String::new()
};
let mut existing = if content.trim().is_empty() {
serde_json::json!({})
} else {
crate::core::jsonc::parse_jsonc(&content).unwrap_or_else(|_| serde_json::json!({}))
};
if !existing.is_object() {
existing = serde_json::json!({});
}
merge_cursor_hooks(&mut existing, &rewrite_cmd, &redirect_cmd);
let formatted = serde_json::to_string_pretty(&existing).unwrap_or_default();
write_file(&hooks_json, &formatted);
if !mcp_server_quiet_mode() {
eprintln!("Installed Cursor hooks at {}", hooks_json.display());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_hooks_merge_preserves_other_entries() {
let mut v = serde_json::json!({
"version": 1,
"hooks": {
"preToolUse": [
{ "matcher": "Shell", "command": "/old/bin hook rewrite" },
{ "matcher": "Other", "command": "do-something" }
],
"postToolUse": [
{ "matcher": "Shell", "command": "post" }
]
},
"otherKey": { "x": 1 }
});
merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
assert!(v.get("otherKey").is_some());
assert!(v.pointer("/hooks/postToolUse").is_some());
let pre = v
.pointer("/hooks/preToolUse")
.and_then(|x| x.as_array())
.unwrap();
assert!(pre
.iter()
.any(|e| e.get("matcher").and_then(|m| m.as_str()) == Some("Other")));
assert!(pre.iter().any(|e| {
e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
&& e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook rewrite")
}));
assert!(pre.iter().any(|e| {
e.get("matcher").and_then(|m| m.as_str()) == Some("Read|Grep")
&& e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect")
}));
}
}