1use std::path::PathBuf;
2
3pub mod agents;
4mod support;
5
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum HookMode {
15 #[default]
16 Mcp,
17 Hybrid,
18 Replace,
19}
20
21impl std::fmt::Display for HookMode {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 Self::Mcp => write!(f, "MCP"),
25 Self::Hybrid => write!(f, "Hybrid"),
26 Self::Replace => write!(f, "Replace"),
27 }
28 }
29}
30
31impl HookMode {
32 pub fn from_str_loose(s: &str) -> Option<Self> {
33 match s.to_lowercase().replace('-', "").as_str() {
34 "mcp" => Some(Self::Mcp),
35 "hybrid" => Some(Self::Hybrid),
36 "replace" => Some(Self::Replace),
37 _ => None,
38 }
39 }
40
41 pub fn description(&self) -> &'static str {
42 match self {
43 Self::Mcp => "MCP server only (extension/plugin-based agents without reliable shell)",
44 Self::Hybrid => "MCP server + shell hooks for command compression (best of both)",
45 Self::Replace => {
46 "Native tools denied — lean-ctx MCP is the only path (zero tool drift)"
47 }
48 }
49 }
50}
51
52pub const REPLACE_AGENTS: &[&str] = &[
56 "cursor",
57 "claude",
58 "claude-code",
59 "codebuddy",
60 "codex",
61 "windsurf",
62 "opencode",
63 "gemini",
64];
65
66pub const HYBRID_AGENTS: &[&str] = &[
69 "cursor",
70 "gemini",
71 "codex",
72 "claude",
73 "claude-code",
74 "crush",
75 "hermes",
76 "opencode",
77 "openclaw",
78 "pi",
79 "qoder",
80 "windsurf",
81 "amp",
82 "cline",
83 "roo",
84 "copilot",
85 "kiro",
86 "qwen",
87 "trae",
88 "antigravity",
89 "antigravity-cli",
90 "amazonq",
91 "verdent",
92];
93
94pub fn recommend_hook_mode(agent_key: &str) -> HookMode {
101 if let Some(override_mode) = crate::core::config::Config::load().hook_mode_override() {
102 return override_mode;
103 }
104 if REPLACE_AGENTS.contains(&agent_key) {
105 HookMode::Replace
106 } else if HYBRID_AGENTS.contains(&agent_key) {
107 HookMode::Hybrid
108 } else {
109 HookMode::Mcp
110 }
111}
112use agents::{
113 install_amp_hook, install_antigravity_cli_hook, install_antigravity_hook,
114 install_claude_hook_config, install_claude_hook_scripts, install_claude_hook_with_mode,
115 install_claude_permissions_deny_replace, install_claude_project_hooks, install_cline_rules,
116 install_codebuddy_hook_config, install_codebuddy_hook_scripts,
117 install_codebuddy_hook_with_mode, install_codebuddy_permissions_deny_replace,
118 install_codebuddy_project_hooks, install_codex_hook, install_copilot_hook,
119 install_crush_hook_with_mode, install_cursor_deny_hook, install_cursor_hook_config,
120 install_cursor_hook_scripts, install_cursor_hook_with_mode, install_gemini_deny_hook,
121 install_gemini_hook, install_gemini_hook_config, install_gemini_hook_scripts,
122 install_hermes_hook_with_mode, install_jetbrains_hook, install_kiro_hook,
123 install_openclaw_hook, install_opencode_hook_with_mode, install_pi_hook_with_mode,
124 install_qoder_hook, install_qoder_hook_with_mode, install_windsurf_hooks,
125 install_windsurf_hooks_replace, install_windsurf_rules,
126};
127use support::{
128 ensure_codex_hooks_enabled, install_codex_instruction_docs, install_named_json_server,
129 upsert_lean_ctx_codex_hook_entries,
130};
131
132fn mcp_server_quiet_mode() -> bool {
133 std::env::var_os("LEAN_CTX_MCP_SERVER").is_some()
134 || matches!(std::env::var("LEAN_CTX_QUIET"), Ok(value) if value.trim() == "1")
135}
136
137const REFRESHABLE_HOOK_AGENTS: &[&str] = &[
145 "claude", "cursor", "gemini", "codex", "windsurf", "copilot", "qoder",
146];
147
148#[cfg(test)]
153const REFRESH_EXEMPT_HYBRID_AGENTS: &[&str] = &[
154 "claude-code",
156 "pi",
158 "cline",
161 "roo",
162 "kiro",
163 "antigravity",
166 "antigravity-cli",
167 "amp",
168 "crush",
169 "hermes",
170 "opencode",
171 "openclaw",
172 "qwen",
173 "trae",
174 "amazonq",
175 "verdent",
176];
177
178pub fn refresh_installed_hooks() {
183 let Some(home) = crate::core::home::resolve_home_dir() else {
184 return;
185 };
186 for agent in REFRESHABLE_HOOK_AGENTS {
187 if hooks_installed_for(agent, &home) {
188 refresh_agent_hooks(agent, &home);
189 }
190 }
191}
192
193fn hooks_installed_for(agent: &str, home: &std::path::Path) -> bool {
195 match agent {
196 "claude" => {
197 let dir = crate::setup::claude_config_dir(home);
198 dir.join("hooks/lean-ctx-rewrite.sh").exists()
199 || file_contains_lean_ctx(&dir.join("settings.json"))
200 }
201 "codebuddy" => {
202 let dir = crate::core::editor_registry::codebuddy_state_dir(home);
203 dir.join("hooks/lean-ctx-rewrite.sh").exists()
204 || file_contains_lean_ctx(&dir.join("settings.json"))
205 }
206 "cursor" => {
207 home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
208 || file_contains_lean_ctx(&home.join(".cursor/hooks.json"))
209 }
210 "gemini" => {
211 home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh")
212 .exists()
213 || home.join(".gemini/hooks/lean-ctx-hook-gemini.sh").exists()
214 }
215 "codex" => {
216 let dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
217 dir.join("hooks/lean-ctx-rewrite-codex.sh").exists()
218 || file_contains_lean_ctx(&dir.join("hooks.json"))
219 }
220 "windsurf" => file_contains_lean_ctx(&home.join(".codeium/windsurf/hooks.json")),
221 "copilot" => {
222 file_contains_lean_ctx(&home.join(".copilot/hooks/hooks.json"))
225 || file_contains_lean_ctx(&home.join(".github/hooks/hooks.json"))
226 }
227 "qoder" => file_contains_lean_ctx(&home.join(".qoder/settings.json")),
228 _ => false,
229 }
230}
231
232fn refresh_agent_hooks(agent: &str, home: &std::path::Path) {
237 let mode = recommend_hook_mode(agent);
238 match agent {
239 "claude" => {
240 install_claude_hook_scripts(home);
241 install_claude_hook_config(home);
242 if mode == HookMode::Replace {
243 install_claude_permissions_deny_replace(home);
244 }
245 }
246 "codebuddy" => {
247 install_codebuddy_hook_scripts(home);
248 install_codebuddy_hook_config(home);
249 if mode == HookMode::Replace {
250 install_codebuddy_permissions_deny_replace(home);
251 }
252 }
253 "cursor" => {
254 install_cursor_hook_scripts(home);
255 install_cursor_hook_config(home);
256 if mode == HookMode::Replace {
257 install_cursor_deny_hook(true);
258 }
259 }
260 "gemini" => {
261 install_gemini_hook_scripts(home);
262 install_gemini_hook_config(home);
263 if mode == HookMode::Replace {
264 install_gemini_deny_hook(home);
265 }
266 }
267 "codex" => install_codex_hook(),
268 "windsurf" => {
269 if mode == HookMode::Replace {
270 install_windsurf_hooks_replace(home);
271 } else {
272 install_windsurf_hooks(home);
273 }
274 }
275 "copilot" => install_copilot_hook(true),
276 "qoder" => install_qoder_hook(),
277 _ => {}
278 }
279}
280
281fn file_contains_lean_ctx(path: &std::path::Path) -> bool {
282 std::fs::read_to_string(path).is_ok_and(|c| c.contains("lean-ctx"))
283}
284
285fn resolve_binary_path() -> String {
301 crate::core::portable_binary::resolve_portable_binary()
302}
303
304fn resolve_hook_command_binary() -> String {
315 if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
316 return portable;
317 }
318 resolve_binary_path()
319}
320
321fn resolve_binary_path_for_bash() -> String {
322 if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
323 return portable;
324 }
325 to_bash_compatible_path(&resolve_binary_path())
326}
327
328pub(crate) fn shell_quoted_binary(binary: &str) -> String {
335 let escaped = binary.replace('"', "\\\"").replace('`', "\\`");
336 format!("\"{escaped}\"")
337}
338
339fn wrapper_is_portable_and_working(path: &std::path::Path, home: &std::path::Path) -> bool {
348 std::fs::read_to_string(path)
349 .is_ok_and(|content| wrapper_content_is_portable_and_working(&content, home))
350}
351
352pub(crate) fn wrapper_content_is_portable_and_working(
353 content: &str,
354 home: &std::path::Path,
355) -> bool {
356 let Some(token) = wrapper_binary_token(content) else {
357 return false;
358 };
359 if !(token.contains("$HOME") || token.contains("${HOME}") || token.contains("%USERPROFILE%")) {
360 return false;
361 }
362 let home_s = home.to_string_lossy();
363 let expanded = token
364 .replace("${HOME}", &home_s)
365 .replace("$HOME", &home_s)
366 .replace("%USERPROFILE%", &home_s);
367 std::path::Path::new(&from_bash_to_native_path(&expanded)).exists()
368}
369
370pub(crate) fn wrapper_binary_token(content: &str) -> Option<String> {
374 for line in content.lines() {
375 let t = line.trim();
376 if let Some(rest) = t.strip_prefix("LEAN_CTX_BIN=") {
377 let rest = rest.trim();
378 let tok = rest
379 .strip_prefix('"')
380 .map_or(rest, |r| r.split('"').next().unwrap_or_default());
381 if !tok.is_empty() {
382 return Some(tok.to_string());
383 }
384 }
385 if let Some(rest) = t.strip_prefix("exec ") {
386 let rest = rest.trim();
387 let tok = match rest.strip_prefix('"') {
388 Some(r) => r.split('"').next().unwrap_or_default().to_string(),
389 None => rest
390 .split_whitespace()
391 .next()
392 .unwrap_or_default()
393 .to_string(),
394 };
395 if !tok.is_empty() {
396 return Some(tok);
397 }
398 }
399 }
400 None
401}
402
403fn write_wrapper_file(path: &std::path::Path, content: &str, home: &std::path::Path) {
408 if crate::core::portable_binary::hook_binary_override().is_none()
409 && wrapper_is_portable_and_working(path, home)
410 {
411 return;
412 }
413 write_file(path, content);
414}
415
416pub fn to_bash_compatible_path(path: &str) -> String {
417 let path = match crate::core::pathutil::strip_verbatim_str(path) {
418 Some(stripped) => stripped,
419 None => path.replace('\\', "/"),
420 };
421 if path.len() >= 2 && path.as_bytes()[1] == b':' {
422 let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
423 format!("/{drive}{}", &path[2..])
424 } else {
425 path
426 }
427}
428
429pub fn from_bash_to_native_path(path: &str) -> String {
432 crate::core::pathutil::normalize_tool_path(path)
433}
434
435pub fn normalize_tool_path(path: &str) -> String {
438 crate::core::pathutil::normalize_tool_path(path)
439}
440
441pub fn generate_rewrite_script(binary: &str) -> String {
442 let case_pattern = crate::rewrite_registry::bash_case_pattern();
443 let quoted_binary = shell_quoted_binary(binary);
447 format!(
448 r#"#!/usr/bin/env bash
449# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
450set -euo pipefail
451
452LEAN_CTX_BIN={quoted_binary}
453
454INPUT=$(cat)
455TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
456
457case "$TOOL" in
458 Bash|bash|PowerShell|powershell) ;;
459 *) exit 0 ;;
460esac
461
462CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
463
464if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |\"?$LEAN_CTX_BIN\"? )"; then
465 exit 0
466fi
467
468# Skip multi-line commands: the grep/sed extraction above does not decode
469# JSON \n into real newlines, so lean-ctx -c would receive fused lines (#787).
470if printf '%s' "$CMD" | grep -qF '\n'; then exit 0; fi
471
472case "$CMD" in
473 {case_pattern})
474 # Shell-escape then JSON-escape (two passes)
475 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
476 REWRITE="\"$LEAN_CTX_BIN\" -c \"$SHELL_ESC\""
477 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
478 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
479 ;;
480 *) exit 0 ;;
481esac
482"#
483 )
484}
485
486pub fn generate_compact_rewrite_script(binary: &str) -> String {
487 let case_pattern = crate::rewrite_registry::bash_case_pattern();
488 let quoted_binary = shell_quoted_binary(binary);
489 format!(
490 r#"#!/usr/bin/env bash
491# lean-ctx hook — rewrites shell commands
492set -euo pipefail
493LEAN_CTX_BIN={quoted_binary}
494INPUT=$(cat)
495CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
496if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |\"?$LEAN_CTX_BIN\"? )"; then exit 0; fi
497if printf '%s' "$CMD" | grep -qF '\n'; then exit 0; fi
498case "$CMD" in
499 {case_pattern})
500 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
501 REWRITE="\"$LEAN_CTX_BIN\" -c \"$SHELL_ESC\""
502 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
503 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
504 *) exit 0 ;;
505esac
506"#
507 )
508}
509
510const REDIRECT_SCRIPT_CLAUDE: &str = r"#!/usr/bin/env bash
511# lean-ctx PreToolUse hook — all native tools pass through
512# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
513# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
514exit 0
515";
516
517const REDIRECT_SCRIPT_GENERIC: &str = r"#!/usr/bin/env bash
518# lean-ctx hook — all native tools pass through
519exit 0
520";
521
522pub fn hybrid_rules_content() -> String {
523 use crate::core::rules_canonical;
524 format!(
525 "{start}\n<!-- version: {version} -->\n\n\
526# lean-ctx \u{2014} Hybrid Mode (MCP reads + CLI commands)\n\n\
527{bullets}\n\n\
528{never}\n\n\
529{end}",
530 start = rules_canonical::START_MARK,
531 version = rules_canonical::RULES_VERSION,
532 bullets = rules_canonical::BULLETS,
533 never = rules_canonical::NEVER,
534 end = rules_canonical::END_MARK,
535 )
536}
537
538pub fn replace_rules_content() -> String {
539 use crate::core::rules_canonical;
540 format!(
541 "{start}\n<!-- version: {version} -->\n\n\
542# lean-ctx \u{2014} Replace Mode (native tools denied)\n\n\
543Native Read/Grep/Glob/Bash are denied by policy. Use ONLY ctx_* MCP tools:\n\
544- ctx_read for ALL file reads (cached, 10 modes, re-reads ~13 tokens)\n\
545- ctx_shell for ALL shell commands (95+ compression patterns)\n\
546- ctx_search instead of Grep/rg (compact results)\n\
547- ctx_tree instead of ls/find (compact directory maps)\n\
548- ctx_glob instead of Glob (file pattern matching)\n\n\
549Do NOT attempt native Read, Grep, Glob, or Bash \u{2014} they will be denied.\n\n\
550{end}",
551 start = rules_canonical::START_MARK,
552 version = rules_canonical::RULES_VERSION,
553 end = rules_canonical::END_MARK,
554 )
555}
556
557pub fn install_project_rules() {
558 install_project_rules_for_agents(&[]);
559}
560
561pub fn install_project_rules_for_agents(agents: &[&str]) {
564 if crate::core::config::Config::load().rules_scope_effective()
565 == crate::core::config::RulesScope::Global
566 {
567 return;
568 }
569
570 let cwd = std::env::current_dir().unwrap_or_default();
571
572 if !is_inside_git_repo(&cwd) {
573 eprintln!(
574 " Skipping project files: not inside a git repository.\n \
575 Run this command from your project root to create CLAUDE.md / AGENTS.md."
576 );
577 return;
578 }
579
580 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
581 if cwd == home {
582 eprintln!(
583 " Skipping project files: current directory is your home folder.\n \
584 Run this command from a project directory instead."
585 );
586 return;
587 }
588
589 let all = agents.is_empty();
590 let wants = |name: &str| all || agents.iter().any(|a| a.eq_ignore_ascii_case(name));
591
592 ensure_project_agents_integration(&cwd);
593
594 if wants("cursor") || wants("windsurf") {
595 let cursorrules = cwd.join(".cursorrules");
596 if !cursorrules.exists()
597 || !std::fs::read_to_string(&cursorrules)
598 .unwrap_or_default()
599 .contains("lean-ctx")
600 {
601 let content = cursorrules_content();
602 if cursorrules.exists() {
603 let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
604 if !existing.ends_with('\n') {
605 existing.push('\n');
606 }
607 existing.push('\n');
608 existing.push_str(&content);
609 write_file(&cursorrules, &existing);
610 } else {
611 write_file(&cursorrules, &content);
612 }
613 if !mcp_server_quiet_mode() {
614 eprintln!("Created/updated .cursorrules in project root.");
615 }
616 }
617 }
618
619 if wants("claude") {
620 let claude_rules_file = cwd.join(".claude").join("rules").join("lean-ctx.md");
626 if let Ok(existing) = std::fs::read_to_string(&claude_rules_file)
627 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
628 && std::fs::remove_file(&claude_rules_file).is_ok()
629 && !mcp_server_quiet_mode()
630 {
631 eprintln!(
632 "Removed .claude/rules/lean-ctx.md (always-loaded duplicate; AGENTS.md block + skill replace it)."
633 );
634 }
635
636 install_claude_project_hooks(&cwd);
637 }
638
639 if wants("codebuddy") {
640 let codebuddy_rules_file = cwd.join(".codebuddy").join("rules").join("lean-ctx.md");
641 if let Ok(existing) = std::fs::read_to_string(&codebuddy_rules_file)
642 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
643 && std::fs::remove_file(&codebuddy_rules_file).is_ok()
644 && !mcp_server_quiet_mode()
645 {
646 eprintln!(
647 "Removed .codebuddy/rules/lean-ctx.md (always-loaded duplicate; CODEBUDDY.md block + skill replace it)."
648 );
649 }
650
651 install_codebuddy_project_hooks(&cwd);
652 }
653
654 if wants("kiro") {
655 let kiro_dir = cwd.join(".kiro");
656 if kiro_dir.exists() {
657 let steering_dir = kiro_dir.join("steering");
658 let steering_file = steering_dir.join("lean-ctx.md");
659 if !steering_file.exists()
660 || !std::fs::read_to_string(&steering_file)
661 .unwrap_or_default()
662 .contains("lean-ctx")
663 {
664 let _ = std::fs::create_dir_all(&steering_dir);
665 write_file(&steering_file, &kiro_steering_content());
666 if !mcp_server_quiet_mode() {
667 eprintln!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
668 }
669 }
670 }
671 }
672
673 if wants("copilot") || wants("vscode") {
674 ensure_copilot_instructions(&cwd);
675 ensure_vscode_instruction_files_setting(&cwd);
676 }
677}
678
679const PROJECT_LEAN_CTX_MD_MARKER: &str =
680 crate::core::rules_canonical::PROJECT_LEAN_CTX_OWNED_MARKER;
681const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
682const PROJECT_AGENTS_MD: &str = "AGENTS.md";
683const AGENTS_BLOCK_START: &str = crate::core::rules_canonical::AGENTS_BLOCK_START;
687const AGENTS_BLOCK_END: &str = crate::core::rules_canonical::AGENTS_BLOCK_END;
688
689fn ensure_project_agents_integration(cwd: &std::path::Path) {
690 let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
691 let desired = format!(
694 "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
695 crate::rules_inject::rules_longform_markdown()
696 );
697
698 if !lean_ctx_md.exists() {
699 write_file(&lean_ctx_md, &desired);
700 } else if std::fs::read_to_string(&lean_ctx_md)
701 .unwrap_or_default()
702 .contains(PROJECT_LEAN_CTX_MD_MARKER)
703 {
704 let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
705 let version_str = format!(
706 "<!-- version: {} -->",
707 crate::core::rules_canonical::RULES_VERSION
708 );
709 if !current.contains(&version_str) {
710 write_file(&lean_ctx_md, &desired);
711 }
712 }
713
714 let block = format!(
719 "{AGENTS_BLOCK_START}\n\
720## lean-ctx\n\n\
721lean-ctx is active — the MCP tools replace native equivalents.\n\
722Full rules: {PROJECT_LEAN_CTX_MD} (open on demand — do not auto-load).\n\
723{AGENTS_BLOCK_END}\n"
724 );
725
726 let agents_md = cwd.join(PROJECT_AGENTS_MD);
727 if !agents_md.exists() {
728 let content = format!("# Agent Instructions\n\n{block}");
729 write_file(&agents_md, &content);
730 if !mcp_server_quiet_mode() {
731 eprintln!("Created AGENTS.md in project root (lean-ctx reference only).");
732 }
733 return;
734 }
735
736 let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
737
738 let has_block = crate::marked_block::contains_marker_line(&existing, AGENTS_BLOCK_START);
741
742 if existing.contains("CLI-first Token Optimization for Pi") && !has_block {
743 let content = format!("# Agent Instructions\n\n{block}");
744 write_file(&agents_md, &content);
745 return;
746 }
747
748 if has_block {
749 let updated = crate::marked_block::replace_marked_block(
750 &existing,
751 AGENTS_BLOCK_START,
752 AGENTS_BLOCK_END,
753 &block,
754 );
755 if updated != existing {
756 write_file(&agents_md, &updated);
757 }
758 return;
759 }
760
761 if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
762 return;
763 }
764
765 let mut out = existing;
766 if !out.ends_with('\n') {
767 out.push('\n');
768 }
769 out.push('\n');
770 out.push_str(&block);
771 write_file(&agents_md, &out);
772 if !mcp_server_quiet_mode() {
773 eprintln!("Updated AGENTS.md (added lean-ctx reference block).");
774 }
775}
776
777fn ensure_copilot_instructions(cwd: &std::path::Path) {
784 let path = cwd.join(".github").join("copilot-instructions.md");
785 let block = crate::rules_inject::rules_dedicated_markdown();
786 let start = crate::core::rules_canonical::START_MARK;
787 let end = crate::core::rules_canonical::END_MARK;
788 let owned = format!("{}\n", block.trim_end());
789
790 let existing = std::fs::read_to_string(&path).unwrap_or_default();
791 let desired = if existing.trim().is_empty() {
792 owned
793 } else if existing.contains(start) {
794 let user = crate::marked_block::remove_content(&existing, start, end);
796 if user.trim().is_empty() {
797 owned
798 } else {
799 format!("{}\n\n{}\n", user.trim_end(), block.trim_end())
800 }
801 } else {
802 format!("{}\n\n{}\n", existing.trim_end(), block.trim_end())
804 };
805
806 if desired == existing {
807 return;
808 }
809 if let Some(parent) = path.parent()
810 && std::fs::create_dir_all(parent).is_err()
811 {
812 return;
813 }
814 write_file(&path, &desired);
815 if !mcp_server_quiet_mode() {
816 eprintln!("Created/updated .github/copilot-instructions.md (Copilot/VS Code rules).");
817 }
818}
819
820fn ensure_vscode_instruction_files_setting(cwd: &std::path::Path) {
825 const KEY: &str = "github.copilot.chat.codeGeneration.useInstructionFiles";
826 let path = cwd.join(".vscode").join("settings.json");
827
828 let existing = std::fs::read_to_string(&path).unwrap_or_default();
829 let mut json = if existing.trim().is_empty() {
830 serde_json::json!({})
831 } else {
832 match crate::core::jsonc::parse_jsonc(&existing) {
833 Ok(v) if v.is_object() => v,
834 _ => return,
836 }
837 };
838 let Some(obj) = json.as_object_mut() else {
839 return;
840 };
841 if obj.contains_key(KEY) {
842 return;
843 }
844 obj.insert(KEY.to_string(), serde_json::Value::Bool(true));
845
846 if let Some(parent) = path.parent()
847 && std::fs::create_dir_all(parent).is_err()
848 {
849 return;
850 }
851 let Ok(formatted) = serde_json::to_string_pretty(&json) else {
852 return;
853 };
854 if crate::config_io::write_atomic_with_backup(&path, &formatted).is_ok()
855 && !mcp_server_quiet_mode()
856 {
857 eprintln!("Set {KEY} in .vscode/settings.json.");
858 }
859}
860
861pub fn cursorrules_content() -> String {
865 let start = crate::core::rules_canonical::START_MARK;
866 let end = crate::core::rules_canonical::END_MARK;
867 let version = crate::core::rules_canonical::RULES_VERSION;
868 format!(
869 "{start}\n<!-- version: {version} -->\n\n\
870# lean-ctx\n\n\
871{bullets}\n\n\
872{never}\n\
873Full rules: ~/.cursor/rules/lean-ctx.mdc (auto-loaded) \u{2014} do not duplicate here.\n\
874{end}",
875 bullets = crate::core::rules_canonical::BULLETS,
876 never = crate::core::rules_canonical::NEVER,
877 )
878}
879
880pub fn kiro_steering_content() -> String {
881 use crate::core::rules_canonical;
882 format!(
883 "---\n\
884inclusion: always\n\
885---\n\n\
886# Context Engineering Layer\n\n\
887{start}\n\
888<!-- version: {version} -->\n\n\
889The workspace has the `lean-ctx` MCP server installed. \
890You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.\n\n\
891{bullets}\n\n\
892{never}\n\n\
893## When to use native Kiro tools instead\n\n\
894- `fsWrite` / `fsAppend` \u{2014} always use native (lean-ctx doesn't write files)\n\
895- `strReplace` \u{2014} always use native (precise string replacement)\n\
896- `semanticRename` / `smartRelocate` \u{2014} always use native (IDE integration)\n\
897- `getDiagnostics` \u{2014} always use native (language server diagnostics)\n\
898- `deleteFile` \u{2014} always use native\n\
899- Glob \u{2014} always use native glob\n\n\
900{end}",
901 start = rules_canonical::START_MARK,
902 version = rules_canonical::RULES_VERSION,
903 bullets = rules_canonical::BULLETS,
904 never = rules_canonical::NEVER,
905 end = rules_canonical::END_MARK,
906 )
907}
908pub(crate) fn should_register_mcp() -> bool {
915 crate::core::config::Config::load()
916 .setup
917 .should_update_mcp()
918}
919
920pub fn install_agent_hook(agent: &str, global: bool) {
921 install_agent_hook_with_mode(agent, global, HookMode::Mcp);
922}
923
924pub fn install_agent_hook_with_mode(agent: &str, global: bool, mode: HookMode) {
925 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
926 match agent {
927 "claude" | "claude-code" => install_claude_hook_with_mode(global, mode),
928 "codebuddy" => install_codebuddy_hook_with_mode(global, mode),
929 "cursor" => install_cursor_hook_with_mode(global, mode),
930 "gemini" => {
931 install_gemini_hook();
932 if mode == HookMode::Replace {
933 install_gemini_deny_hook(&home);
934 }
935 install_antigravity_cli_hook();
941 }
942 "antigravity" => install_antigravity_hook(),
943 "antigravity-cli" => install_antigravity_cli_hook(),
944 "augment" => install_mcp_json_agent(
945 "Augment CLI",
946 "~/.augment/settings.json",
947 &crate::core::editor_registry::augment_cli_settings_path(&home),
948 ),
949 "codex" => {
950 install_codex_hook();
951 }
952 "windsurf" => {
953 install_windsurf_rules(global);
954 if mode == HookMode::Replace {
955 install_windsurf_hooks_replace(&home);
956 }
957 }
958 "cline" | "roo" => install_cline_rules(global),
959 "copilot" | "vscode" => install_copilot_hook(global),
960 "vscode-insiders" => {}
965 "pi" => install_pi_hook_with_mode(global, mode),
966 "qoder" => install_qoder_hook_with_mode(mode),
967 "qoderwork" => install_mcp_json_agent(
968 "QoderWork",
969 "~/.qoderwork/mcp.json",
970 &home.join(".qoderwork/mcp.json"),
971 ),
972 "qwen" => install_mcp_json_agent(
973 "Qwen Code",
974 "~/.qwen/settings.json",
975 &home.join(".qwen/settings.json"),
976 ),
977 "trae" => install_mcp_json_agent("Trae", "~/.trae/mcp.json", &home.join(".trae/mcp.json")),
978 "amazonq" => install_mcp_json_agent(
979 "Amazon Q Developer",
980 "~/.aws/amazonq/default.json",
981 &home.join(".aws/amazonq/default.json"),
982 ),
983 "jetbrains" => install_jetbrains_hook(),
984 "kiro" => install_kiro_hook(),
985 "verdent" => install_mcp_json_agent(
986 "Verdent",
987 "~/.verdent/mcp.json",
988 &home.join(".verdent/mcp.json"),
989 ),
990 "opencode" => install_opencode_hook_with_mode(mode),
991 "amp" => install_amp_hook(),
992 "crush" => install_crush_hook_with_mode(mode),
993 "openclaw" => install_openclaw_hook(),
994 "hermes" => install_hermes_hook_with_mode(global, mode),
995 "zed" => {
996 let zed_path = crate::core::editor_registry::zed_settings_path(&home);
997 let binary = resolve_binary_path();
998 let entry = full_server_entry(&binary);
999 install_named_json_server("Zed", "settings.json", &zed_path, "context_servers", entry);
1000 }
1001 "aider" => {
1002 install_mcp_json_agent("Aider", "~/.aider/mcp.json", &home.join(".aider/mcp.json"));
1003 }
1004 "continue" => install_mcp_json_agent(
1005 "Continue",
1006 "~/.continue/mcp.json",
1007 &home.join(".continue/mcp.json"),
1008 ),
1009 "neovim" => install_mcp_json_agent(
1010 "Neovim (mcphub.nvim)",
1011 "~/.config/mcphub/servers.json",
1012 &home.join(".config/mcphub/servers.json"),
1013 ),
1014 "emacs" => install_mcp_json_agent(
1015 "Emacs (mcp.el)",
1016 "~/.emacs.d/mcp.json",
1017 &home.join(".emacs.d/mcp.json"),
1018 ),
1019 "sublime" => install_mcp_json_agent(
1020 "Sublime Text",
1021 "~/.config/sublime-text/mcp.json",
1022 &home.join(".config/sublime-text/mcp.json"),
1023 ),
1024 _ => {
1025 eprintln!("Unknown agent: {agent}");
1026 eprintln!(" Supported: aider, amazonq, amp, antigravity, antigravity-cli, augment,");
1027 eprintln!(
1028 " claude, cline, codebuddy, codex, continue, copilot, crush, cursor, emacs, gemini,"
1029 );
1030 eprintln!(" hermes, jetbrains, kiro, neovim, openclaw, opencode, pi, qoder,");
1031 eprintln!(" qoderwork, qwen, roo, sublime, trae, verdent, vscode, windsurf, zed");
1032 std::process::exit(1);
1033 }
1034 }
1035}
1036
1037pub fn install_agent_project_hooks(agent: &str, cwd: &std::path::Path) {
1038 match agent {
1039 "claude" | "claude-code" => agents::install_claude_project_hooks(cwd),
1040 "codebuddy" => agents::install_codebuddy_project_hooks(cwd),
1041 _ => {}
1042 }
1043}
1044
1045fn write_file(path: &std::path::Path, content: &str) {
1046 if std::fs::read_to_string(path).is_ok_and(|existing| existing == content) {
1049 return;
1050 }
1051 if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
1052 tracing::error!("Error writing {}: {e}", path.display());
1053 }
1054}
1055
1056fn ensure_state_dir(dir: &std::path::Path) -> bool {
1065 match crate::config_io::ensure_dir(dir) {
1066 Ok(()) => true,
1067 Err(e) => {
1068 eprintln!("lean-ctx setup: cannot prepare {}: {e}", dir.display());
1070 false
1071 }
1072 }
1073}
1074
1075fn is_inside_git_repo(path: &std::path::Path) -> bool {
1076 let mut p = path;
1077 loop {
1078 if p.join(".git").exists() {
1079 return true;
1080 }
1081 match p.parent() {
1082 Some(parent) => p = parent,
1083 None => return false,
1084 }
1085 }
1086}
1087
1088#[cfg(unix)]
1089fn make_executable(path: &PathBuf) {
1090 use std::os::unix::fs::PermissionsExt;
1091 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1092}
1093
1094#[cfg(not(unix))]
1095fn make_executable(_path: &PathBuf) {}
1096
1097pub(crate) fn mcp_server_env_pairs() -> Vec<(String, String)> {
1110 let mut pairs = Vec::new();
1111
1112 let cfg = crate::core::config::Config::load();
1113
1114 let project_root = std::env::var("LEAN_CTX_PROJECT_ROOT")
1115 .ok()
1116 .filter(|v| !v.trim().is_empty())
1117 .or_else(|| cfg.project_root.clone().filter(|v| !v.trim().is_empty()));
1118 if let Some(root) = project_root {
1119 pairs.push(("LEAN_CTX_PROJECT_ROOT".to_string(), root));
1120 }
1121
1122 let extra_roots = std::env::var("LEAN_CTX_EXTRA_ROOTS")
1125 .ok()
1126 .filter(|v| !v.trim().is_empty())
1127 .or_else(|| {
1128 let roots: Vec<&str> = cfg
1129 .extra_roots
1130 .iter()
1131 .map(String::as_str)
1132 .filter(|s| !s.trim().is_empty())
1133 .collect();
1134 if roots.is_empty() {
1135 return None;
1136 }
1137 std::env::join_paths(roots)
1138 .ok()
1139 .map(|s| s.to_string_lossy().to_string())
1140 });
1141 if let Some(extra) = extra_roots {
1142 pairs.push(("LEAN_CTX_EXTRA_ROOTS".to_string(), extra));
1143 }
1144
1145 pairs
1146}
1147
1148pub(crate) fn mcp_server_env_json() -> serde_json::Value {
1150 let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
1151 .into_iter()
1152 .map(|(k, v)| (k, serde_json::Value::String(v)))
1153 .collect();
1154 serde_json::Value::Object(map)
1155}
1156
1157fn full_server_entry(binary: &str) -> serde_json::Value {
1158 serde_json::json!({
1164 "command": binary,
1165 "env": mcp_server_env_json()
1166 })
1167}
1168
1169pub(crate) fn install_mcp_json_agent(
1170 name: &str,
1171 display_path: &str,
1172 config_path: &std::path::Path,
1173) {
1174 let binary = resolve_binary_path();
1175 let entry = full_server_entry(&binary);
1176 install_named_json_server(name, display_path, config_path, "mcpServers", entry);
1177}
1178
1179#[cfg(test)]
1180mod tests;