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