1use std::path::PathBuf;
2
3pub mod agents;
4mod support;
5
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum HookMode {
13 #[default]
14 Mcp,
15 Hybrid,
16}
17
18impl std::fmt::Display for HookMode {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 Self::Mcp => write!(f, "MCP"),
22 Self::Hybrid => write!(f, "Hybrid"),
23 }
24 }
25}
26
27impl HookMode {
28 pub fn from_str_loose(s: &str) -> Option<Self> {
29 match s.to_lowercase().replace('-', "").as_str() {
30 "mcp" => Some(Self::Mcp),
31 "hybrid" => Some(Self::Hybrid),
32 _ => None,
33 }
34 }
35
36 pub fn description(&self) -> &'static str {
37 match self {
38 Self::Mcp => "MCP server only (extension/plugin-based agents without reliable shell)",
39 Self::Hybrid => "MCP server + shell hooks for command compression (best of both)",
40 }
41 }
42}
43
44pub const HYBRID_AGENTS: &[&str] = &[
55 "cursor",
56 "gemini",
57 "codex",
58 "claude",
59 "claude-code",
60 "crush",
61 "hermes",
62 "opencode",
63 "openclaw",
64 "pi",
65 "qoder",
66 "windsurf",
67 "amp",
68 "cline",
69 "roo",
70 "copilot",
71 "kiro",
72 "qwen",
73 "trae",
74 "antigravity",
75 "antigravity-cli",
76 "amazonq",
77 "verdent",
78];
79
80pub fn recommend_hook_mode(agent_key: &str) -> HookMode {
81 if HYBRID_AGENTS.contains(&agent_key) {
82 HookMode::Hybrid
83 } else {
84 HookMode::Mcp
86 }
87}
88use agents::{
89 install_amp_hook, install_antigravity_cli_hook, install_antigravity_hook,
90 install_claude_hook_config, install_claude_hook_scripts, install_claude_hook_with_mode,
91 install_claude_project_hooks, install_cline_rules, install_codebuddy_hook_config,
92 install_codebuddy_hook_scripts, install_codebuddy_hook_with_mode,
93 install_codebuddy_project_hooks, install_codex_hook, install_copilot_hook,
94 install_crush_hook_with_mode, install_cursor_hook_config, install_cursor_hook_scripts,
95 install_cursor_hook_with_mode, install_gemini_hook, install_gemini_hook_config,
96 install_gemini_hook_scripts, install_hermes_hook_with_mode, install_jetbrains_hook,
97 install_kiro_hook, install_openclaw_hook, install_opencode_hook_with_mode,
98 install_pi_hook_with_mode, install_qoder_hook, install_qoder_hook_with_mode,
99 install_windsurf_hooks, install_windsurf_rules,
100};
101use support::{
102 ensure_codex_hooks_enabled, install_codex_instruction_docs, install_named_json_server,
103 upsert_lean_ctx_codex_hook_entries,
104};
105
106fn mcp_server_quiet_mode() -> bool {
107 std::env::var_os("LEAN_CTX_MCP_SERVER").is_some()
108 || matches!(std::env::var("LEAN_CTX_QUIET"), Ok(value) if value.trim() == "1")
109}
110
111const REFRESHABLE_HOOK_AGENTS: &[&str] = &[
119 "claude", "cursor", "gemini", "codex", "windsurf", "copilot", "qoder",
120];
121
122#[cfg(test)]
127const REFRESH_EXEMPT_HYBRID_AGENTS: &[&str] = &[
128 "claude-code",
130 "pi",
132 "cline",
135 "roo",
136 "kiro",
137 "antigravity",
140 "antigravity-cli",
141 "amp",
142 "crush",
143 "hermes",
144 "opencode",
145 "openclaw",
146 "qwen",
147 "trae",
148 "amazonq",
149 "verdent",
150];
151
152pub fn refresh_installed_hooks() {
157 let Some(home) = crate::core::home::resolve_home_dir() else {
158 return;
159 };
160 for agent in REFRESHABLE_HOOK_AGENTS {
161 if hooks_installed_for(agent, &home) {
162 refresh_agent_hooks(agent, &home);
163 }
164 }
165}
166
167fn hooks_installed_for(agent: &str, home: &std::path::Path) -> bool {
169 match agent {
170 "claude" => {
171 let dir = crate::setup::claude_config_dir(home);
172 dir.join("hooks/lean-ctx-rewrite.sh").exists()
173 || file_contains_lean_ctx(&dir.join("settings.json"))
174 }
175 "codebuddy" => {
176 let dir = crate::core::editor_registry::codebuddy_state_dir(home);
177 dir.join("hooks/lean-ctx-rewrite.sh").exists()
178 || file_contains_lean_ctx(&dir.join("settings.json"))
179 }
180 "cursor" => {
181 home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
182 || file_contains_lean_ctx(&home.join(".cursor/hooks.json"))
183 }
184 "gemini" => {
185 home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh")
186 .exists()
187 || home.join(".gemini/hooks/lean-ctx-hook-gemini.sh").exists()
188 }
189 "codex" => {
190 let dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
191 dir.join("hooks/lean-ctx-rewrite-codex.sh").exists()
192 || file_contains_lean_ctx(&dir.join("hooks.json"))
193 }
194 "windsurf" => file_contains_lean_ctx(&home.join(".codeium/windsurf/hooks.json")),
195 "copilot" => {
196 file_contains_lean_ctx(&home.join(".copilot/hooks/hooks.json"))
199 || file_contains_lean_ctx(&home.join(".github/hooks/hooks.json"))
200 }
201 "qoder" => file_contains_lean_ctx(&home.join(".qoder/settings.json")),
202 _ => false,
203 }
204}
205
206fn refresh_agent_hooks(agent: &str, home: &std::path::Path) {
209 match agent {
210 "claude" => {
211 install_claude_hook_scripts(home);
212 install_claude_hook_config(home);
213 }
214 "codebuddy" => {
215 install_codebuddy_hook_scripts(home);
216 install_codebuddy_hook_config(home);
217 }
218 "cursor" => {
219 install_cursor_hook_scripts(home);
220 install_cursor_hook_config(home);
221 }
222 "gemini" => {
223 install_gemini_hook_scripts(home);
224 install_gemini_hook_config(home);
225 }
226 "codex" => install_codex_hook(),
227 "windsurf" => install_windsurf_hooks(home),
228 "copilot" => install_copilot_hook(true),
229 "qoder" => install_qoder_hook(),
230 _ => {}
231 }
232}
233
234fn file_contains_lean_ctx(path: &std::path::Path) -> bool {
235 std::fs::read_to_string(path).is_ok_and(|c| c.contains("lean-ctx"))
236}
237
238fn resolve_binary_path() -> String {
249 crate::core::portable_binary::resolve_portable_binary()
250}
251
252fn resolve_binary_path_for_bash() -> String {
253 let path = resolve_binary_path();
254 to_bash_compatible_path(&path)
255}
256
257pub fn to_bash_compatible_path(path: &str) -> String {
258 let path = match crate::core::pathutil::strip_verbatim_str(path) {
259 Some(stripped) => stripped,
260 None => path.replace('\\', "/"),
261 };
262 if path.len() >= 2 && path.as_bytes()[1] == b':' {
263 let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
264 format!("/{drive}{}", &path[2..])
265 } else {
266 path
267 }
268}
269
270pub fn from_bash_to_native_path(path: &str) -> String {
273 crate::core::pathutil::normalize_tool_path(path)
274}
275
276pub fn normalize_tool_path(path: &str) -> String {
279 crate::core::pathutil::normalize_tool_path(path)
280}
281
282pub fn generate_rewrite_script(binary: &str) -> String {
283 let case_pattern = crate::rewrite_registry::bash_case_pattern();
284 format!(
285 r#"#!/usr/bin/env bash
286# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
287set -euo pipefail
288
289LEAN_CTX_BIN="{binary}"
290
291INPUT=$(cat)
292TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
293
294case "$TOOL" in
295 Bash|bash|PowerShell|powershell) ;;
296 *) exit 0 ;;
297esac
298
299CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
300
301if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
302 exit 0
303fi
304
305case "$CMD" in
306 {case_pattern})
307 # Shell-escape then JSON-escape (two passes)
308 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
309 REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
310 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
311 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
312 ;;
313 *) exit 0 ;;
314esac
315"#
316 )
317}
318
319pub fn generate_compact_rewrite_script(binary: &str) -> String {
320 let case_pattern = crate::rewrite_registry::bash_case_pattern();
321 format!(
322 r#"#!/usr/bin/env bash
323# lean-ctx hook — rewrites shell commands
324set -euo pipefail
325LEAN_CTX_BIN="{binary}"
326INPUT=$(cat)
327CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
328if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
329case "$CMD" in
330 {case_pattern})
331 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
332 REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
333 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
334 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
335 *) exit 0 ;;
336esac
337"#
338 )
339}
340
341const REDIRECT_SCRIPT_CLAUDE: &str = r"#!/usr/bin/env bash
342# lean-ctx PreToolUse hook — all native tools pass through
343# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
344# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
345exit 0
346";
347
348const REDIRECT_SCRIPT_GENERIC: &str = r"#!/usr/bin/env bash
349# lean-ctx hook — all native tools pass through
350exit 0
351";
352
353pub fn hybrid_rules_content() -> String {
354 use crate::core::rules_canonical;
355 format!(
356 "{start}\n<!-- version: {version} -->\n\n\
357# lean-ctx \u{2014} Hybrid Mode (MCP reads + CLI commands)\n\n\
358{bullets}\n\n\
359{never}\n\n\
360{end}",
361 start = rules_canonical::START_MARK,
362 version = rules_canonical::RULES_VERSION,
363 bullets = rules_canonical::BULLETS,
364 never = rules_canonical::NEVER,
365 end = rules_canonical::END_MARK,
366 )
367}
368
369pub fn install_project_rules() {
370 install_project_rules_for_agents(&[]);
371}
372
373pub fn install_project_rules_for_agents(agents: &[&str]) {
376 if crate::core::config::Config::load().rules_scope_effective()
377 == crate::core::config::RulesScope::Global
378 {
379 return;
380 }
381
382 let cwd = std::env::current_dir().unwrap_or_default();
383
384 if !is_inside_git_repo(&cwd) {
385 eprintln!(
386 " Skipping project files: not inside a git repository.\n \
387 Run this command from your project root to create CLAUDE.md / AGENTS.md."
388 );
389 return;
390 }
391
392 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
393 if cwd == home {
394 eprintln!(
395 " Skipping project files: current directory is your home folder.\n \
396 Run this command from a project directory instead."
397 );
398 return;
399 }
400
401 let all = agents.is_empty();
402 let wants = |name: &str| all || agents.iter().any(|a| a.eq_ignore_ascii_case(name));
403
404 ensure_project_agents_integration(&cwd);
405
406 if wants("cursor") || wants("windsurf") {
407 let cursorrules = cwd.join(".cursorrules");
408 if !cursorrules.exists()
409 || !std::fs::read_to_string(&cursorrules)
410 .unwrap_or_default()
411 .contains("lean-ctx")
412 {
413 let content = cursorrules_content();
414 if cursorrules.exists() {
415 let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
416 if !existing.ends_with('\n') {
417 existing.push('\n');
418 }
419 existing.push('\n');
420 existing.push_str(&content);
421 write_file(&cursorrules, &existing);
422 } else {
423 write_file(&cursorrules, &content);
424 }
425 if !mcp_server_quiet_mode() {
426 eprintln!("Created/updated .cursorrules in project root.");
427 }
428 }
429 }
430
431 if wants("claude") {
432 let claude_rules_file = cwd.join(".claude").join("rules").join("lean-ctx.md");
438 if let Ok(existing) = std::fs::read_to_string(&claude_rules_file)
439 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
440 && std::fs::remove_file(&claude_rules_file).is_ok()
441 && !mcp_server_quiet_mode()
442 {
443 eprintln!(
444 "Removed .claude/rules/lean-ctx.md (always-loaded duplicate; AGENTS.md block + skill replace it)."
445 );
446 }
447
448 install_claude_project_hooks(&cwd);
449 }
450
451 if wants("codebuddy") {
452 let codebuddy_rules_file = cwd.join(".codebuddy").join("rules").join("lean-ctx.md");
453 if let Ok(existing) = std::fs::read_to_string(&codebuddy_rules_file)
454 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
455 && std::fs::remove_file(&codebuddy_rules_file).is_ok()
456 && !mcp_server_quiet_mode()
457 {
458 eprintln!(
459 "Removed .codebuddy/rules/lean-ctx.md (always-loaded duplicate; CODEBUDDY.md block + skill replace it)."
460 );
461 }
462
463 install_codebuddy_project_hooks(&cwd);
464 }
465
466 if wants("kiro") {
467 let kiro_dir = cwd.join(".kiro");
468 if kiro_dir.exists() {
469 let steering_dir = kiro_dir.join("steering");
470 let steering_file = steering_dir.join("lean-ctx.md");
471 if !steering_file.exists()
472 || !std::fs::read_to_string(&steering_file)
473 .unwrap_or_default()
474 .contains("lean-ctx")
475 {
476 let _ = std::fs::create_dir_all(&steering_dir);
477 write_file(&steering_file, &kiro_steering_content());
478 if !mcp_server_quiet_mode() {
479 eprintln!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
480 }
481 }
482 }
483 }
484}
485
486const PROJECT_LEAN_CTX_MD_MARKER: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
487const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
488const PROJECT_AGENTS_MD: &str = "AGENTS.md";
489const AGENTS_BLOCK_START: &str = crate::core::rules_canonical::AGENTS_BLOCK_START;
493const AGENTS_BLOCK_END: &str = crate::core::rules_canonical::AGENTS_BLOCK_END;
494
495fn ensure_project_agents_integration(cwd: &std::path::Path) {
496 let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
497 let desired = format!(
498 "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
499 crate::rules_inject::rules_dedicated_markdown()
500 );
501
502 if !lean_ctx_md.exists() {
503 write_file(&lean_ctx_md, &desired);
504 } else if std::fs::read_to_string(&lean_ctx_md)
505 .unwrap_or_default()
506 .contains(PROJECT_LEAN_CTX_MD_MARKER)
507 {
508 let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
509 let version_str = format!(
510 "<!-- version: {} -->",
511 crate::core::rules_canonical::RULES_VERSION
512 );
513 if !current.contains(&version_str) {
514 write_file(&lean_ctx_md, &desired);
515 }
516 }
517
518 let block = format!(
523 "{AGENTS_BLOCK_START}\n\
524## lean-ctx\n\n\
525lean-ctx is active — the MCP tools replace native equivalents.\n\
526Full rules: {PROJECT_LEAN_CTX_MD} (open on demand — do not auto-load).\n\
527{AGENTS_BLOCK_END}\n"
528 );
529
530 let agents_md = cwd.join(PROJECT_AGENTS_MD);
531 if !agents_md.exists() {
532 let content = format!("# Agent Instructions\n\n{block}");
533 write_file(&agents_md, &content);
534 if !mcp_server_quiet_mode() {
535 eprintln!("Created AGENTS.md in project root (lean-ctx reference only).");
536 }
537 return;
538 }
539
540 let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
541
542 if existing.contains("CLI-first Token Optimization for Pi")
543 && !existing.contains(AGENTS_BLOCK_START)
544 {
545 let content = format!("# Agent Instructions\n\n{block}");
546 write_file(&agents_md, &content);
547 return;
548 }
549
550 if existing.contains(AGENTS_BLOCK_START) {
551 let updated = crate::marked_block::replace_marked_block(
552 &existing,
553 AGENTS_BLOCK_START,
554 AGENTS_BLOCK_END,
555 &block,
556 );
557 if updated != existing {
558 write_file(&agents_md, &updated);
559 }
560 return;
561 }
562
563 if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
564 return;
565 }
566
567 let mut out = existing;
568 if !out.ends_with('\n') {
569 out.push('\n');
570 }
571 out.push('\n');
572 out.push_str(&block);
573 write_file(&agents_md, &out);
574 if !mcp_server_quiet_mode() {
575 eprintln!("Updated AGENTS.md (added lean-ctx reference block).");
576 }
577}
578
579pub fn cursorrules_content() -> String {
583 let start = crate::core::rules_canonical::START_MARK;
584 let end = crate::core::rules_canonical::END_MARK;
585 let version = crate::core::rules_canonical::RULES_VERSION;
586 format!(
587 "{start}\n<!-- version: {version} -->\n\n\
588# lean-ctx\n\n\
589{bullets}\n\n\
590{never}\n\
591Full rules: ~/.cursor/rules/lean-ctx.mdc (auto-loaded) \u{2014} do not duplicate here.\n\
592{end}",
593 bullets = crate::core::rules_canonical::BULLETS,
594 never = crate::core::rules_canonical::NEVER,
595 )
596}
597
598pub fn kiro_steering_content() -> String {
599 use crate::core::rules_canonical;
600 format!(
601 "---\n\
602inclusion: always\n\
603---\n\n\
604# Context Engineering Layer\n\n\
605{start}\n\
606<!-- version: {version} -->\n\n\
607The workspace has the `lean-ctx` MCP server installed. \
608You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.\n\n\
609{bullets}\n\n\
610{never}\n\n\
611## When to use native Kiro tools instead\n\n\
612- `fsWrite` / `fsAppend` \u{2014} always use native (lean-ctx doesn't write files)\n\
613- `strReplace` \u{2014} always use native (precise string replacement)\n\
614- `semanticRename` / `smartRelocate` \u{2014} always use native (IDE integration)\n\
615- `getDiagnostics` \u{2014} always use native (language server diagnostics)\n\
616- `deleteFile` \u{2014} always use native\n\
617- Glob \u{2014} always use native glob\n\n\
618{end}",
619 start = rules_canonical::START_MARK,
620 version = rules_canonical::RULES_VERSION,
621 bullets = rules_canonical::BULLETS,
622 never = rules_canonical::NEVER,
623 end = rules_canonical::END_MARK,
624 )
625}
626pub(crate) fn should_register_mcp() -> bool {
633 crate::core::config::Config::load()
634 .setup
635 .should_update_mcp()
636}
637
638pub fn install_agent_hook(agent: &str, global: bool) {
639 install_agent_hook_with_mode(agent, global, HookMode::Mcp);
640}
641
642pub fn install_agent_hook_with_mode(agent: &str, global: bool, mode: HookMode) {
643 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
644 match agent {
645 "claude" | "claude-code" => install_claude_hook_with_mode(global, mode),
646 "codebuddy" => install_codebuddy_hook_with_mode(global, mode),
647 "cursor" => install_cursor_hook_with_mode(global, mode),
648 "gemini" => {
649 install_gemini_hook();
650 install_antigravity_cli_hook();
656 }
657 "antigravity" => install_antigravity_hook(),
658 "antigravity-cli" => install_antigravity_cli_hook(),
659 "augment" => install_mcp_json_agent(
660 "Augment CLI",
661 "~/.augment/settings.json",
662 &crate::core::editor_registry::augment_cli_settings_path(&home),
663 ),
664 "codex" => install_codex_hook(),
665 "windsurf" => install_windsurf_rules(global),
666 "cline" | "roo" => install_cline_rules(global),
667 "copilot" | "vscode" => install_copilot_hook(global),
668 "pi" => install_pi_hook_with_mode(global, mode),
669 "qoder" => install_qoder_hook_with_mode(mode),
670 "qoderwork" => install_mcp_json_agent(
671 "QoderWork",
672 "~/.qoderwork/mcp.json",
673 &home.join(".qoderwork/mcp.json"),
674 ),
675 "qwen" => install_mcp_json_agent(
676 "Qwen Code",
677 "~/.qwen/settings.json",
678 &home.join(".qwen/settings.json"),
679 ),
680 "trae" => install_mcp_json_agent("Trae", "~/.trae/mcp.json", &home.join(".trae/mcp.json")),
681 "amazonq" => install_mcp_json_agent(
682 "Amazon Q Developer",
683 "~/.aws/amazonq/default.json",
684 &home.join(".aws/amazonq/default.json"),
685 ),
686 "jetbrains" => install_jetbrains_hook(),
687 "kiro" => install_kiro_hook(),
688 "verdent" => install_mcp_json_agent(
689 "Verdent",
690 "~/.verdent/mcp.json",
691 &home.join(".verdent/mcp.json"),
692 ),
693 "opencode" => install_opencode_hook_with_mode(mode),
694 "amp" => install_amp_hook(),
695 "crush" => install_crush_hook_with_mode(mode),
696 "openclaw" => install_openclaw_hook(),
697 "hermes" => install_hermes_hook_with_mode(global, mode),
698 "zed" => {
699 let zed_path = crate::core::editor_registry::zed_settings_path(&home);
700 let binary = resolve_binary_path();
701 let entry = full_server_entry(&binary);
702 install_named_json_server("Zed", "settings.json", &zed_path, "context_servers", entry);
703 }
704 "aider" => {
705 install_mcp_json_agent("Aider", "~/.aider/mcp.json", &home.join(".aider/mcp.json"));
706 }
707 "continue" => install_mcp_json_agent(
708 "Continue",
709 "~/.continue/mcp.json",
710 &home.join(".continue/mcp.json"),
711 ),
712 "neovim" => install_mcp_json_agent(
713 "Neovim (mcphub.nvim)",
714 "~/.config/mcphub/servers.json",
715 &home.join(".config/mcphub/servers.json"),
716 ),
717 "emacs" => install_mcp_json_agent(
718 "Emacs (mcp.el)",
719 "~/.emacs.d/mcp.json",
720 &home.join(".emacs.d/mcp.json"),
721 ),
722 "sublime" => install_mcp_json_agent(
723 "Sublime Text",
724 "~/.config/sublime-text/mcp.json",
725 &home.join(".config/sublime-text/mcp.json"),
726 ),
727 _ => {
728 eprintln!("Unknown agent: {agent}");
729 eprintln!(" Supported: aider, amazonq, amp, antigravity, antigravity-cli, augment,");
730 eprintln!(
731 " claude, cline, codebuddy, codex, continue, copilot, crush, cursor, emacs, gemini,"
732 );
733 eprintln!(" hermes, jetbrains, kiro, neovim, openclaw, opencode, pi, qoder,");
734 eprintln!(" qoderwork, qwen, roo, sublime, trae, verdent, vscode, windsurf, zed");
735 std::process::exit(1);
736 }
737 }
738}
739
740pub fn install_agent_project_hooks(agent: &str, cwd: &std::path::Path) {
741 match agent {
742 "claude" | "claude-code" => agents::install_claude_project_hooks(cwd),
743 "codebuddy" => agents::install_codebuddy_project_hooks(cwd),
744 _ => {}
745 }
746}
747
748fn write_file(path: &std::path::Path, content: &str) {
749 if std::fs::read_to_string(path).is_ok_and(|existing| existing == content) {
752 return;
753 }
754 if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
755 tracing::error!("Error writing {}: {e}", path.display());
756 }
757}
758
759fn is_inside_git_repo(path: &std::path::Path) -> bool {
760 let mut p = path;
761 loop {
762 if p.join(".git").exists() {
763 return true;
764 }
765 match p.parent() {
766 Some(parent) => p = parent,
767 None => return false,
768 }
769 }
770}
771
772#[cfg(unix)]
773fn make_executable(path: &PathBuf) {
774 use std::os::unix::fs::PermissionsExt;
775 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
776}
777
778#[cfg(not(unix))]
779fn make_executable(_path: &PathBuf) {}
780
781pub(crate) fn mcp_server_env_pairs() -> Vec<(String, String)> {
794 let mut pairs = Vec::new();
795
796 let cfg = crate::core::config::Config::load();
797
798 let project_root = std::env::var("LEAN_CTX_PROJECT_ROOT")
799 .ok()
800 .filter(|v| !v.trim().is_empty())
801 .or_else(|| cfg.project_root.clone().filter(|v| !v.trim().is_empty()));
802 if let Some(root) = project_root {
803 pairs.push(("LEAN_CTX_PROJECT_ROOT".to_string(), root));
804 }
805
806 let extra_roots = std::env::var("LEAN_CTX_EXTRA_ROOTS")
809 .ok()
810 .filter(|v| !v.trim().is_empty())
811 .or_else(|| {
812 let roots: Vec<&str> = cfg
813 .extra_roots
814 .iter()
815 .map(String::as_str)
816 .filter(|s| !s.trim().is_empty())
817 .collect();
818 if roots.is_empty() {
819 return None;
820 }
821 std::env::join_paths(roots)
822 .ok()
823 .map(|s| s.to_string_lossy().to_string())
824 });
825 if let Some(extra) = extra_roots {
826 pairs.push(("LEAN_CTX_EXTRA_ROOTS".to_string(), extra));
827 }
828
829 pairs
830}
831
832pub(crate) fn mcp_server_env_json() -> serde_json::Value {
834 let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
835 .into_iter()
836 .map(|(k, v)| (k, serde_json::Value::String(v)))
837 .collect();
838 serde_json::Value::Object(map)
839}
840
841fn full_server_entry(binary: &str) -> serde_json::Value {
842 serde_json::json!({
848 "command": binary,
849 "env": mcp_server_env_json()
850 })
851}
852
853pub(crate) fn install_mcp_json_agent(
854 name: &str,
855 display_path: &str,
856 config_path: &std::path::Path,
857) {
858 let binary = resolve_binary_path();
859 let entry = full_server_entry(&binary);
860 install_named_json_server(name, display_path, config_path, "mcpServers", entry);
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866
867 #[test]
868 fn refresh_covers_every_hybrid_agent() {
869 for agent in HYBRID_AGENTS {
872 let refreshed = REFRESHABLE_HOOK_AGENTS.contains(agent);
873 let exempt = REFRESH_EXEMPT_HYBRID_AGENTS.contains(agent);
874 assert!(
875 refreshed ^ exempt,
876 "hybrid agent `{agent}` must be either refreshed or explicitly exempt (exactly one)"
877 );
878 }
879 }
880
881 #[test]
882 fn refresh_sets_reference_only_hybrid_agents() {
883 for agent in REFRESHABLE_HOOK_AGENTS {
884 assert!(
885 HYBRID_AGENTS.contains(agent),
886 "refreshable agent `{agent}` is not a Hybrid agent"
887 );
888 }
889 for agent in REFRESH_EXEMPT_HYBRID_AGENTS {
890 assert!(
891 HYBRID_AGENTS.contains(agent),
892 "exempt agent `{agent}` is not a Hybrid agent (stale exemption?)"
893 );
894 }
895 }
896
897 #[test]
898 fn mcp_env_pairs_propagate_project_and_extra_roots_from_env() {
899 let _iso = crate::core::data_dir::isolated_data_dir();
903 crate::test_env::set_var("LEAN_CTX_PROJECT_ROOT", "/work/main");
904 crate::test_env::set_var("LEAN_CTX_EXTRA_ROOTS", "/work/wt-a:/work/wt-b");
905
906 let pairs = mcp_server_env_pairs();
907 let get = |k: &str| pairs.iter().find(|(p, _)| p == k).map(|(_, v)| v.as_str());
908 assert!(
909 get("LEAN_CTX_DATA_DIR").is_none(),
910 "data dir is auto-detected at runtime, never pinned into the config (GH #408)"
911 );
912 assert_eq!(get("LEAN_CTX_PROJECT_ROOT"), Some("/work/main"));
913 assert_eq!(get("LEAN_CTX_EXTRA_ROOTS"), Some("/work/wt-a:/work/wt-b"));
914
915 let json = mcp_server_env_json();
917 assert_eq!(json["LEAN_CTX_PROJECT_ROOT"].as_str(), Some("/work/main"));
918
919 crate::test_env::remove_var("LEAN_CTX_PROJECT_ROOT");
920 crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
921 }
922
923 #[test]
924 fn mcp_env_pairs_omit_roots_when_unset() {
925 let _iso = crate::core::data_dir::isolated_data_dir();
929 crate::test_env::remove_var("LEAN_CTX_PROJECT_ROOT");
930 crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
931
932 let pairs = mcp_server_env_pairs();
933 let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
934 assert!(!keys.contains(&"LEAN_CTX_DATA_DIR"));
935 assert!(!keys.contains(&"LEAN_CTX_PROJECT_ROOT"));
936 assert!(!keys.contains(&"LEAN_CTX_EXTRA_ROOTS"));
937 }
938
939 #[test]
940 fn hooks_installed_for_is_false_without_artifacts() {
941 let tmp = unique_tmp_dir("leanctx_refresh_empty");
942 for agent in REFRESHABLE_HOOK_AGENTS {
943 if *agent == "codex" {
947 continue;
948 }
949 assert!(
950 !hooks_installed_for(agent, &tmp),
951 "`{agent}` should not be detected as installed in an empty home"
952 );
953 }
954 let _ = std::fs::remove_dir_all(&tmp);
955 }
956
957 #[test]
958 fn hooks_installed_for_detects_marker_content() {
959 let tmp = unique_tmp_dir("leanctx_refresh_marker");
960 let hooks = tmp.join(".codeium/windsurf/hooks.json");
961 std::fs::create_dir_all(hooks.parent().unwrap()).unwrap();
962
963 std::fs::write(&hooks, "{\"hooks\":{}}").unwrap();
965 assert!(!hooks_installed_for("windsurf", &tmp));
966
967 std::fs::write(&hooks, "{\"hooks\":{\"cmd\":\"lean-ctx hook rewrite\"}}").unwrap();
969 assert!(hooks_installed_for("windsurf", &tmp));
970
971 let _ = std::fs::remove_dir_all(&tmp);
972 }
973
974 fn unique_tmp_dir(prefix: &str) -> std::path::PathBuf {
975 let nanos = std::time::SystemTime::now()
976 .duration_since(std::time::UNIX_EPOCH)
977 .map_or(0, |d| d.as_nanos());
978 let dir = std::env::temp_dir().join(format!("{prefix}_{}_{nanos}", std::process::id()));
979 std::fs::create_dir_all(&dir).unwrap();
980 dir
981 }
982
983 #[test]
984 fn bash_path_unix_unchanged() {
985 assert_eq!(
986 to_bash_compatible_path("/usr/local/bin/lean-ctx"),
987 "/usr/local/bin/lean-ctx"
988 );
989 }
990
991 #[test]
992 fn bash_path_home_unchanged() {
993 assert_eq!(
994 to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
995 "/home/user/.cargo/bin/lean-ctx"
996 );
997 }
998
999 #[test]
1000 fn bash_path_windows_drive_converted() {
1001 assert_eq!(
1002 to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
1003 "/c/Users/Fraser/bin/lean-ctx.exe"
1004 );
1005 }
1006
1007 #[test]
1008 fn bash_path_windows_lowercase_drive() {
1009 assert_eq!(
1010 to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
1011 "/d/tools/lean-ctx.exe"
1012 );
1013 }
1014
1015 #[test]
1016 fn bash_path_windows_forward_slashes() {
1017 assert_eq!(
1018 to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
1019 "/c/Users/Fraser/bin/lean-ctx.exe"
1020 );
1021 }
1022
1023 #[test]
1024 fn bash_path_bare_name_unchanged() {
1025 assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
1026 }
1027
1028 #[cfg(windows)]
1031 #[test]
1032 fn normalize_msys2_path() {
1033 assert_eq!(
1034 normalize_tool_path("/c/Users/game/Downloads/project"),
1035 "C:/Users/game/Downloads/project"
1036 );
1037 assert_eq!(
1038 normalize_tool_path("/d/Projects/app/src"),
1039 "D:/Projects/app/src"
1040 );
1041 }
1042
1043 #[cfg(not(windows))]
1044 #[test]
1045 fn normalize_msys2_path_untouched_on_unix() {
1046 assert_eq!(
1047 crate::core::pathutil::normalize_tool_path_lexical("/c/Users/game/Downloads/project"),
1048 "/c/Users/game/Downloads/project"
1049 );
1050 }
1051
1052 #[test]
1053 fn normalize_backslashes() {
1054 assert_eq!(
1055 normalize_tool_path("C:\\Users\\game\\project\\src"),
1056 "C:/Users/game/project/src"
1057 );
1058 }
1059
1060 #[test]
1061 fn normalize_mixed_separators() {
1062 assert_eq!(
1063 normalize_tool_path("C:\\Users/game\\project/src"),
1064 "C:/Users/game/project/src"
1065 );
1066 }
1067
1068 #[test]
1069 fn normalize_double_slashes() {
1070 assert_eq!(
1071 normalize_tool_path("/home/user//project///src"),
1072 "/home/user/project/src"
1073 );
1074 }
1075
1076 #[test]
1077 fn normalize_trailing_slash() {
1078 assert_eq!(
1079 normalize_tool_path("/home/user/project/"),
1080 "/home/user/project"
1081 );
1082 }
1083
1084 #[test]
1085 fn normalize_root_preserved() {
1086 assert_eq!(normalize_tool_path("/"), "/");
1087 }
1088
1089 #[test]
1090 fn normalize_windows_root_preserved() {
1091 assert_eq!(normalize_tool_path("C:/"), "C:/");
1092 }
1093
1094 #[test]
1095 fn normalize_unix_path_unchanged() {
1096 assert_eq!(
1097 normalize_tool_path("/home/user/project/src/main.rs"),
1098 "/home/user/project/src/main.rs"
1099 );
1100 }
1101
1102 #[test]
1103 fn normalize_relative_path_unchanged() {
1104 assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
1105 }
1106
1107 #[test]
1108 fn normalize_dot_unchanged() {
1109 assert_eq!(normalize_tool_path("."), ".");
1110 }
1111
1112 #[test]
1113 fn normalize_unc_path_preserved() {
1114 assert_eq!(
1115 normalize_tool_path("//server/share/file"),
1116 "//server/share/file"
1117 );
1118 }
1119
1120 #[test]
1121 fn cursor_hook_config_has_version_and_object_hooks() {
1122 let config = serde_json::json!({
1123 "version": 1,
1124 "hooks": {
1125 "preToolUse": [
1126 {
1127 "matcher": "terminal_command",
1128 "command": "lean-ctx hook rewrite"
1129 },
1130 {
1131 "matcher": "read_file|grep|search|list_files|list_directory",
1132 "command": "lean-ctx hook redirect"
1133 }
1134 ]
1135 }
1136 });
1137
1138 let json_str = serde_json::to_string_pretty(&config).unwrap();
1139 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1140
1141 assert_eq!(parsed["version"], 1);
1142 assert!(parsed["hooks"].is_object());
1143 assert!(parsed["hooks"]["preToolUse"].is_array());
1144 assert_eq!(parsed["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
1145 assert_eq!(
1146 parsed["hooks"]["preToolUse"][0]["matcher"],
1147 "terminal_command"
1148 );
1149 }
1150
1151 #[test]
1152 fn cursor_hook_detects_old_format_needs_migration() {
1153 let old_format = r#"{"hooks":[{"event":"preToolUse","command":"lean-ctx hook rewrite"}]}"#;
1154 let has_correct =
1155 old_format.contains("\"version\"") && old_format.contains("\"preToolUse\"");
1156 assert!(
1157 !has_correct,
1158 "Old format should be detected as needing migration"
1159 );
1160 }
1161
1162 #[test]
1163 fn gemini_hook_config_has_type_command() {
1164 let binary = "lean-ctx";
1165 let rewrite_cmd = format!("{binary} hook rewrite");
1166 let redirect_cmd = format!("{binary} hook redirect");
1167
1168 let hook_config = serde_json::json!({
1169 "hooks": {
1170 "BeforeTool": [
1171 {
1172 "hooks": [{
1173 "type": "command",
1174 "command": rewrite_cmd
1175 }]
1176 },
1177 {
1178 "hooks": [{
1179 "type": "command",
1180 "command": redirect_cmd
1181 }]
1182 }
1183 ]
1184 }
1185 });
1186
1187 let parsed = hook_config;
1188 let before_tool = parsed["hooks"]["BeforeTool"].as_array().unwrap();
1189 assert_eq!(before_tool.len(), 2);
1190
1191 let first_hook = &before_tool[0]["hooks"][0];
1192 assert_eq!(first_hook["type"], "command");
1193 assert_eq!(first_hook["command"], "lean-ctx hook rewrite");
1194
1195 let second_hook = &before_tool[1]["hooks"][0];
1196 assert_eq!(second_hook["type"], "command");
1197 assert_eq!(second_hook["command"], "lean-ctx hook redirect");
1198 }
1199
1200 #[test]
1201 fn gemini_hook_old_format_detected() {
1202 let old_format = r#"{"hooks":{"BeforeTool":[{"command":"lean-ctx hook rewrite"}]}}"#;
1203 let has_new = old_format.contains("hook rewrite")
1204 && old_format.contains("hook redirect")
1205 && old_format.contains("\"type\"");
1206 assert!(!has_new, "Missing 'type' field should trigger migration");
1207 }
1208
1209 #[test]
1210 fn rewrite_script_uses_registry_pattern() {
1211 let script = generate_rewrite_script("/usr/bin/lean-ctx");
1212 assert!(script.contains(r"git\ *"), "script missing git pattern");
1213 assert!(script.contains(r"cargo\ *"), "script missing cargo pattern");
1214 assert!(script.contains(r"npm\ *"), "script missing npm pattern");
1215 assert!(script.contains(r"rg\ *"), "script missing rg pattern");
1216 assert!(script.contains(r"ls\ *"), "script missing ls pattern");
1217 assert!(
1218 script.contains("LEAN_CTX_BIN=\"/usr/bin/lean-ctx\""),
1219 "script missing binary path"
1220 );
1221 assert!(
1222 script.contains("PowerShell|powershell"),
1223 "rewrite script must accept PowerShell tool names for Windows compatibility"
1224 );
1225 }
1226
1227 #[test]
1228 fn compact_rewrite_script_uses_registry_pattern() {
1229 let script = generate_compact_rewrite_script("/usr/bin/lean-ctx");
1230 assert!(script.contains(r"git\ *"), "compact script missing git");
1231 assert!(script.contains(r"cargo\ *"), "compact script missing cargo");
1232 assert!(script.contains(r"rg\ *"), "compact script missing rg");
1233 }
1234
1235 #[test]
1236 fn rewrite_scripts_contain_all_registry_commands() {
1237 let script = generate_rewrite_script("lean-ctx");
1238 let compact = generate_compact_rewrite_script("lean-ctx");
1239 for entry in crate::rewrite_registry::REWRITE_COMMANDS {
1240 if matches!(entry.category, crate::rewrite_registry::Category::FileRead) {
1241 continue;
1242 }
1243 let pattern = if entry.command.contains('-') {
1244 format!("{}*", entry.command.replace('-', r"\-"))
1245 } else {
1246 format!(r"{}\ *", entry.command)
1247 };
1248 assert!(
1249 script.contains(&pattern),
1250 "rewrite_script missing '{}' (pattern: {})",
1251 entry.command,
1252 pattern
1253 );
1254 assert!(
1255 compact.contains(&pattern),
1256 "compact_rewrite_script missing '{}' (pattern: {})",
1257 entry.command,
1258 pattern
1259 );
1260 }
1261 }
1262
1263 #[test]
1264 fn codex_is_hybrid() {
1265 assert_eq!(recommend_hook_mode("codex"), HookMode::Hybrid);
1266 }
1267
1268 #[test]
1269 fn cursor_is_hybrid() {
1270 assert_eq!(recommend_hook_mode("cursor"), HookMode::Hybrid);
1271 }
1272
1273 #[test]
1274 fn gemini_is_hybrid() {
1275 assert_eq!(recommend_hook_mode("gemini"), HookMode::Hybrid);
1276 }
1277
1278 #[test]
1279 fn claude_is_hybrid() {
1280 assert_eq!(recommend_hook_mode("claude"), HookMode::Hybrid);
1281 }
1282
1283 #[test]
1284 fn unknown_agent_falls_back_to_mcp() {
1285 assert_eq!(recommend_hook_mode("unknown-agent"), HookMode::Mcp);
1286 }
1287
1288 #[cfg(windows)]
1290 #[test]
1291 fn from_bash_to_native_converts_msys_drive() {
1292 assert_eq!(
1293 from_bash_to_native_path("/c/Users/ABC/lean-ctx"),
1294 "C:/Users/ABC/lean-ctx"
1295 );
1296 assert_eq!(
1297 from_bash_to_native_path("/d/Program Files/lean-ctx.exe"),
1298 "D:/Program Files/lean-ctx.exe"
1299 );
1300 }
1301
1302 #[test]
1303 fn from_bash_to_native_unix_path_unchanged() {
1304 assert_eq!(
1305 from_bash_to_native_path("/usr/local/bin/lean-ctx"),
1306 "/usr/local/bin/lean-ctx"
1307 );
1308 }
1309
1310 #[test]
1311 fn from_bash_to_native_bare_name() {
1312 assert_eq!(from_bash_to_native_path("lean-ctx"), "lean-ctx");
1313 }
1314
1315 #[test]
1316 fn windows_path_to_bash_form() {
1317 let native = r"C:\Users\ABC\AppData\Local\lean-ctx\lean-ctx.exe";
1318 let bash = to_bash_compatible_path(native);
1319 assert_eq!(bash, "/c/Users/ABC/AppData/Local/lean-ctx/lean-ctx.exe");
1320 }
1321
1322 #[cfg(windows)]
1324 #[test]
1325 fn roundtrip_windows_path() {
1326 let native = r"C:\Users\ABC\AppData\Local\lean-ctx\lean-ctx.exe";
1327 let bash = to_bash_compatible_path(native);
1328 let back = from_bash_to_native_path(&bash);
1329 assert_eq!(back, "C:/Users/ABC/AppData/Local/lean-ctx/lean-ctx.exe");
1330 }
1331
1332 #[test]
1333 fn roundtrip_unix_path() {
1334 let native = "/usr/local/bin/lean-ctx";
1335 let bash = to_bash_compatible_path(native);
1336 assert_eq!(bash, native);
1337 let back = from_bash_to_native_path(&bash);
1338 assert_eq!(back, native);
1339 }
1340}