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 cap_at_hybrid(self) -> Self {
43 if matches!(self, Self::Replace) {
44 Self::Hybrid
45 } else {
46 self
47 }
48 }
49
50 pub fn description(&self) -> &'static str {
51 match self {
52 Self::Mcp => "MCP server only (extension/plugin-based agents without reliable shell)",
53 Self::Hybrid => "MCP server + shell hooks for command compression (best of both)",
54 Self::Replace => {
55 "Native tools denied — lean-ctx MCP is the only path (zero tool drift)"
56 }
57 }
58 }
59}
60
61pub const REPLACE_AGENTS: &[&str] = &[
65 "cursor",
66 "claude",
67 "claude-code",
68 "codebuddy",
69 "codex",
70 "windsurf",
71 "opencode",
72 "gemini",
73];
74
75pub const HYBRID_AGENTS: &[&str] = &[
78 "cursor",
79 "gemini",
80 "codex",
81 "claude",
82 "claude-code",
83 "crush",
84 "hermes",
85 "opencode",
86 "openclaw",
87 "pi",
88 "qoder",
89 "qodercli",
90 "windsurf",
91 "amp",
92 "cline",
93 "roo",
94 "copilot",
95 "kiro",
96 "qwen",
97 "trae",
98 "antigravity",
99 "antigravity-cli",
100 "amazonq",
101 "verdent",
102];
103
104pub fn recommend_hook_mode(agent_key: &str) -> HookMode {
114 let deny_suppressed = is_deny_suppressed();
115 if let Some(override_mode) = crate::core::config::Config::load().hook_mode_override() {
116 return if deny_suppressed {
117 override_mode.cap_at_hybrid()
118 } else {
119 override_mode
120 };
121 }
122 if deny_suppressed {
123 if REPLACE_AGENTS.contains(&agent_key) || HYBRID_AGENTS.contains(&agent_key) {
124 return HookMode::Hybrid;
125 }
126 return HookMode::Mcp;
127 }
128 if REPLACE_AGENTS.contains(&agent_key) {
129 HookMode::Replace
130 } else if HYBRID_AGENTS.contains(&agent_key) {
131 HookMode::Hybrid
132 } else {
133 HookMode::Mcp
134 }
135}
136
137fn is_deny_suppressed() -> bool {
139 if std::env::var("LEAN_CTX_DISABLED").is_ok() {
140 return true;
141 }
142 if matches!(std::env::var("LEAN_CTX_SHADOW_MODE"), Ok(v) if v.trim() == "false" || v.trim() == "0")
143 {
144 return true;
145 }
146 if matches!(std::env::var("LEAN_CTX_HEAL"), Ok(v) if v.trim().eq_ignore_ascii_case("off") || v.trim() == "0")
147 {
148 return true;
149 }
150 let cfg = crate::core::config::Config::load();
151 !cfg.shadow_mode
152}
153use agents::{
154 install_amp_hook, install_antigravity_cli_hook, install_antigravity_hook,
155 install_claude_hook_config, install_claude_hook_scripts, install_claude_hook_with_mode,
156 install_claude_permissions_deny_replace, install_claude_project_hooks, install_cline_rules,
157 install_codebuddy_hook_config, install_codebuddy_hook_scripts,
158 install_codebuddy_hook_with_mode, install_codebuddy_permissions_deny_replace,
159 install_codebuddy_project_hooks, install_codex_hook, install_copilot_hook,
160 install_crush_hook_with_mode, install_cursor_deny_hook, install_cursor_hook_config,
161 install_cursor_hook_scripts, install_cursor_hook_with_mode, install_gemini_deny_hook,
162 install_gemini_hook, install_gemini_hook_config, install_gemini_hook_scripts, install_grok_mcp,
163 install_hermes_hook_with_mode, install_jetbrains_hook, install_kiro_hook,
164 install_openclaw_hook, install_opencode_hook_with_mode, install_pi_hook_with_mode,
165 install_qoder_hook_with_mode, install_vibe_hook, install_windsurf_hooks,
166 install_windsurf_hooks_replace, install_windsurf_rules,
167};
168use support::{
169 ensure_codex_hooks_enabled, install_codex_instruction_docs, install_named_json_server,
170 upsert_lean_ctx_codex_hook_entries,
171};
172
173fn mcp_server_quiet_mode() -> bool {
174 crate::core::runtime_flags::mcp_server_enabled() || crate::core::runtime_flags::quiet_enabled()
175}
176
177const REFRESHABLE_HOOK_AGENTS: &[&str] = &[
185 "claude", "cursor", "gemini", "codex", "windsurf", "copilot", "qoder", "qodercli",
186];
187
188#[cfg(test)]
193const REFRESH_EXEMPT_HYBRID_AGENTS: &[&str] = &[
194 "claude-code",
196 "pi",
198 "cline",
201 "roo",
202 "kiro",
203 "antigravity",
206 "antigravity-cli",
207 "amp",
208 "crush",
209 "hermes",
210 "opencode",
211 "openclaw",
212 "qwen",
213 "trae",
214 "amazonq",
215 "verdent",
216];
217
218pub fn refresh_installed_hooks() {
223 let Some(home) = crate::core::home::resolve_home_dir() else {
224 return;
225 };
226 for agent in REFRESHABLE_HOOK_AGENTS {
227 if hooks_installed_for(agent, &home) {
228 refresh_agent_hooks(agent, &home);
229 }
230 }
231}
232
233fn hooks_installed_for(agent: &str, home: &std::path::Path) -> bool {
235 match agent {
236 "claude" => {
237 let dir = crate::setup::claude_config_dir(home);
238 dir.join("hooks/lean-ctx-rewrite.sh").exists()
239 || file_contains_lean_ctx(&dir.join("settings.json"))
240 }
241 "codebuddy" => {
242 let dir = crate::core::editor_registry::codebuddy_state_dir(home);
243 dir.join("hooks/lean-ctx-rewrite.sh").exists()
244 || file_contains_lean_ctx(&dir.join("settings.json"))
245 }
246 "cursor" => {
247 home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
248 || file_contains_lean_ctx(&home.join(".cursor/hooks.json"))
249 }
250 "gemini" => {
251 home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh")
252 .exists()
253 || home.join(".gemini/hooks/lean-ctx-hook-gemini.sh").exists()
254 }
255 "codex" => {
256 let dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
257 dir.join("hooks/lean-ctx-rewrite-codex.sh").exists()
258 || file_contains_lean_ctx(&dir.join("hooks.json"))
259 }
260 "windsurf" => file_contains_lean_ctx(&home.join(".codeium/windsurf/hooks.json")),
261 "copilot" => {
262 file_contains_lean_ctx(&home.join(".copilot/hooks/hooks.json"))
265 || file_contains_lean_ctx(&home.join(".github/hooks/hooks.json"))
266 }
267 "qoder" | "qodercli" => file_contains_lean_ctx(&home.join(".qoder/settings.json")),
268 _ => false,
269 }
270}
271
272fn refresh_agent_hooks(agent: &str, home: &std::path::Path) {
277 let mode = recommend_hook_mode(agent);
278 match agent {
279 "claude" => {
280 install_claude_hook_scripts(home);
281 install_claude_hook_config(home);
282 if mode == HookMode::Replace {
283 install_claude_permissions_deny_replace(home);
284 }
285 }
286 "codebuddy" => {
287 install_codebuddy_hook_scripts(home);
288 install_codebuddy_hook_config(home);
289 if mode == HookMode::Replace {
290 install_codebuddy_permissions_deny_replace(home);
291 }
292 }
293 "cursor" => {
294 install_cursor_hook_scripts(home);
295 install_cursor_hook_config(home);
296 if mode == HookMode::Replace {
297 install_cursor_deny_hook(true);
298 }
299 }
300 "gemini" => {
301 install_gemini_hook_scripts(home);
302 install_gemini_hook_config(home);
303 if mode == HookMode::Replace {
304 install_gemini_deny_hook(home);
305 }
306 }
307 "codex" => install_codex_hook(),
308 "windsurf" => {
309 if mode == HookMode::Replace {
310 install_windsurf_hooks_replace(home);
311 } else {
312 install_windsurf_hooks(home);
313 }
314 }
315 "copilot" => install_copilot_hook(true),
316 "qoder" | "qodercli" => install_qoder_hook_with_mode(mode),
317 _ => {}
318 }
319}
320
321fn file_contains_lean_ctx(path: &std::path::Path) -> bool {
322 std::fs::read_to_string(path).is_ok_and(|c| c.contains("lean-ctx"))
323}
324
325fn resolve_binary_path() -> String {
341 crate::core::portable_binary::resolve_portable_binary()
342}
343
344fn resolve_hook_command_binary() -> String {
355 if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
356 return portable;
357 }
358 resolve_binary_path()
359}
360
361fn resolve_binary_path_for_bash() -> String {
362 if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
363 return portable;
364 }
365 to_bash_compatible_path(&resolve_binary_path())
366}
367
368pub(crate) fn shell_quoted_binary(binary: &str) -> String {
375 let escaped = binary.replace('"', "\\\"").replace('`', "\\`");
376 format!("\"{escaped}\"")
377}
378
379fn wrapper_is_portable_and_working(path: &std::path::Path, home: &std::path::Path) -> bool {
388 std::fs::read_to_string(path)
389 .is_ok_and(|content| wrapper_content_is_portable_and_working(&content, home))
390}
391
392pub(crate) fn wrapper_content_is_portable_and_working(
393 content: &str,
394 home: &std::path::Path,
395) -> bool {
396 let Some(token) = wrapper_binary_token(content) else {
397 return false;
398 };
399 if !(token.contains("$HOME") || token.contains("${HOME}") || token.contains("%USERPROFILE%")) {
400 return false;
401 }
402 let home_s = home.to_string_lossy();
403 let expanded = token
404 .replace("${HOME}", &home_s)
405 .replace("$HOME", &home_s)
406 .replace("%USERPROFILE%", &home_s);
407 std::path::Path::new(&from_bash_to_native_path(&expanded)).exists()
408}
409
410pub(crate) fn wrapper_binary_token(content: &str) -> Option<String> {
414 for line in content.lines() {
415 let t = line.trim();
416 if let Some(rest) = t.strip_prefix("LEAN_CTX_BIN=") {
417 let rest = rest.trim();
418 let tok = rest
419 .strip_prefix('"')
420 .map_or(rest, |r| r.split('"').next().unwrap_or_default());
421 if !tok.is_empty() {
422 return Some(tok.to_string());
423 }
424 }
425 if let Some(rest) = t.strip_prefix("exec ") {
426 let rest = rest.trim();
427 let tok = match rest.strip_prefix('"') {
428 Some(r) => r.split('"').next().unwrap_or_default().to_string(),
429 None => rest
430 .split_whitespace()
431 .next()
432 .unwrap_or_default()
433 .to_string(),
434 };
435 if !tok.is_empty() {
436 return Some(tok);
437 }
438 }
439 }
440 None
441}
442
443fn write_wrapper_file(path: &std::path::Path, content: &str, home: &std::path::Path) {
448 if crate::core::portable_binary::hook_binary_override().is_none()
449 && wrapper_is_portable_and_working(path, home)
450 {
451 return;
452 }
453 write_file(path, content);
454}
455
456pub fn to_bash_compatible_path(path: &str) -> String {
457 let path = match crate::core::pathutil::strip_verbatim_str(path) {
458 Some(stripped) => stripped,
459 None => path.replace('\\', "/"),
460 };
461 if path.len() >= 2 && path.as_bytes()[1] == b':' {
462 let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
463 format!("/{drive}{}", &path[2..])
464 } else {
465 path
466 }
467}
468
469pub fn from_bash_to_native_path(path: &str) -> String {
472 crate::core::pathutil::normalize_tool_path(path)
473}
474
475pub fn normalize_tool_path(path: &str) -> String {
478 crate::core::pathutil::normalize_tool_path(path)
479}
480
481pub fn generate_rewrite_script(binary: &str) -> String {
482 let case_pattern = crate::rewrite_registry::bash_case_pattern();
483 let quoted_binary = shell_quoted_binary(binary);
487 format!(
488 r#"#!/usr/bin/env bash
489# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
490set -euo pipefail
491
492LEAN_CTX_BIN={quoted_binary}
493
494INPUT=$(cat)
495TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
496
497case "$TOOL" in
498 Bash|bash|PowerShell|powershell) ;;
499 *) exit 0 ;;
500esac
501
502CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
503
504if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |\"?$LEAN_CTX_BIN\"? )"; then
505 exit 0
506fi
507
508# Skip multi-line commands: the grep/sed extraction above does not decode
509# JSON \n into real newlines, so lean-ctx -c would receive fused lines (#787).
510if printf '%s' "$CMD" | grep -qF '\n'; then exit 0; fi
511
512case "$CMD" in
513 {case_pattern})
514 # Shell-escape then JSON-escape (two passes)
515 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
516 REWRITE="\"$LEAN_CTX_BIN\" -c \"$SHELL_ESC\""
517 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
518 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
519 ;;
520 *) exit 0 ;;
521esac
522"#
523 )
524}
525
526pub fn generate_compact_rewrite_script(binary: &str) -> String {
527 let case_pattern = crate::rewrite_registry::bash_case_pattern();
528 let quoted_binary = shell_quoted_binary(binary);
529 format!(
530 r#"#!/usr/bin/env bash
531# lean-ctx hook — rewrites shell commands
532set -euo pipefail
533LEAN_CTX_BIN={quoted_binary}
534INPUT=$(cat)
535CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
536if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |\"?$LEAN_CTX_BIN\"? )"; then exit 0; fi
537if printf '%s' "$CMD" | grep -qF '\n'; then exit 0; fi
538case "$CMD" in
539 {case_pattern})
540 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
541 REWRITE="\"$LEAN_CTX_BIN\" -c \"$SHELL_ESC\""
542 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
543 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
544 *) exit 0 ;;
545esac
546"#
547 )
548}
549
550const REDIRECT_SCRIPT_CLAUDE: &str = r"#!/usr/bin/env bash
551# lean-ctx PreToolUse hook — all native tools pass through
552# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
553# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
554exit 0
555";
556
557const REDIRECT_SCRIPT_GENERIC: &str = r"#!/usr/bin/env bash
558# lean-ctx hook — all native tools pass through
559exit 0
560";
561
562pub fn hybrid_rules_content() -> String {
563 use crate::core::rules_canonical;
564 format!(
565 "{start}\n<!-- version: {version} -->\n\n\
566# lean-ctx \u{2014} Hybrid Mode (MCP reads + CLI commands)\n\n\
567{bullets}\n\n\
568{never}\n\n\
569{end}",
570 start = rules_canonical::START_MARK,
571 version = rules_canonical::RULES_VERSION,
572 bullets = rules_canonical::BULLETS,
573 never = rules_canonical::NEVER,
574 end = rules_canonical::END_MARK,
575 )
576}
577
578pub fn replace_rules_content() -> String {
579 use crate::core::rules_canonical;
580 format!(
581 "{start}\n<!-- version: {version} -->\n\n\
582# lean-ctx \u{2014} Replace Mode (native tools denied)\n\n\
583Native Read/Grep/Glob/Bash are denied by policy. Use ONLY ctx_* MCP tools:\n\
584- ctx_read for ALL file reads (cached, 10 modes, re-reads ~13 tokens)\n\
585- ctx_shell for ALL shell commands (95+ compression patterns)\n\
586- ctx_search instead of Grep/rg (compact results)\n\
587- ctx_tree instead of ls/find (compact directory maps)\n\
588- ctx_glob instead of Glob (file pattern matching)\n\n\
589Do NOT attempt native Read, Grep, Glob, or Bash \u{2014} they will be denied.\n\n\
590{end}",
591 start = rules_canonical::START_MARK,
592 version = rules_canonical::RULES_VERSION,
593 end = rules_canonical::END_MARK,
594 )
595}
596
597pub fn install_project_rules() {
598 install_project_rules_for_agents(&[]);
599}
600
601pub fn install_project_rules_for_agents(agents: &[&str]) {
604 if crate::core::config::Config::load().rules_scope_effective()
605 == crate::core::config::RulesScope::Global
606 {
607 return;
608 }
609
610 let cwd = std::env::current_dir().unwrap_or_default();
611
612 if !is_inside_git_repo(&cwd) {
613 eprintln!(
614 " Skipping project files: not inside a git repository.\n \
615 Run this command from your project root to create CLAUDE.md / AGENTS.md."
616 );
617 return;
618 }
619
620 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
621 if cwd == home {
622 eprintln!(
623 " Skipping project files: current directory is your home folder.\n \
624 Run this command from a project directory instead."
625 );
626 return;
627 }
628
629 let all = agents.is_empty();
630 let wants = |name: &str| all || agents.iter().any(|a| a.eq_ignore_ascii_case(name));
631
632 ensure_project_agents_integration(&cwd);
633
634 if wants("cursor") || wants("windsurf") {
635 let cursorrules = cwd.join(".cursorrules");
636 if !cursorrules.exists()
637 || !std::fs::read_to_string(&cursorrules)
638 .unwrap_or_default()
639 .contains("lean-ctx")
640 {
641 let content = cursorrules_content();
642 if cursorrules.exists() {
643 let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
644 if !existing.ends_with('\n') {
645 existing.push('\n');
646 }
647 existing.push('\n');
648 existing.push_str(&content);
649 write_file(&cursorrules, &existing);
650 } else {
651 write_file(&cursorrules, &content);
652 }
653 if !mcp_server_quiet_mode() {
654 eprintln!("Created/updated .cursorrules in project root.");
655 }
656 }
657 }
658
659 if wants("claude") {
660 let claude_rules_file = cwd.join(".claude").join("rules").join("lean-ctx.md");
666 if let Ok(existing) = std::fs::read_to_string(&claude_rules_file)
667 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
668 && std::fs::remove_file(&claude_rules_file).is_ok()
669 && !mcp_server_quiet_mode()
670 {
671 eprintln!(
672 "Removed .claude/rules/lean-ctx.md (always-loaded duplicate; AGENTS.md block + skill replace it)."
673 );
674 }
675
676 install_claude_project_hooks(&cwd);
677 }
678
679 if wants("codebuddy") {
680 let codebuddy_rules_file = cwd.join(".codebuddy").join("rules").join("lean-ctx.md");
681 if let Ok(existing) = std::fs::read_to_string(&codebuddy_rules_file)
682 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
683 && std::fs::remove_file(&codebuddy_rules_file).is_ok()
684 && !mcp_server_quiet_mode()
685 {
686 eprintln!(
687 "Removed .codebuddy/rules/lean-ctx.md (always-loaded duplicate; CODEBUDDY.md block + skill replace it)."
688 );
689 }
690
691 install_codebuddy_project_hooks(&cwd);
692 }
693
694 if wants("kiro") {
695 let kiro_dir = cwd.join(".kiro");
696 if kiro_dir.exists() {
697 let steering_dir = kiro_dir.join("steering");
698 let steering_file = steering_dir.join("lean-ctx.md");
699 if !steering_file.exists()
700 || !std::fs::read_to_string(&steering_file)
701 .unwrap_or_default()
702 .contains("lean-ctx")
703 {
704 let _ = std::fs::create_dir_all(&steering_dir);
705 write_file(&steering_file, &kiro_steering_content());
706 if !mcp_server_quiet_mode() {
707 eprintln!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
708 }
709 }
710 }
711 }
712
713 if wants("copilot") || wants("vscode") {
714 ensure_copilot_instructions(&cwd);
715 ensure_vscode_instruction_files_setting(&cwd);
716 }
717}
718
719const PROJECT_LEAN_CTX_MD_MARKER: &str =
720 crate::core::rules_canonical::PROJECT_LEAN_CTX_OWNED_MARKER;
721const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
722const PROJECT_AGENTS_MD: &str = "AGENTS.md";
723const AGENTS_BLOCK_START: &str = crate::core::rules_canonical::AGENTS_BLOCK_START;
727const AGENTS_BLOCK_END: &str = crate::core::rules_canonical::AGENTS_BLOCK_END;
728
729fn ensure_project_agents_integration(cwd: &std::path::Path) {
730 let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
731 let desired = format!(
734 "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
735 crate::rules_inject::rules_longform_markdown()
736 );
737
738 if !lean_ctx_md.exists() {
739 write_file(&lean_ctx_md, &desired);
740 } else if std::fs::read_to_string(&lean_ctx_md)
741 .unwrap_or_default()
742 .contains(PROJECT_LEAN_CTX_MD_MARKER)
743 {
744 let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
745 let version_str = format!(
746 "<!-- version: {} -->",
747 crate::core::rules_canonical::RULES_VERSION
748 );
749 if !current.contains(&version_str) {
750 write_file(&lean_ctx_md, &desired);
751 }
752 }
753
754 let block = format!(
759 "{AGENTS_BLOCK_START}\n\
760## lean-ctx\n\n\
761lean-ctx is active — the MCP tools replace native equivalents.\n\
762Full rules: {PROJECT_LEAN_CTX_MD} (open on demand — do not auto-load).\n\
763{AGENTS_BLOCK_END}\n"
764 );
765
766 let agents_md = cwd.join(PROJECT_AGENTS_MD);
767 if !agents_md.exists() {
768 let content = format!("# Agent Instructions\n\n{block}");
769 write_file(&agents_md, &content);
770 if !mcp_server_quiet_mode() {
771 eprintln!("Created AGENTS.md in project root (lean-ctx reference only).");
772 }
773 return;
774 }
775
776 let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
777
778 let has_block = crate::marked_block::contains_marker_line(&existing, AGENTS_BLOCK_START);
781
782 if existing.contains("CLI-first Token Optimization for Pi") && !has_block {
783 let content = format!("# Agent Instructions\n\n{block}");
784 write_file(&agents_md, &content);
785 return;
786 }
787
788 if has_block {
789 let updated = crate::marked_block::replace_marked_block(
790 &existing,
791 AGENTS_BLOCK_START,
792 AGENTS_BLOCK_END,
793 &block,
794 );
795 if updated != existing {
796 write_file(&agents_md, &updated);
797 }
798 return;
799 }
800
801 if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
802 return;
803 }
804
805 let mut out = existing;
806 if !out.ends_with('\n') {
807 out.push('\n');
808 }
809 out.push('\n');
810 out.push_str(&block);
811 write_file(&agents_md, &out);
812 if !mcp_server_quiet_mode() {
813 eprintln!("Updated AGENTS.md (added lean-ctx reference block).");
814 }
815}
816
817fn ensure_copilot_instructions(cwd: &std::path::Path) {
824 let path = cwd.join(".github").join("copilot-instructions.md");
825 let block = crate::rules_inject::rules_dedicated_markdown();
826 let start = crate::core::rules_canonical::START_MARK;
827 let end = crate::core::rules_canonical::END_MARK;
828 let owned = format!("{}\n", block.trim_end());
829
830 let existing = std::fs::read_to_string(&path).unwrap_or_default();
831 let desired = if existing.trim().is_empty() {
832 owned
833 } else if existing.contains(start) {
834 let user = crate::marked_block::remove_content(&existing, start, end);
836 if user.trim().is_empty() {
837 owned
838 } else {
839 format!("{}\n\n{}\n", user.trim_end(), block.trim_end())
840 }
841 } else {
842 format!("{}\n\n{}\n", existing.trim_end(), block.trim_end())
844 };
845
846 if desired == existing {
847 return;
848 }
849 if let Some(parent) = path.parent()
850 && std::fs::create_dir_all(parent).is_err()
851 {
852 return;
853 }
854 write_file(&path, &desired);
855 if !mcp_server_quiet_mode() {
856 eprintln!("Created/updated .github/copilot-instructions.md (Copilot/VS Code rules).");
857 }
858}
859
860fn ensure_vscode_instruction_files_setting(cwd: &std::path::Path) {
865 const KEY: &str = "github.copilot.chat.codeGeneration.useInstructionFiles";
866 let path = cwd.join(".vscode").join("settings.json");
867
868 let existing = std::fs::read_to_string(&path).unwrap_or_default();
869 let mut json = if existing.trim().is_empty() {
870 serde_json::json!({})
871 } else {
872 match crate::core::jsonc::parse_jsonc(&existing) {
873 Ok(v) if v.is_object() => v,
874 _ => return,
876 }
877 };
878 let Some(obj) = json.as_object_mut() else {
879 return;
880 };
881 if obj.contains_key(KEY) {
882 return;
883 }
884 obj.insert(KEY.to_string(), serde_json::Value::Bool(true));
885
886 if let Some(parent) = path.parent()
887 && std::fs::create_dir_all(parent).is_err()
888 {
889 return;
890 }
891 let Ok(formatted) = serde_json::to_string_pretty(&json) else {
892 return;
893 };
894 if crate::config_io::write_atomic_with_backup(&path, &formatted).is_ok()
895 && !mcp_server_quiet_mode()
896 {
897 eprintln!("Set {KEY} in .vscode/settings.json.");
898 }
899}
900
901pub fn cursorrules_content() -> String {
905 let start = crate::core::rules_canonical::START_MARK;
906 let end = crate::core::rules_canonical::END_MARK;
907 let version = crate::core::rules_canonical::RULES_VERSION;
908 format!(
909 "{start}\n<!-- version: {version} -->\n\n\
910# lean-ctx\n\n\
911{bullets}\n\n\
912{never}\n\
913Full rules: ~/.cursor/rules/lean-ctx.mdc (auto-loaded) \u{2014} do not duplicate here.\n\
914{end}",
915 bullets = crate::core::rules_canonical::BULLETS,
916 never = crate::core::rules_canonical::NEVER,
917 )
918}
919
920pub fn kiro_steering_content() -> String {
921 use crate::core::rules_canonical;
922 format!(
923 "---\n\
924inclusion: always\n\
925---\n\n\
926# Context Engineering Layer\n\n\
927{start}\n\
928<!-- version: {version} -->\n\n\
929The workspace has the `lean-ctx` MCP server installed. \
930You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.\n\n\
931{bullets}\n\n\
932{never}\n\n\
933## When to use native Kiro tools instead\n\n\
934- `fsWrite` / `fsAppend` \u{2014} always use native (lean-ctx doesn't write files)\n\
935- `strReplace` \u{2014} always use native (precise string replacement)\n\
936- `semanticRename` / `smartRelocate` \u{2014} always use native (IDE integration)\n\
937- `getDiagnostics` \u{2014} always use native (language server diagnostics)\n\
938- `deleteFile` \u{2014} always use native\n\
939- Glob \u{2014} always use native glob\n\n\
940{end}",
941 start = rules_canonical::START_MARK,
942 version = rules_canonical::RULES_VERSION,
943 bullets = rules_canonical::BULLETS,
944 never = rules_canonical::NEVER,
945 end = rules_canonical::END_MARK,
946 )
947}
948pub(crate) fn should_register_mcp() -> bool {
955 crate::core::config::Config::load()
956 .setup
957 .should_update_mcp()
958}
959
960pub fn install_agent_hook(agent: &str, global: bool) {
961 install_agent_hook_with_mode(agent, global, HookMode::Mcp);
962}
963
964pub fn install_agent_hook_with_mode(agent: &str, global: bool, mode: HookMode) {
965 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
966 match agent {
967 "claude" | "claude-code" => install_claude_hook_with_mode(global, mode),
968 "codebuddy" => install_codebuddy_hook_with_mode(global, mode),
969 "cursor" => install_cursor_hook_with_mode(global, mode),
970 "gemini" => {
971 install_gemini_hook();
972 if mode == HookMode::Replace {
973 install_gemini_deny_hook(&home);
974 }
975 install_antigravity_cli_hook();
981 }
982 "grok" | "grok-build" => install_grok_mcp(),
983 "antigravity" => install_antigravity_hook(),
984 "antigravity-cli" => install_antigravity_cli_hook(),
985 "augment" => install_mcp_json_agent(
986 "Augment CLI",
987 "~/.augment/settings.json",
988 &crate::core::editor_registry::augment_cli_settings_path(&home),
989 ),
990 "codex" => {
991 install_codex_hook();
992 }
993 "windsurf" => {
994 install_windsurf_rules(global);
995 if mode == HookMode::Replace {
996 install_windsurf_hooks_replace(&home);
997 }
998 }
999 "cline" | "roo" => install_cline_rules(global),
1000 "copilot" | "vscode" => install_copilot_hook(global),
1001 "vscode-insiders" | "commandcode" => {}
1008 "pi" => install_pi_hook_with_mode(global, mode),
1009 "qoder" | "qodercli" => install_qoder_hook_with_mode(mode),
1010 "qoderwork" => install_mcp_json_agent(
1011 "QoderWork",
1012 "~/.qoderwork/mcp.json",
1013 &home.join(".qoderwork/mcp.json"),
1014 ),
1015 "qwen" => install_mcp_json_agent(
1016 "Qwen Code",
1017 "~/.qwen/settings.json",
1018 &home.join(".qwen/settings.json"),
1019 ),
1020 "trae" => install_mcp_json_agent("Trae", "~/.trae/mcp.json", &home.join(".trae/mcp.json")),
1021 "amazonq" => install_mcp_json_agent(
1022 "Amazon Q Developer",
1023 "~/.aws/amazonq/default.json",
1024 &home.join(".aws/amazonq/default.json"),
1025 ),
1026 "jetbrains" => install_jetbrains_hook(),
1027 "kiro" => install_kiro_hook(),
1028 "verdent" => install_mcp_json_agent(
1029 "Verdent",
1030 "~/.verdent/mcp.json",
1031 &home.join(".verdent/mcp.json"),
1032 ),
1033 "opencode" => install_opencode_hook_with_mode(mode),
1034 "amp" => install_amp_hook(),
1035 "crush" => install_crush_hook_with_mode(mode),
1036 "openclaw" => install_openclaw_hook(),
1037 "hermes" => install_hermes_hook_with_mode(global, mode),
1038 "vibe" => install_vibe_hook(),
1039 "zed" => {
1040 let zed_path = crate::core::editor_registry::zed_settings_path(&home);
1041 let binary = resolve_binary_path();
1042 let entry = full_server_entry(&binary);
1043 install_named_json_server("Zed", "settings.json", &zed_path, "context_servers", entry);
1044 }
1045 "aider" => {
1046 install_mcp_json_agent("Aider", "~/.aider/mcp.json", &home.join(".aider/mcp.json"));
1047 }
1048 "continue" => install_mcp_json_agent(
1049 "Continue",
1050 "~/.continue/mcp.json",
1051 &home.join(".continue/mcp.json"),
1052 ),
1053 "neovim" => install_mcp_json_agent(
1054 "Neovim (mcphub.nvim)",
1055 "~/.config/mcphub/servers.json",
1056 &home.join(".config/mcphub/servers.json"),
1057 ),
1058 "emacs" => install_mcp_json_agent(
1059 "Emacs (mcp.el)",
1060 "~/.emacs.d/mcp.json",
1061 &home.join(".emacs.d/mcp.json"),
1062 ),
1063 "sublime" => install_mcp_json_agent(
1064 "Sublime Text",
1065 "~/.config/sublime-text/mcp.json",
1066 &home.join(".config/sublime-text/mcp.json"),
1067 ),
1068 _ => {
1069 eprintln!("Unknown agent: {agent}");
1070 eprintln!(" Supported: aider, amazonq, amp, antigravity, antigravity-cli, augment,");
1071 eprintln!(
1072 " claude, cline, codebuddy, codex, commandcode, continue, copilot, crush, cursor, emacs, gemini, grok,"
1073 );
1074 eprintln!(
1075 " grok-build, hermes, jetbrains, kiro, neovim, openclaw, opencode, pi, qoder,"
1076 );
1077 eprintln!(
1078 " qodercli, qoderwork, qwen, roo, sublime, trae, verdent, vibe, vscode, windsurf, zed"
1079 );
1080 std::process::exit(1);
1081 }
1082 }
1083}
1084
1085pub fn install_agent_project_hooks(agent: &str, cwd: &std::path::Path) {
1086 match agent {
1087 "claude" | "claude-code" => agents::install_claude_project_hooks(cwd),
1088 "codebuddy" => agents::install_codebuddy_project_hooks(cwd),
1089 _ => {}
1090 }
1091}
1092
1093fn write_file(path: &std::path::Path, content: &str) {
1094 if std::fs::read_to_string(path).is_ok_and(|existing| existing == content) {
1097 return;
1098 }
1099 if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
1100 tracing::error!("Error writing {}: {e}", path.display());
1101 }
1102}
1103
1104fn ensure_state_dir(dir: &std::path::Path) -> bool {
1113 match crate::config_io::ensure_dir(dir) {
1114 Ok(()) => true,
1115 Err(e) => {
1116 eprintln!("lean-ctx setup: cannot prepare {}: {e}", dir.display());
1118 false
1119 }
1120 }
1121}
1122
1123fn is_inside_git_repo(path: &std::path::Path) -> bool {
1124 let mut p = path;
1125 loop {
1126 if p.join(".git").exists() {
1127 return true;
1128 }
1129 match p.parent() {
1130 Some(parent) => p = parent,
1131 None => return false,
1132 }
1133 }
1134}
1135
1136#[cfg(unix)]
1137fn make_executable(path: &PathBuf) {
1138 use std::os::unix::fs::PermissionsExt;
1139 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1140}
1141
1142#[cfg(not(unix))]
1143fn make_executable(_path: &PathBuf) {}
1144
1145pub(crate) fn mcp_server_env_pairs() -> Vec<(String, String)> {
1158 let mut pairs = Vec::new();
1159
1160 let cfg = crate::core::config::Config::load();
1161
1162 let project_root = std::env::var("LEAN_CTX_PROJECT_ROOT")
1163 .ok()
1164 .filter(|v| !v.trim().is_empty())
1165 .or_else(|| cfg.project_root.clone().filter(|v| !v.trim().is_empty()));
1166 if let Some(root) = project_root {
1167 pairs.push(("LEAN_CTX_PROJECT_ROOT".to_string(), root));
1168 }
1169
1170 let extra_roots = std::env::var("LEAN_CTX_EXTRA_ROOTS")
1173 .ok()
1174 .filter(|v| !v.trim().is_empty())
1175 .or_else(|| {
1176 let roots: Vec<&str> = cfg
1177 .extra_roots
1178 .iter()
1179 .map(String::as_str)
1180 .filter(|s| !s.trim().is_empty())
1181 .collect();
1182 if roots.is_empty() {
1183 return None;
1184 }
1185 std::env::join_paths(roots)
1186 .ok()
1187 .map(|s| s.to_string_lossy().to_string())
1188 });
1189 if let Some(extra) = extra_roots {
1190 pairs.push(("LEAN_CTX_EXTRA_ROOTS".to_string(), extra));
1191 }
1192
1193 pairs
1194}
1195
1196pub(crate) fn mcp_server_env_json() -> serde_json::Value {
1198 let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
1199 .into_iter()
1200 .map(|(k, v)| (k, serde_json::Value::String(v)))
1201 .collect();
1202 serde_json::Value::Object(map)
1203}
1204
1205fn full_server_entry(binary: &str) -> serde_json::Value {
1206 serde_json::json!({
1212 "command": binary,
1213 "env": mcp_server_env_json()
1214 })
1215}
1216
1217pub(crate) fn install_mcp_json_agent(
1218 name: &str,
1219 display_path: &str,
1220 config_path: &std::path::Path,
1221) {
1222 let binary = resolve_binary_path();
1223 let entry = full_server_entry(&binary);
1224 install_named_json_server(name, display_path, config_path, "mcpServers", entry);
1225}
1226
1227#[cfg(test)]
1228mod tests;