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 "codex",
60 "windsurf",
61 "opencode",
62 "gemini",
63 "copilot",
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_project_hooks, install_cline_rules, install_codebuddy_hook_config,
116 install_codebuddy_hook_scripts, install_codebuddy_hook_with_mode,
117 install_codebuddy_project_hooks, install_codex_deny_hook, install_codex_hook,
118 install_copilot_hook, install_crush_hook_with_mode, install_cursor_hook_config,
119 install_cursor_hook_scripts, install_cursor_hook_with_mode, install_gemini_deny_hook,
120 install_gemini_hook, install_gemini_hook_config, install_gemini_hook_scripts,
121 install_hermes_hook_with_mode, install_jetbrains_hook, install_kiro_hook,
122 install_openclaw_hook, install_opencode_hook_with_mode, install_pi_hook_with_mode,
123 install_qoder_hook, install_qoder_hook_with_mode, install_windsurf_hooks,
124 install_windsurf_hooks_replace, install_windsurf_rules,
125};
126use support::{
127 ensure_codex_hooks_enabled, install_codex_instruction_docs, install_named_json_server,
128 upsert_lean_ctx_codex_hook_entries,
129};
130
131fn mcp_server_quiet_mode() -> bool {
132 std::env::var_os("LEAN_CTX_MCP_SERVER").is_some()
133 || matches!(std::env::var("LEAN_CTX_QUIET"), Ok(value) if value.trim() == "1")
134}
135
136const REFRESHABLE_HOOK_AGENTS: &[&str] = &[
144 "claude", "cursor", "gemini", "codex", "windsurf", "copilot", "qoder",
145];
146
147#[cfg(test)]
152const REFRESH_EXEMPT_HYBRID_AGENTS: &[&str] = &[
153 "claude-code",
155 "pi",
157 "cline",
160 "roo",
161 "kiro",
162 "antigravity",
165 "antigravity-cli",
166 "amp",
167 "crush",
168 "hermes",
169 "opencode",
170 "openclaw",
171 "qwen",
172 "trae",
173 "amazonq",
174 "verdent",
175];
176
177pub fn refresh_installed_hooks() {
182 let Some(home) = crate::core::home::resolve_home_dir() else {
183 return;
184 };
185 for agent in REFRESHABLE_HOOK_AGENTS {
186 if hooks_installed_for(agent, &home) {
187 refresh_agent_hooks(agent, &home);
188 }
189 }
190}
191
192fn hooks_installed_for(agent: &str, home: &std::path::Path) -> bool {
194 match agent {
195 "claude" => {
196 let dir = crate::setup::claude_config_dir(home);
197 dir.join("hooks/lean-ctx-rewrite.sh").exists()
198 || file_contains_lean_ctx(&dir.join("settings.json"))
199 }
200 "codebuddy" => {
201 let dir = crate::core::editor_registry::codebuddy_state_dir(home);
202 dir.join("hooks/lean-ctx-rewrite.sh").exists()
203 || file_contains_lean_ctx(&dir.join("settings.json"))
204 }
205 "cursor" => {
206 home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
207 || file_contains_lean_ctx(&home.join(".cursor/hooks.json"))
208 }
209 "gemini" => {
210 home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh")
211 .exists()
212 || home.join(".gemini/hooks/lean-ctx-hook-gemini.sh").exists()
213 }
214 "codex" => {
215 let dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
216 dir.join("hooks/lean-ctx-rewrite-codex.sh").exists()
217 || file_contains_lean_ctx(&dir.join("hooks.json"))
218 }
219 "windsurf" => file_contains_lean_ctx(&home.join(".codeium/windsurf/hooks.json")),
220 "copilot" => {
221 file_contains_lean_ctx(&home.join(".copilot/hooks/hooks.json"))
224 || file_contains_lean_ctx(&home.join(".github/hooks/hooks.json"))
225 }
226 "qoder" => file_contains_lean_ctx(&home.join(".qoder/settings.json")),
227 _ => false,
228 }
229}
230
231fn refresh_agent_hooks(agent: &str, home: &std::path::Path) {
234 match agent {
235 "claude" => {
236 install_claude_hook_scripts(home);
237 install_claude_hook_config(home);
238 }
239 "codebuddy" => {
240 install_codebuddy_hook_scripts(home);
241 install_codebuddy_hook_config(home);
242 }
243 "cursor" => {
244 install_cursor_hook_scripts(home);
245 install_cursor_hook_config(home);
246 }
247 "gemini" => {
248 install_gemini_hook_scripts(home);
249 install_gemini_hook_config(home);
250 }
251 "codex" => install_codex_hook(),
252 "windsurf" => install_windsurf_hooks(home),
253 "copilot" => install_copilot_hook(true),
254 "qoder" => install_qoder_hook(),
255 _ => {}
256 }
257}
258
259fn file_contains_lean_ctx(path: &std::path::Path) -> bool {
260 std::fs::read_to_string(path).is_ok_and(|c| c.contains("lean-ctx"))
261}
262
263fn resolve_binary_path() -> String {
279 crate::core::portable_binary::resolve_portable_binary()
280}
281
282fn resolve_hook_command_binary() -> String {
293 if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
294 return portable;
295 }
296 resolve_binary_path()
297}
298
299fn resolve_binary_path_for_bash() -> String {
300 if let Some(portable) = crate::core::portable_binary::hook_binary_override() {
301 return portable;
302 }
303 to_bash_compatible_path(&resolve_binary_path())
304}
305
306pub(crate) fn shell_quoted_binary(binary: &str) -> String {
313 let escaped = binary.replace('"', "\\\"").replace('`', "\\`");
314 format!("\"{escaped}\"")
315}
316
317fn wrapper_is_portable_and_working(path: &std::path::Path, home: &std::path::Path) -> bool {
326 std::fs::read_to_string(path)
327 .is_ok_and(|content| wrapper_content_is_portable_and_working(&content, home))
328}
329
330pub(crate) fn wrapper_content_is_portable_and_working(
331 content: &str,
332 home: &std::path::Path,
333) -> bool {
334 let Some(token) = wrapper_binary_token(content) else {
335 return false;
336 };
337 if !(token.contains("$HOME") || token.contains("${HOME}") || token.contains("%USERPROFILE%")) {
338 return false;
339 }
340 let home_s = home.to_string_lossy();
341 let expanded = token
342 .replace("${HOME}", &home_s)
343 .replace("$HOME", &home_s)
344 .replace("%USERPROFILE%", &home_s);
345 std::path::Path::new(&from_bash_to_native_path(&expanded)).exists()
346}
347
348pub(crate) fn wrapper_binary_token(content: &str) -> Option<String> {
352 for line in content.lines() {
353 let t = line.trim();
354 if let Some(rest) = t.strip_prefix("LEAN_CTX_BIN=") {
355 let rest = rest.trim();
356 let tok = rest
357 .strip_prefix('"')
358 .map_or(rest, |r| r.split('"').next().unwrap_or_default());
359 if !tok.is_empty() {
360 return Some(tok.to_string());
361 }
362 }
363 if let Some(rest) = t.strip_prefix("exec ") {
364 let rest = rest.trim();
365 let tok = match rest.strip_prefix('"') {
366 Some(r) => r.split('"').next().unwrap_or_default().to_string(),
367 None => rest
368 .split_whitespace()
369 .next()
370 .unwrap_or_default()
371 .to_string(),
372 };
373 if !tok.is_empty() {
374 return Some(tok);
375 }
376 }
377 }
378 None
379}
380
381fn write_wrapper_file(path: &std::path::Path, content: &str, home: &std::path::Path) {
386 if crate::core::portable_binary::hook_binary_override().is_none()
387 && wrapper_is_portable_and_working(path, home)
388 {
389 return;
390 }
391 write_file(path, content);
392}
393
394pub fn to_bash_compatible_path(path: &str) -> String {
395 let path = match crate::core::pathutil::strip_verbatim_str(path) {
396 Some(stripped) => stripped,
397 None => path.replace('\\', "/"),
398 };
399 if path.len() >= 2 && path.as_bytes()[1] == b':' {
400 let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
401 format!("/{drive}{}", &path[2..])
402 } else {
403 path
404 }
405}
406
407pub fn from_bash_to_native_path(path: &str) -> String {
410 crate::core::pathutil::normalize_tool_path(path)
411}
412
413pub fn normalize_tool_path(path: &str) -> String {
416 crate::core::pathutil::normalize_tool_path(path)
417}
418
419pub fn generate_rewrite_script(binary: &str) -> String {
420 let case_pattern = crate::rewrite_registry::bash_case_pattern();
421 let quoted_binary = shell_quoted_binary(binary);
425 format!(
426 r#"#!/usr/bin/env bash
427# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
428set -euo pipefail
429
430LEAN_CTX_BIN={quoted_binary}
431
432INPUT=$(cat)
433TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
434
435case "$TOOL" in
436 Bash|bash|PowerShell|powershell) ;;
437 *) exit 0 ;;
438esac
439
440CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
441
442if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |\"?$LEAN_CTX_BIN\"? )"; then
443 exit 0
444fi
445
446# Skip multi-line commands: the grep/sed extraction above does not decode
447# JSON \n into real newlines, so lean-ctx -c would receive fused lines (#787).
448if printf '%s' "$CMD" | grep -qF '\n'; then exit 0; fi
449
450case "$CMD" in
451 {case_pattern})
452 # Shell-escape then JSON-escape (two passes)
453 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
454 REWRITE="\"$LEAN_CTX_BIN\" -c \"$SHELL_ESC\""
455 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
456 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
457 ;;
458 *) exit 0 ;;
459esac
460"#
461 )
462}
463
464pub fn generate_compact_rewrite_script(binary: &str) -> String {
465 let case_pattern = crate::rewrite_registry::bash_case_pattern();
466 let quoted_binary = shell_quoted_binary(binary);
467 format!(
468 r#"#!/usr/bin/env bash
469# lean-ctx hook — rewrites shell commands
470set -euo pipefail
471LEAN_CTX_BIN={quoted_binary}
472INPUT=$(cat)
473CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
474if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |\"?$LEAN_CTX_BIN\"? )"; then exit 0; fi
475if printf '%s' "$CMD" | grep -qF '\n'; then exit 0; fi
476case "$CMD" in
477 {case_pattern})
478 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
479 REWRITE="\"$LEAN_CTX_BIN\" -c \"$SHELL_ESC\""
480 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
481 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
482 *) exit 0 ;;
483esac
484"#
485 )
486}
487
488const REDIRECT_SCRIPT_CLAUDE: &str = r"#!/usr/bin/env bash
489# lean-ctx PreToolUse hook — all native tools pass through
490# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
491# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
492exit 0
493";
494
495const REDIRECT_SCRIPT_GENERIC: &str = r"#!/usr/bin/env bash
496# lean-ctx hook — all native tools pass through
497exit 0
498";
499
500pub fn hybrid_rules_content() -> String {
501 use crate::core::rules_canonical;
502 format!(
503 "{start}\n<!-- version: {version} -->\n\n\
504# lean-ctx \u{2014} Hybrid Mode (MCP reads + CLI commands)\n\n\
505{bullets}\n\n\
506{never}\n\n\
507{end}",
508 start = rules_canonical::START_MARK,
509 version = rules_canonical::RULES_VERSION,
510 bullets = rules_canonical::BULLETS,
511 never = rules_canonical::NEVER,
512 end = rules_canonical::END_MARK,
513 )
514}
515
516pub fn install_project_rules() {
517 install_project_rules_for_agents(&[]);
518}
519
520pub fn install_project_rules_for_agents(agents: &[&str]) {
523 if crate::core::config::Config::load().rules_scope_effective()
524 == crate::core::config::RulesScope::Global
525 {
526 return;
527 }
528
529 let cwd = std::env::current_dir().unwrap_or_default();
530
531 if !is_inside_git_repo(&cwd) {
532 eprintln!(
533 " Skipping project files: not inside a git repository.\n \
534 Run this command from your project root to create CLAUDE.md / AGENTS.md."
535 );
536 return;
537 }
538
539 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
540 if cwd == home {
541 eprintln!(
542 " Skipping project files: current directory is your home folder.\n \
543 Run this command from a project directory instead."
544 );
545 return;
546 }
547
548 let all = agents.is_empty();
549 let wants = |name: &str| all || agents.iter().any(|a| a.eq_ignore_ascii_case(name));
550
551 ensure_project_agents_integration(&cwd);
552
553 if wants("cursor") || wants("windsurf") {
554 let cursorrules = cwd.join(".cursorrules");
555 if !cursorrules.exists()
556 || !std::fs::read_to_string(&cursorrules)
557 .unwrap_or_default()
558 .contains("lean-ctx")
559 {
560 let content = cursorrules_content();
561 if cursorrules.exists() {
562 let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
563 if !existing.ends_with('\n') {
564 existing.push('\n');
565 }
566 existing.push('\n');
567 existing.push_str(&content);
568 write_file(&cursorrules, &existing);
569 } else {
570 write_file(&cursorrules, &content);
571 }
572 if !mcp_server_quiet_mode() {
573 eprintln!("Created/updated .cursorrules in project root.");
574 }
575 }
576 }
577
578 if wants("claude") {
579 let claude_rules_file = cwd.join(".claude").join("rules").join("lean-ctx.md");
585 if let Ok(existing) = std::fs::read_to_string(&claude_rules_file)
586 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
587 && std::fs::remove_file(&claude_rules_file).is_ok()
588 && !mcp_server_quiet_mode()
589 {
590 eprintln!(
591 "Removed .claude/rules/lean-ctx.md (always-loaded duplicate; AGENTS.md block + skill replace it)."
592 );
593 }
594
595 install_claude_project_hooks(&cwd);
596 }
597
598 if wants("codebuddy") {
599 let codebuddy_rules_file = cwd.join(".codebuddy").join("rules").join("lean-ctx.md");
600 if let Ok(existing) = std::fs::read_to_string(&codebuddy_rules_file)
601 && existing.contains(crate::core::rules_canonical::RULES_MARKER_PREFIX)
602 && std::fs::remove_file(&codebuddy_rules_file).is_ok()
603 && !mcp_server_quiet_mode()
604 {
605 eprintln!(
606 "Removed .codebuddy/rules/lean-ctx.md (always-loaded duplicate; CODEBUDDY.md block + skill replace it)."
607 );
608 }
609
610 install_codebuddy_project_hooks(&cwd);
611 }
612
613 if wants("kiro") {
614 let kiro_dir = cwd.join(".kiro");
615 if kiro_dir.exists() {
616 let steering_dir = kiro_dir.join("steering");
617 let steering_file = steering_dir.join("lean-ctx.md");
618 if !steering_file.exists()
619 || !std::fs::read_to_string(&steering_file)
620 .unwrap_or_default()
621 .contains("lean-ctx")
622 {
623 let _ = std::fs::create_dir_all(&steering_dir);
624 write_file(&steering_file, &kiro_steering_content());
625 if !mcp_server_quiet_mode() {
626 eprintln!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
627 }
628 }
629 }
630 }
631
632 if wants("copilot") || wants("vscode") {
633 ensure_copilot_instructions(&cwd);
634 ensure_vscode_instruction_files_setting(&cwd);
635 }
636}
637
638const PROJECT_LEAN_CTX_MD_MARKER: &str =
639 crate::core::rules_canonical::PROJECT_LEAN_CTX_OWNED_MARKER;
640const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
641const PROJECT_AGENTS_MD: &str = "AGENTS.md";
642const AGENTS_BLOCK_START: &str = crate::core::rules_canonical::AGENTS_BLOCK_START;
646const AGENTS_BLOCK_END: &str = crate::core::rules_canonical::AGENTS_BLOCK_END;
647
648fn ensure_project_agents_integration(cwd: &std::path::Path) {
649 let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
650 let desired = format!(
653 "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
654 crate::rules_inject::rules_longform_markdown()
655 );
656
657 if !lean_ctx_md.exists() {
658 write_file(&lean_ctx_md, &desired);
659 } else if std::fs::read_to_string(&lean_ctx_md)
660 .unwrap_or_default()
661 .contains(PROJECT_LEAN_CTX_MD_MARKER)
662 {
663 let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
664 let version_str = format!(
665 "<!-- version: {} -->",
666 crate::core::rules_canonical::RULES_VERSION
667 );
668 if !current.contains(&version_str) {
669 write_file(&lean_ctx_md, &desired);
670 }
671 }
672
673 let block = format!(
678 "{AGENTS_BLOCK_START}\n\
679## lean-ctx\n\n\
680lean-ctx is active — the MCP tools replace native equivalents.\n\
681Full rules: {PROJECT_LEAN_CTX_MD} (open on demand — do not auto-load).\n\
682{AGENTS_BLOCK_END}\n"
683 );
684
685 let agents_md = cwd.join(PROJECT_AGENTS_MD);
686 if !agents_md.exists() {
687 let content = format!("# Agent Instructions\n\n{block}");
688 write_file(&agents_md, &content);
689 if !mcp_server_quiet_mode() {
690 eprintln!("Created AGENTS.md in project root (lean-ctx reference only).");
691 }
692 return;
693 }
694
695 let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
696
697 let has_block = crate::marked_block::contains_marker_line(&existing, AGENTS_BLOCK_START);
700
701 if existing.contains("CLI-first Token Optimization for Pi") && !has_block {
702 let content = format!("# Agent Instructions\n\n{block}");
703 write_file(&agents_md, &content);
704 return;
705 }
706
707 if has_block {
708 let updated = crate::marked_block::replace_marked_block(
709 &existing,
710 AGENTS_BLOCK_START,
711 AGENTS_BLOCK_END,
712 &block,
713 );
714 if updated != existing {
715 write_file(&agents_md, &updated);
716 }
717 return;
718 }
719
720 if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
721 return;
722 }
723
724 let mut out = existing;
725 if !out.ends_with('\n') {
726 out.push('\n');
727 }
728 out.push('\n');
729 out.push_str(&block);
730 write_file(&agents_md, &out);
731 if !mcp_server_quiet_mode() {
732 eprintln!("Updated AGENTS.md (added lean-ctx reference block).");
733 }
734}
735
736fn ensure_copilot_instructions(cwd: &std::path::Path) {
743 let path = cwd.join(".github").join("copilot-instructions.md");
744 let block = crate::rules_inject::rules_dedicated_markdown();
745 let start = crate::core::rules_canonical::START_MARK;
746 let end = crate::core::rules_canonical::END_MARK;
747 let owned = format!("{}\n", block.trim_end());
748
749 let existing = std::fs::read_to_string(&path).unwrap_or_default();
750 let desired = if existing.trim().is_empty() {
751 owned
752 } else if existing.contains(start) {
753 let user = crate::marked_block::remove_content(&existing, start, end);
755 if user.trim().is_empty() {
756 owned
757 } else {
758 format!("{}\n\n{}\n", user.trim_end(), block.trim_end())
759 }
760 } else {
761 format!("{}\n\n{}\n", existing.trim_end(), block.trim_end())
763 };
764
765 if desired == existing {
766 return;
767 }
768 if let Some(parent) = path.parent()
769 && std::fs::create_dir_all(parent).is_err()
770 {
771 return;
772 }
773 write_file(&path, &desired);
774 if !mcp_server_quiet_mode() {
775 eprintln!("Created/updated .github/copilot-instructions.md (Copilot/VS Code rules).");
776 }
777}
778
779fn ensure_vscode_instruction_files_setting(cwd: &std::path::Path) {
784 const KEY: &str = "github.copilot.chat.codeGeneration.useInstructionFiles";
785 let path = cwd.join(".vscode").join("settings.json");
786
787 let existing = std::fs::read_to_string(&path).unwrap_or_default();
788 let mut json = if existing.trim().is_empty() {
789 serde_json::json!({})
790 } else {
791 match crate::core::jsonc::parse_jsonc(&existing) {
792 Ok(v) if v.is_object() => v,
793 _ => return,
795 }
796 };
797 let Some(obj) = json.as_object_mut() else {
798 return;
799 };
800 if obj.contains_key(KEY) {
801 return;
802 }
803 obj.insert(KEY.to_string(), serde_json::Value::Bool(true));
804
805 if let Some(parent) = path.parent()
806 && std::fs::create_dir_all(parent).is_err()
807 {
808 return;
809 }
810 let Ok(formatted) = serde_json::to_string_pretty(&json) else {
811 return;
812 };
813 if crate::config_io::write_atomic_with_backup(&path, &formatted).is_ok()
814 && !mcp_server_quiet_mode()
815 {
816 eprintln!("Set {KEY} in .vscode/settings.json.");
817 }
818}
819
820pub fn cursorrules_content() -> String {
824 let start = crate::core::rules_canonical::START_MARK;
825 let end = crate::core::rules_canonical::END_MARK;
826 let version = crate::core::rules_canonical::RULES_VERSION;
827 format!(
828 "{start}\n<!-- version: {version} -->\n\n\
829# lean-ctx\n\n\
830{bullets}\n\n\
831{never}\n\
832Full rules: ~/.cursor/rules/lean-ctx.mdc (auto-loaded) \u{2014} do not duplicate here.\n\
833{end}",
834 bullets = crate::core::rules_canonical::BULLETS,
835 never = crate::core::rules_canonical::NEVER,
836 )
837}
838
839pub fn kiro_steering_content() -> String {
840 use crate::core::rules_canonical;
841 format!(
842 "---\n\
843inclusion: always\n\
844---\n\n\
845# Context Engineering Layer\n\n\
846{start}\n\
847<!-- version: {version} -->\n\n\
848The workspace has the `lean-ctx` MCP server installed. \
849You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.\n\n\
850{bullets}\n\n\
851{never}\n\n\
852## When to use native Kiro tools instead\n\n\
853- `fsWrite` / `fsAppend` \u{2014} always use native (lean-ctx doesn't write files)\n\
854- `strReplace` \u{2014} always use native (precise string replacement)\n\
855- `semanticRename` / `smartRelocate` \u{2014} always use native (IDE integration)\n\
856- `getDiagnostics` \u{2014} always use native (language server diagnostics)\n\
857- `deleteFile` \u{2014} always use native\n\
858- Glob \u{2014} always use native glob\n\n\
859{end}",
860 start = rules_canonical::START_MARK,
861 version = rules_canonical::RULES_VERSION,
862 bullets = rules_canonical::BULLETS,
863 never = rules_canonical::NEVER,
864 end = rules_canonical::END_MARK,
865 )
866}
867pub(crate) fn should_register_mcp() -> bool {
874 crate::core::config::Config::load()
875 .setup
876 .should_update_mcp()
877}
878
879pub fn install_agent_hook(agent: &str, global: bool) {
880 install_agent_hook_with_mode(agent, global, HookMode::Mcp);
881}
882
883pub fn install_agent_hook_with_mode(agent: &str, global: bool, mode: HookMode) {
884 let home = crate::core::home::resolve_home_dir().unwrap_or_default();
885 match agent {
886 "claude" | "claude-code" => install_claude_hook_with_mode(global, mode),
887 "codebuddy" => install_codebuddy_hook_with_mode(global, mode),
888 "cursor" => install_cursor_hook_with_mode(global, mode),
889 "gemini" => {
890 install_gemini_hook();
891 if mode == HookMode::Replace {
892 install_gemini_deny_hook(&home);
893 }
894 install_antigravity_cli_hook();
900 }
901 "antigravity" => install_antigravity_hook(),
902 "antigravity-cli" => install_antigravity_cli_hook(),
903 "augment" => install_mcp_json_agent(
904 "Augment CLI",
905 "~/.augment/settings.json",
906 &crate::core::editor_registry::augment_cli_settings_path(&home),
907 ),
908 "codex" => {
909 install_codex_hook();
910 if mode == HookMode::Replace {
911 install_codex_deny_hook();
912 }
913 }
914 "windsurf" => {
915 install_windsurf_rules(global);
916 if mode == HookMode::Replace {
917 install_windsurf_hooks_replace(&home);
918 }
919 }
920 "cline" | "roo" => install_cline_rules(global),
921 "copilot" | "vscode" => install_copilot_hook(global),
922 "vscode-insiders" => {}
927 "pi" => install_pi_hook_with_mode(global, mode),
928 "qoder" => install_qoder_hook_with_mode(mode),
929 "qoderwork" => install_mcp_json_agent(
930 "QoderWork",
931 "~/.qoderwork/mcp.json",
932 &home.join(".qoderwork/mcp.json"),
933 ),
934 "qwen" => install_mcp_json_agent(
935 "Qwen Code",
936 "~/.qwen/settings.json",
937 &home.join(".qwen/settings.json"),
938 ),
939 "trae" => install_mcp_json_agent("Trae", "~/.trae/mcp.json", &home.join(".trae/mcp.json")),
940 "amazonq" => install_mcp_json_agent(
941 "Amazon Q Developer",
942 "~/.aws/amazonq/default.json",
943 &home.join(".aws/amazonq/default.json"),
944 ),
945 "jetbrains" => install_jetbrains_hook(),
946 "kiro" => install_kiro_hook(),
947 "verdent" => install_mcp_json_agent(
948 "Verdent",
949 "~/.verdent/mcp.json",
950 &home.join(".verdent/mcp.json"),
951 ),
952 "opencode" => install_opencode_hook_with_mode(mode),
953 "amp" => install_amp_hook(),
954 "crush" => install_crush_hook_with_mode(mode),
955 "openclaw" => install_openclaw_hook(),
956 "hermes" => install_hermes_hook_with_mode(global, mode),
957 "zed" => {
958 let zed_path = crate::core::editor_registry::zed_settings_path(&home);
959 let binary = resolve_binary_path();
960 let entry = full_server_entry(&binary);
961 install_named_json_server("Zed", "settings.json", &zed_path, "context_servers", entry);
962 }
963 "aider" => {
964 install_mcp_json_agent("Aider", "~/.aider/mcp.json", &home.join(".aider/mcp.json"));
965 }
966 "continue" => install_mcp_json_agent(
967 "Continue",
968 "~/.continue/mcp.json",
969 &home.join(".continue/mcp.json"),
970 ),
971 "neovim" => install_mcp_json_agent(
972 "Neovim (mcphub.nvim)",
973 "~/.config/mcphub/servers.json",
974 &home.join(".config/mcphub/servers.json"),
975 ),
976 "emacs" => install_mcp_json_agent(
977 "Emacs (mcp.el)",
978 "~/.emacs.d/mcp.json",
979 &home.join(".emacs.d/mcp.json"),
980 ),
981 "sublime" => install_mcp_json_agent(
982 "Sublime Text",
983 "~/.config/sublime-text/mcp.json",
984 &home.join(".config/sublime-text/mcp.json"),
985 ),
986 _ => {
987 eprintln!("Unknown agent: {agent}");
988 eprintln!(" Supported: aider, amazonq, amp, antigravity, antigravity-cli, augment,");
989 eprintln!(
990 " claude, cline, codebuddy, codex, continue, copilot, crush, cursor, emacs, gemini,"
991 );
992 eprintln!(" hermes, jetbrains, kiro, neovim, openclaw, opencode, pi, qoder,");
993 eprintln!(" qoderwork, qwen, roo, sublime, trae, verdent, vscode, windsurf, zed");
994 std::process::exit(1);
995 }
996 }
997}
998
999pub fn install_agent_project_hooks(agent: &str, cwd: &std::path::Path) {
1000 match agent {
1001 "claude" | "claude-code" => agents::install_claude_project_hooks(cwd),
1002 "codebuddy" => agents::install_codebuddy_project_hooks(cwd),
1003 _ => {}
1004 }
1005}
1006
1007fn write_file(path: &std::path::Path, content: &str) {
1008 if std::fs::read_to_string(path).is_ok_and(|existing| existing == content) {
1011 return;
1012 }
1013 if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
1014 tracing::error!("Error writing {}: {e}", path.display());
1015 }
1016}
1017
1018fn ensure_state_dir(dir: &std::path::Path) -> bool {
1027 match crate::config_io::ensure_dir(dir) {
1028 Ok(()) => true,
1029 Err(e) => {
1030 eprintln!("lean-ctx setup: cannot prepare {}: {e}", dir.display());
1032 false
1033 }
1034 }
1035}
1036
1037fn is_inside_git_repo(path: &std::path::Path) -> bool {
1038 let mut p = path;
1039 loop {
1040 if p.join(".git").exists() {
1041 return true;
1042 }
1043 match p.parent() {
1044 Some(parent) => p = parent,
1045 None => return false,
1046 }
1047 }
1048}
1049
1050#[cfg(unix)]
1051fn make_executable(path: &PathBuf) {
1052 use std::os::unix::fs::PermissionsExt;
1053 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1054}
1055
1056#[cfg(not(unix))]
1057fn make_executable(_path: &PathBuf) {}
1058
1059pub(crate) fn mcp_server_env_pairs() -> Vec<(String, String)> {
1072 let mut pairs = Vec::new();
1073
1074 let cfg = crate::core::config::Config::load();
1075
1076 let project_root = std::env::var("LEAN_CTX_PROJECT_ROOT")
1077 .ok()
1078 .filter(|v| !v.trim().is_empty())
1079 .or_else(|| cfg.project_root.clone().filter(|v| !v.trim().is_empty()));
1080 if let Some(root) = project_root {
1081 pairs.push(("LEAN_CTX_PROJECT_ROOT".to_string(), root));
1082 }
1083
1084 let extra_roots = std::env::var("LEAN_CTX_EXTRA_ROOTS")
1087 .ok()
1088 .filter(|v| !v.trim().is_empty())
1089 .or_else(|| {
1090 let roots: Vec<&str> = cfg
1091 .extra_roots
1092 .iter()
1093 .map(String::as_str)
1094 .filter(|s| !s.trim().is_empty())
1095 .collect();
1096 if roots.is_empty() {
1097 return None;
1098 }
1099 std::env::join_paths(roots)
1100 .ok()
1101 .map(|s| s.to_string_lossy().to_string())
1102 });
1103 if let Some(extra) = extra_roots {
1104 pairs.push(("LEAN_CTX_EXTRA_ROOTS".to_string(), extra));
1105 }
1106
1107 pairs
1108}
1109
1110pub(crate) fn mcp_server_env_json() -> serde_json::Value {
1112 let map: serde_json::Map<String, serde_json::Value> = mcp_server_env_pairs()
1113 .into_iter()
1114 .map(|(k, v)| (k, serde_json::Value::String(v)))
1115 .collect();
1116 serde_json::Value::Object(map)
1117}
1118
1119fn full_server_entry(binary: &str) -> serde_json::Value {
1120 serde_json::json!({
1126 "command": binary,
1127 "env": mcp_server_env_json()
1128 })
1129}
1130
1131pub(crate) fn install_mcp_json_agent(
1132 name: &str,
1133 display_path: &str,
1134 config_path: &std::path::Path,
1135) {
1136 let binary = resolve_binary_path();
1137 let entry = full_server_entry(&binary);
1138 install_named_json_server(name, display_path, config_path, "mcpServers", entry);
1139}
1140
1141#[cfg(test)]
1142mod tests;