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 {
254 crate::core::portable_binary::resolve_portable_binary()
255}
256
257fn resolve_hook_command_binary() -> String {
268 if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
269 return portable;
270 }
271 resolve_binary_path()
272}
273
274fn resolve_binary_path_for_bash() -> String {
275 if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
276 return portable;
277 }
278 to_bash_compatible_path(&resolve_binary_path())
279}
280
281pub fn to_bash_compatible_path(path: &str) -> String {
282 let path = match crate::core::pathutil::strip_verbatim_str(path) {
283 Some(stripped) => stripped,
284 None => path.replace('\\', "/"),
285 };
286 if path.len() >= 2 && path.as_bytes()[1] == b':' {
287 let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
288 format!("/{drive}{}", &path[2..])
289 } else {
290 path
291 }
292}
293
294pub fn from_bash_to_native_path(path: &str) -> String {
297 crate::core::pathutil::normalize_tool_path(path)
298}
299
300pub fn normalize_tool_path(path: &str) -> String {
303 crate::core::pathutil::normalize_tool_path(path)
304}
305
306pub fn generate_rewrite_script(binary: &str) -> String {
307 let case_pattern = crate::rewrite_registry::bash_case_pattern();
308 format!(
309 r#"#!/usr/bin/env bash
310# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
311set -euo pipefail
312
313LEAN_CTX_BIN="{binary}"
314
315INPUT=$(cat)
316TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
317
318case "$TOOL" in
319 Bash|bash|PowerShell|powershell) ;;
320 *) exit 0 ;;
321esac
322
323CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
324
325if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
326 exit 0
327fi
328
329case "$CMD" in
330 {case_pattern})
331 # Shell-escape then JSON-escape (two passes)
332 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
333 REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
334 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
335 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
336 ;;
337 *) exit 0 ;;
338esac
339"#
340 )
341}
342
343pub fn generate_compact_rewrite_script(binary: &str) -> String {
344 let case_pattern = crate::rewrite_registry::bash_case_pattern();
345 format!(
346 r#"#!/usr/bin/env bash
347# lean-ctx hook — rewrites shell commands
348set -euo pipefail
349LEAN_CTX_BIN="{binary}"
350INPUT=$(cat)
351CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
352if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
353case "$CMD" in
354 {case_pattern})
355 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
356 REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
357 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
358 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
359 *) exit 0 ;;
360esac
361"#
362 )
363}
364
365const REDIRECT_SCRIPT_CLAUDE: &str = r"#!/usr/bin/env bash
366# lean-ctx PreToolUse hook — all native tools pass through
367# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
368# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
369exit 0
370";
371
372const REDIRECT_SCRIPT_GENERIC: &str = r"#!/usr/bin/env bash
373# lean-ctx hook — all native tools pass through
374exit 0
375";
376
377pub fn hybrid_rules_content() -> String {
378 use crate::core::rules_canonical;
379 format!(
380 "{start}\n<!-- version: {version} -->\n\n\
381# lean-ctx \u{2014} Hybrid Mode (MCP reads + CLI commands)\n\n\
382{bullets}\n\n\
383{never}\n\n\
384{end}",
385 start = rules_canonical::START_MARK,
386 version = rules_canonical::RULES_VERSION,
387 bullets = rules_canonical::BULLETS,
388 never = rules_canonical::NEVER,
389 end = rules_canonical::END_MARK,
390 )
391}
392
393pub fn install_project_rules() {
394 install_project_rules_for_agents(&[]);
395}
396
397pub fn install_project_rules_for_agents(agents: &[&str]) {
400 if crate::core::config::Config::load().rules_scope_effective()
401 == crate::core::config::RulesScope::Global
402 {
403 return;
404 }
405
406 let cwd = std::env::current_dir().unwrap_or_default();
407
408 if !is_inside_git_repo(&cwd) {
409 eprintln!(
410 " Skipping project files: not inside a git repository.\n \
411 Run this command from your project root to create CLAUDE.md / AGENTS.md."
412 );
413 return;
414 }
415
416 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
417 if cwd == home {
418 eprintln!(
419 " Skipping project files: current directory is your home folder.\n \
420 Run this command from a project directory instead."
421 );
422 return;
423 }
424
425 let all = agents.is_empty();
426 let wants = |name: &str| all || agents.iter().any(|a| a.eq_ignore_ascii_case(name));
427
428 ensure_project_agents_integration(&cwd);
429
430 if wants("cursor") || wants("windsurf") {
431 let cursorrules = cwd.join(".cursorrules");
432 if !cursorrules.exists()
433 || !std::fs::read_to_string(&cursorrules)
434 .unwrap_or_default()
435 .contains("lean-ctx")
436 {
437 let content = cursorrules_content();
438 if cursorrules.exists() {
439 let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
440 if !existing.ends_with('\n') {
441 existing.push('\n');
442 }
443 existing.push('\n');
444 existing.push_str(&content);
445 write_file(&cursorrules, &existing);
446 } else {
447 write_file(&cursorrules, &content);
448 }
449 if !mcp_server_quiet_mode() {
450 eprintln!("Created/updated .cursorrules in project root.");
451 }
452 }
453 }
454
455 if wants("claude") {
456 let claude_rules_file = cwd.join(".claude").join("rules").join("lean-ctx.md");
462 if let Ok(existing) = std::fs::read_to_string(&claude_rules_file)
463 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
464 && std::fs::remove_file(&claude_rules_file).is_ok()
465 && !mcp_server_quiet_mode()
466 {
467 eprintln!(
468 "Removed .claude/rules/lean-ctx.md (always-loaded duplicate; AGENTS.md block + skill replace it)."
469 );
470 }
471
472 install_claude_project_hooks(&cwd);
473 }
474
475 if wants("codebuddy") {
476 let codebuddy_rules_file = cwd.join(".codebuddy").join("rules").join("lean-ctx.md");
477 if let Ok(existing) = std::fs::read_to_string(&codebuddy_rules_file)
478 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
479 && std::fs::remove_file(&codebuddy_rules_file).is_ok()
480 && !mcp_server_quiet_mode()
481 {
482 eprintln!(
483 "Removed .codebuddy/rules/lean-ctx.md (always-loaded duplicate; CODEBUDDY.md block + skill replace it)."
484 );
485 }
486
487 install_codebuddy_project_hooks(&cwd);
488 }
489
490 if wants("kiro") {
491 let kiro_dir = cwd.join(".kiro");
492 if kiro_dir.exists() {
493 let steering_dir = kiro_dir.join("steering");
494 let steering_file = steering_dir.join("lean-ctx.md");
495 if !steering_file.exists()
496 || !std::fs::read_to_string(&steering_file)
497 .unwrap_or_default()
498 .contains("lean-ctx")
499 {
500 let _ = std::fs::create_dir_all(&steering_dir);
501 write_file(&steering_file, &kiro_steering_content());
502 if !mcp_server_quiet_mode() {
503 eprintln!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
504 }
505 }
506 }
507 }
508
509 if wants("copilot") || wants("vscode") {
510 ensure_copilot_instructions(&cwd);
511 ensure_vscode_instruction_files_setting(&cwd);
512 }
513}
514
515const PROJECT_LEAN_CTX_MD_MARKER: &str =
516 crate::core::rules_canonical::PROJECT_LEAN_CTX_OWNED_MARKER;
517const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
518const PROJECT_AGENTS_MD: &str = "AGENTS.md";
519const AGENTS_BLOCK_START: &str = crate::core::rules_canonical::AGENTS_BLOCK_START;
523const AGENTS_BLOCK_END: &str = crate::core::rules_canonical::AGENTS_BLOCK_END;
524
525fn ensure_project_agents_integration(cwd: &std::path::Path) {
526 let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
527 let desired = format!(
530 "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
531 crate::rules_inject::rules_longform_markdown()
532 );
533
534 if !lean_ctx_md.exists() {
535 write_file(&lean_ctx_md, &desired);
536 } else if std::fs::read_to_string(&lean_ctx_md)
537 .unwrap_or_default()
538 .contains(PROJECT_LEAN_CTX_MD_MARKER)
539 {
540 let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
541 let version_str = format!(
542 "<!-- version: {} -->",
543 crate::core::rules_canonical::RULES_VERSION
544 );
545 if !current.contains(&version_str) {
546 write_file(&lean_ctx_md, &desired);
547 }
548 }
549
550 let block = format!(
555 "{AGENTS_BLOCK_START}\n\
556## lean-ctx\n\n\
557lean-ctx is active — the MCP tools replace native equivalents.\n\
558Full rules: {PROJECT_LEAN_CTX_MD} (open on demand — do not auto-load).\n\
559{AGENTS_BLOCK_END}\n"
560 );
561
562 let agents_md = cwd.join(PROJECT_AGENTS_MD);
563 if !agents_md.exists() {
564 let content = format!("# Agent Instructions\n\n{block}");
565 write_file(&agents_md, &content);
566 if !mcp_server_quiet_mode() {
567 eprintln!("Created AGENTS.md in project root (lean-ctx reference only).");
568 }
569 return;
570 }
571
572 let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
573
574 let has_block = crate::marked_block::contains_marker_line(&existing, AGENTS_BLOCK_START);
577
578 if existing.contains("CLI-first Token Optimization for Pi") && !has_block {
579 let content = format!("# Agent Instructions\n\n{block}");
580 write_file(&agents_md, &content);
581 return;
582 }
583
584 if has_block {
585 let updated = crate::marked_block::replace_marked_block(
586 &existing,
587 AGENTS_BLOCK_START,
588 AGENTS_BLOCK_END,
589 &block,
590 );
591 if updated != existing {
592 write_file(&agents_md, &updated);
593 }
594 return;
595 }
596
597 if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
598 return;
599 }
600
601 let mut out = existing;
602 if !out.ends_with('\n') {
603 out.push('\n');
604 }
605 out.push('\n');
606 out.push_str(&block);
607 write_file(&agents_md, &out);
608 if !mcp_server_quiet_mode() {
609 eprintln!("Updated AGENTS.md (added lean-ctx reference block).");
610 }
611}
612
613fn ensure_copilot_instructions(cwd: &std::path::Path) {
620 let path = cwd.join(".github").join("copilot-instructions.md");
621 let block = crate::rules_inject::rules_dedicated_markdown();
622 let start = crate::core::rules_canonical::START_MARK;
623 let end = crate::core::rules_canonical::END_MARK;
624 let owned = format!("{}\n", block.trim_end());
625
626 let existing = std::fs::read_to_string(&path).unwrap_or_default();
627 let desired = if existing.trim().is_empty() {
628 owned
629 } else if existing.contains(start) {
630 let user = crate::marked_block::remove_content(&existing, start, end);
632 if user.trim().is_empty() {
633 owned
634 } else {
635 format!("{}\n\n{}\n", user.trim_end(), block.trim_end())
636 }
637 } else {
638 format!("{}\n\n{}\n", existing.trim_end(), block.trim_end())
640 };
641
642 if desired == existing {
643 return;
644 }
645 if let Some(parent) = path.parent()
646 && std::fs::create_dir_all(parent).is_err()
647 {
648 return;
649 }
650 write_file(&path, &desired);
651 if !mcp_server_quiet_mode() {
652 eprintln!("Created/updated .github/copilot-instructions.md (Copilot/VS Code rules).");
653 }
654}
655
656fn ensure_vscode_instruction_files_setting(cwd: &std::path::Path) {
661 const KEY: &str = "github.copilot.chat.codeGeneration.useInstructionFiles";
662 let path = cwd.join(".vscode").join("settings.json");
663
664 let existing = std::fs::read_to_string(&path).unwrap_or_default();
665 let mut json = if existing.trim().is_empty() {
666 serde_json::json!({})
667 } else {
668 match crate::core::jsonc::parse_jsonc(&existing) {
669 Ok(v) if v.is_object() => v,
670 _ => return,
672 }
673 };
674 let Some(obj) = json.as_object_mut() else {
675 return;
676 };
677 if obj.contains_key(KEY) {
678 return;
679 }
680 obj.insert(KEY.to_string(), serde_json::Value::Bool(true));
681
682 if let Some(parent) = path.parent()
683 && std::fs::create_dir_all(parent).is_err()
684 {
685 return;
686 }
687 let Ok(formatted) = serde_json::to_string_pretty(&json) else {
688 return;
689 };
690 if crate::config_io::write_atomic_with_backup(&path, &formatted).is_ok()
691 && !mcp_server_quiet_mode()
692 {
693 eprintln!("Set {KEY} in .vscode/settings.json.");
694 }
695}
696
697pub fn cursorrules_content() -> String {
701 let start = crate::core::rules_canonical::START_MARK;
702 let end = crate::core::rules_canonical::END_MARK;
703 let version = crate::core::rules_canonical::RULES_VERSION;
704 format!(
705 "{start}\n<!-- version: {version} -->\n\n\
706# lean-ctx\n\n\
707{bullets}\n\n\
708{never}\n\
709Full rules: ~/.cursor/rules/lean-ctx.mdc (auto-loaded) \u{2014} do not duplicate here.\n\
710{end}",
711 bullets = crate::core::rules_canonical::BULLETS,
712 never = crate::core::rules_canonical::NEVER,
713 )
714}
715
716pub fn kiro_steering_content() -> String {
717 use crate::core::rules_canonical;
718 format!(
719 "---\n\
720inclusion: always\n\
721---\n\n\
722# Context Engineering Layer\n\n\
723{start}\n\
724<!-- version: {version} -->\n\n\
725The workspace has the `lean-ctx` MCP server installed. \
726You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.\n\n\
727{bullets}\n\n\
728{never}\n\n\
729## When to use native Kiro tools instead\n\n\
730- `fsWrite` / `fsAppend` \u{2014} always use native (lean-ctx doesn't write files)\n\
731- `strReplace` \u{2014} always use native (precise string replacement)\n\
732- `semanticRename` / `smartRelocate` \u{2014} always use native (IDE integration)\n\
733- `getDiagnostics` \u{2014} always use native (language server diagnostics)\n\
734- `deleteFile` \u{2014} always use native\n\
735- Glob \u{2014} always use native glob\n\n\
736{end}",
737 start = rules_canonical::START_MARK,
738 version = rules_canonical::RULES_VERSION,
739 bullets = rules_canonical::BULLETS,
740 never = rules_canonical::NEVER,
741 end = rules_canonical::END_MARK,
742 )
743}
744pub(crate) fn should_register_mcp() -> bool {
751 crate::core::config::Config::load()
752 .setup
753 .should_update_mcp()
754}
755
756pub fn install_agent_hook(agent: &str, global: bool) {
757 install_agent_hook_with_mode(agent, global, HookMode::Mcp);
758}
759
760pub fn install_agent_hook_with_mode(agent: &str, global: bool, mode: HookMode) {
761 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
762 match agent {
763 "claude" | "claude-code" => install_claude_hook_with_mode(global, mode),
764 "codebuddy" => install_codebuddy_hook_with_mode(global, mode),
765 "cursor" => install_cursor_hook_with_mode(global, mode),
766 "gemini" => {
767 install_gemini_hook();
768 install_antigravity_cli_hook();
774 }
775 "antigravity" => install_antigravity_hook(),
776 "antigravity-cli" => install_antigravity_cli_hook(),
777 "augment" => install_mcp_json_agent(
778 "Augment CLI",
779 "~/.augment/settings.json",
780 &crate::core::editor_registry::augment_cli_settings_path(&home),
781 ),
782 "codex" => install_codex_hook(),
783 "windsurf" => install_windsurf_rules(global),
784 "cline" | "roo" => install_cline_rules(global),
785 "copilot" | "vscode" => install_copilot_hook(global),
786 "vscode-insiders" => {}
791 "pi" => install_pi_hook_with_mode(global, mode),
792 "qoder" => install_qoder_hook_with_mode(mode),
793 "qoderwork" => install_mcp_json_agent(
794 "QoderWork",
795 "~/.qoderwork/mcp.json",
796 &home.join(".qoderwork/mcp.json"),
797 ),
798 "qwen" => install_mcp_json_agent(
799 "Qwen Code",
800 "~/.qwen/settings.json",
801 &home.join(".qwen/settings.json"),
802 ),
803 "trae" => install_mcp_json_agent("Trae", "~/.trae/mcp.json", &home.join(".trae/mcp.json")),
804 "amazonq" => install_mcp_json_agent(
805 "Amazon Q Developer",
806 "~/.aws/amazonq/default.json",
807 &home.join(".aws/amazonq/default.json"),
808 ),
809 "jetbrains" => install_jetbrains_hook(),
810 "kiro" => install_kiro_hook(),
811 "verdent" => install_mcp_json_agent(
812 "Verdent",
813 "~/.verdent/mcp.json",
814 &home.join(".verdent/mcp.json"),
815 ),
816 "opencode" => install_opencode_hook_with_mode(mode),
817 "amp" => install_amp_hook(),
818 "crush" => install_crush_hook_with_mode(mode),
819 "openclaw" => install_openclaw_hook(),
820 "hermes" => install_hermes_hook_with_mode(global, mode),
821 "zed" => {
822 let zed_path = crate::core::editor_registry::zed_settings_path(&home);
823 let binary = resolve_binary_path();
824 let entry = full_server_entry(&binary);
825 install_named_json_server("Zed", "settings.json", &zed_path, "context_servers", entry);
826 }
827 "aider" => {
828 install_mcp_json_agent("Aider", "~/.aider/mcp.json", &home.join(".aider/mcp.json"));
829 }
830 "continue" => install_mcp_json_agent(
831 "Continue",
832 "~/.continue/mcp.json",
833 &home.join(".continue/mcp.json"),
834 ),
835 "neovim" => install_mcp_json_agent(
836 "Neovim (mcphub.nvim)",
837 "~/.config/mcphub/servers.json",
838 &home.join(".config/mcphub/servers.json"),
839 ),
840 "emacs" => install_mcp_json_agent(
841 "Emacs (mcp.el)",
842 "~/.emacs.d/mcp.json",
843 &home.join(".emacs.d/mcp.json"),
844 ),
845 "sublime" => install_mcp_json_agent(
846 "Sublime Text",
847 "~/.config/sublime-text/mcp.json",
848 &home.join(".config/sublime-text/mcp.json"),
849 ),
850 _ => {
851 eprintln!("Unknown agent: {agent}");
852 eprintln!(" Supported: aider, amazonq, amp, antigravity, antigravity-cli, augment,");
853 eprintln!(
854 " claude, cline, codebuddy, codex, continue, copilot, crush, cursor, emacs, gemini,"
855 );
856 eprintln!(" hermes, jetbrains, kiro, neovim, openclaw, opencode, pi, qoder,");
857 eprintln!(" qoderwork, qwen, roo, sublime, trae, verdent, vscode, windsurf, zed");
858 std::process::exit(1);
859 }
860 }
861}
862
863pub fn install_agent_project_hooks(agent: &str, cwd: &std::path::Path) {
864 match agent {
865 "claude" | "claude-code" => agents::install_claude_project_hooks(cwd),
866 "codebuddy" => agents::install_codebuddy_project_hooks(cwd),
867 _ => {}
868 }
869}
870
871fn write_file(path: &std::path::Path, content: &str) {
872 if std::fs::read_to_string(path).is_ok_and(|existing| existing == content) {
875 return;
876 }
877 if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
878 tracing::error!("Error writing {}: {e}", path.display());
879 }
880}
881
882fn ensure_state_dir(dir: &std::path::Path) -> bool {
891 match crate::config_io::ensure_dir(dir) {
892 Ok(()) => true,
893 Err(e) => {
894 eprintln!("lean-ctx setup: cannot prepare {}: {e}", dir.display());
896 false
897 }
898 }
899}
900
901fn is_inside_git_repo(path: &std::path::Path) -> bool {
902 let mut p = path;
903 loop {
904 if p.join(".git").exists() {
905 return true;
906 }
907 match p.parent() {
908 Some(parent) => p = parent,
909 None => return false,
910 }
911 }
912}
913
914#[cfg(unix)]
915fn make_executable(path: &PathBuf) {
916 use std::os::unix::fs::PermissionsExt;
917 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
918}
919
920#[cfg(not(unix))]
921fn make_executable(_path: &PathBuf) {}
922
923pub(crate) fn mcp_server_env_pairs() -> Vec<(String, String)> {
936 let mut pairs = Vec::new();
937
938 let cfg = crate::core::config::Config::load();
939
940 let project_root = std::env::var("LEAN_CTX_PROJECT_ROOT")
941 .ok()
942 .filter(|v| !v.trim().is_empty())
943 .or_else(|| cfg.project_root.clone().filter(|v| !v.trim().is_empty()));
944 if let Some(root) = project_root {
945 pairs.push(("LEAN_CTX_PROJECT_ROOT".to_string(), root));
946 }
947
948 let extra_roots = std::env::var("LEAN_CTX_EXTRA_ROOTS")
951 .ok()
952 .filter(|v| !v.trim().is_empty())
953 .or_else(|| {
954 let roots: Vec<&str> = cfg
955 .extra_roots
956 .iter()
957 .map(String::as_str)
958 .filter(|s| !s.trim().is_empty())
959 .collect();
960 if roots.is_empty() {
961 return None;
962 }
963 std::env::join_paths(roots)
964 .ok()
965 .map(|s| s.to_string_lossy().to_string())
966 });
967 if let Some(extra) = extra_roots {
968 pairs.push(("LEAN_CTX_EXTRA_ROOTS".to_string(), extra));
969 }
970
971 pairs
972}
973
974pub(crate) fn mcp_server_env_json() -> serde_json::Value {
976 let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
977 .into_iter()
978 .map(|(k, v)| (k, serde_json::Value::String(v)))
979 .collect();
980 serde_json::Value::Object(map)
981}
982
983fn full_server_entry(binary: &str) -> serde_json::Value {
984 serde_json::json!({
990 "command": binary,
991 "env": mcp_server_env_json()
992 })
993}
994
995pub(crate) fn install_mcp_json_agent(
996 name: &str,
997 display_path: &str,
998 config_path: &std::path::Path,
999) {
1000 let binary = resolve_binary_path();
1001 let entry = full_server_entry(&binary);
1002 install_named_json_server(name, display_path, config_path, "mcpServers", entry);
1003}
1004
1005#[cfg(test)]
1006mod tests;