1use std::path::PathBuf;
2
3fn mcp_server_quiet_mode() -> bool {
4 std::env::var_os("LEAN_CTX_MCP_SERVER").is_some()
5}
6
7pub fn refresh_installed_hooks() {
10 let home = match dirs::home_dir() {
11 Some(h) => h,
12 None => return,
13 };
14
15 let claude_dir = crate::setup::claude_config_dir(&home);
16 let claude_hooks = claude_dir.join("hooks/lean-ctx-rewrite.sh").exists()
17 || claude_dir.join("settings.json").exists()
18 && std::fs::read_to_string(claude_dir.join("settings.json"))
19 .unwrap_or_default()
20 .contains("lean-ctx");
21
22 if claude_hooks {
23 install_claude_hook_scripts(&home);
24 install_claude_hook_config(&home);
25 }
26
27 let cursor_hooks = home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
28 || home.join(".cursor/hooks.json").exists()
29 && std::fs::read_to_string(home.join(".cursor/hooks.json"))
30 .unwrap_or_default()
31 .contains("lean-ctx");
32
33 if cursor_hooks {
34 install_cursor_hook_scripts(&home);
35 install_cursor_hook_config(&home);
36 }
37
38 let gemini_rewrite = home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh");
39 let gemini_legacy = home.join(".gemini/hooks/lean-ctx-hook-gemini.sh");
40 if gemini_rewrite.exists() || gemini_legacy.exists() {
41 install_gemini_hook_scripts(&home);
42 install_gemini_hook_config(&home);
43 }
44
45 if home.join(".codex/hooks/lean-ctx-rewrite-codex.sh").exists() {
46 install_codex_hook_scripts(&home);
47 }
48}
49
50fn resolve_binary_path() -> String {
51 if is_lean_ctx_in_path() {
52 return "lean-ctx".to_string();
53 }
54 std::env::current_exe()
55 .map(|p| p.to_string_lossy().to_string())
56 .unwrap_or_else(|_| "lean-ctx".to_string())
57}
58
59fn is_lean_ctx_in_path() -> bool {
60 let which_cmd = if cfg!(windows) { "where" } else { "which" };
61 std::process::Command::new(which_cmd)
62 .arg("lean-ctx")
63 .stdout(std::process::Stdio::null())
64 .stderr(std::process::Stdio::null())
65 .status()
66 .map(|s| s.success())
67 .unwrap_or(false)
68}
69
70fn resolve_binary_path_for_bash() -> String {
71 let path = resolve_binary_path();
72 to_bash_compatible_path(&path)
73}
74
75pub fn to_bash_compatible_path(path: &str) -> String {
76 let path = path.replace('\\', "/");
77 if path.len() >= 2 && path.as_bytes()[1] == b':' {
78 let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
79 format!("/{drive}{}", &path[2..])
80 } else {
81 path
82 }
83}
84
85pub fn normalize_tool_path(path: &str) -> String {
89 let mut p = path.to_string();
90
91 if p.len() >= 3
93 && p.starts_with('/')
94 && p.as_bytes()[1].is_ascii_alphabetic()
95 && p.as_bytes()[2] == b'/'
96 {
97 let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
98 p = format!("{drive}:{}", &p[2..]);
99 }
100
101 p = p.replace('\\', "/");
102
103 while p.contains("//") && !p.starts_with("//") {
105 p = p.replace("//", "/");
106 }
107
108 if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
110 p.pop();
111 }
112
113 p
114}
115
116pub fn generate_rewrite_script(binary: &str) -> String {
117 let case_pattern = crate::rewrite_registry::bash_case_pattern();
118 format!(
119 r#"#!/usr/bin/env bash
120# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
121set -euo pipefail
122
123LEAN_CTX_BIN="{binary}"
124
125INPUT=$(cat)
126TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
127
128if [ "$TOOL" != "Bash" ] && [ "$TOOL" != "bash" ]; then
129 exit 0
130fi
131
132CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
133
134if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
135 exit 0
136fi
137
138case "$CMD" in
139 {case_pattern})
140 # Shell-escape then JSON-escape (two passes)
141 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
142 REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
143 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
144 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
145 ;;
146 *) exit 0 ;;
147esac
148"#
149 )
150}
151
152pub fn generate_compact_rewrite_script(binary: &str) -> String {
153 let case_pattern = crate::rewrite_registry::bash_case_pattern();
154 format!(
155 r#"#!/usr/bin/env bash
156# lean-ctx hook — rewrites shell commands
157set -euo pipefail
158LEAN_CTX_BIN="{binary}"
159INPUT=$(cat)
160CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
161if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
162case "$CMD" in
163 {case_pattern})
164 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
165 REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
166 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
167 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
168 *) exit 0 ;;
169esac
170"#
171 )
172}
173
174const REDIRECT_SCRIPT_CLAUDE: &str = r#"#!/usr/bin/env bash
175# lean-ctx PreToolUse hook — all native tools pass through
176# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
177# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
178exit 0
179"#;
180
181const REDIRECT_SCRIPT_GENERIC: &str = r#"#!/usr/bin/env bash
182# lean-ctx hook — all native tools pass through
183exit 0
184"#;
185
186pub fn install_project_rules() {
187 let cwd = std::env::current_dir().unwrap_or_default();
188
189 if !is_inside_git_repo(&cwd) {
190 eprintln!(
191 " Skipping project files: not inside a git repository.\n \
192 Run this command from your project root to create CLAUDE.md / AGENTS.md."
193 );
194 return;
195 }
196
197 let home = dirs::home_dir().unwrap_or_default();
198 if cwd == home {
199 eprintln!(
200 " Skipping project files: current directory is your home folder.\n \
201 Run this command from a project directory instead."
202 );
203 return;
204 }
205
206 ensure_project_agents_integration(&cwd);
207
208 let cursorrules = cwd.join(".cursorrules");
209 if !cursorrules.exists()
210 || !std::fs::read_to_string(&cursorrules)
211 .unwrap_or_default()
212 .contains("lean-ctx")
213 {
214 let content = CURSORRULES_TEMPLATE;
215 if cursorrules.exists() {
216 let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
217 if !existing.ends_with('\n') {
218 existing.push('\n');
219 }
220 existing.push('\n');
221 existing.push_str(content);
222 write_file(&cursorrules, &existing);
223 } else {
224 write_file(&cursorrules, content);
225 }
226 println!("Created/updated .cursorrules in project root.");
227 }
228
229 let kiro_dir = cwd.join(".kiro");
230 if kiro_dir.exists() {
231 let steering_dir = kiro_dir.join("steering");
232 let steering_file = steering_dir.join("lean-ctx.md");
233 if !steering_file.exists()
234 || !std::fs::read_to_string(&steering_file)
235 .unwrap_or_default()
236 .contains("lean-ctx")
237 {
238 let _ = std::fs::create_dir_all(&steering_dir);
239 write_file(&steering_file, KIRO_STEERING_TEMPLATE);
240 println!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
241 }
242 }
243}
244
245const PROJECT_LEAN_CTX_MD_MARKER: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
246const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
247const PROJECT_AGENTS_MD: &str = "AGENTS.md";
248const AGENTS_BLOCK_START: &str = "<!-- lean-ctx -->";
249const AGENTS_BLOCK_END: &str = "<!-- /lean-ctx -->";
250
251fn ensure_project_agents_integration(cwd: &std::path::Path) {
252 let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
253 let desired = format!(
254 "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
255 crate::rules_inject::rules_dedicated_markdown()
256 );
257
258 if !lean_ctx_md.exists() {
259 write_file(&lean_ctx_md, &desired);
260 } else if std::fs::read_to_string(&lean_ctx_md)
261 .unwrap_or_default()
262 .contains(PROJECT_LEAN_CTX_MD_MARKER)
263 {
264 let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
265 if !current.contains(crate::rules_inject::RULES_VERSION_STR) {
266 write_file(&lean_ctx_md, &desired);
267 }
268 }
269
270 let block = format!(
271 "{AGENTS_BLOCK_START}\n\
272## lean-ctx\n\n\
273Prefer lean-ctx MCP tools over native equivalents for token savings.\n\
274Full rules: @{PROJECT_LEAN_CTX_MD}\n\
275{AGENTS_BLOCK_END}\n"
276 );
277
278 let agents_md = cwd.join(PROJECT_AGENTS_MD);
279 if !agents_md.exists() {
280 let content = format!("# Agent Instructions\n\n{block}");
281 write_file(&agents_md, &content);
282 println!("Created AGENTS.md in project root (lean-ctx reference only).");
283 return;
284 }
285
286 let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
287 if existing.contains(AGENTS_BLOCK_START) {
288 let updated = replace_marked_block(&existing, AGENTS_BLOCK_START, AGENTS_BLOCK_END, &block);
289 if updated != existing {
290 write_file(&agents_md, &updated);
291 }
292 return;
293 }
294
295 if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
296 return;
297 }
298
299 let mut out = existing;
300 if !out.ends_with('\n') {
301 out.push('\n');
302 }
303 out.push('\n');
304 out.push_str(&block);
305 write_file(&agents_md, &out);
306 println!("Updated AGENTS.md (added lean-ctx reference block).");
307}
308
309fn replace_marked_block(content: &str, start: &str, end: &str, replacement: &str) -> String {
310 let s = content.find(start);
311 let e = content.find(end);
312 match (s, e) {
313 (Some(si), Some(ei)) if ei >= si => {
314 let after_end = ei + end.len();
315 let before = &content[..si];
316 let after = &content[after_end..];
317 let mut out = String::new();
318 out.push_str(before.trim_end_matches('\n'));
319 out.push('\n');
320 out.push('\n');
321 out.push_str(replacement.trim_end_matches('\n'));
322 out.push('\n');
323 out.push_str(after.trim_start_matches('\n'));
324 out
325 }
326 _ => content.to_string(),
327 }
328}
329
330const CURSORRULES_TEMPLATE: &str = "\
331# lean-ctx — Context Engineering Layer
332
333PREFER lean-ctx MCP tools over native equivalents for token savings:
334
335| PREFER | OVER | Why |
336|--------|------|-----|
337| `ctx_read(path)` | `Read` | Cached, 8 compression modes |
338| `ctx_shell(command)` | `Shell` | Pattern compression |
339| `ctx_search(pattern, path)` | `Grep` | Compact results |
340| `ctx_tree(path, depth)` | `ls` / `find` | Directory maps |
341| `ctx_edit(path, old_string, new_string)` | `Edit` (when Read unavailable) | Search-and-replace without native Read |
342
343Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
344Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
345";
346
347pub const KIRO_STEERING_TEMPLATE: &str = "\
348---
349inclusion: always
350---
351
352# lean-ctx — Context Engineering Layer
353
354The workspace has the `lean-ctx` MCP server installed. You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.
355
356## Mandatory Tool Preferences
357
358| Use this | Instead of | Why |
359|----------|-----------|-----|
360| `mcp_lean_ctx_ctx_read` | `readFile`, `readCode` | Cached reads, 8 compression modes, re-reads cost ~13 tokens |
361| `mcp_lean_ctx_ctx_multi_read` | `readMultipleFiles` | Batch cached reads in one call |
362| `mcp_lean_ctx_ctx_shell` | `executeBash` | Pattern compression for git/npm/test output |
363| `mcp_lean_ctx_ctx_search` | `grepSearch` | Compact, .gitignore-aware results |
364| `mcp_lean_ctx_ctx_tree` | `listDirectory` | Compact directory maps with file counts |
365
366## When to use native Kiro tools instead
367
368- `fsWrite` / `fsAppend` — always use native (lean-ctx doesn't write files)
369- `strReplace` — always use native (precise string replacement)
370- `semanticRename` / `smartRelocate` — always use native (IDE integration)
371- `getDiagnostics` — always use native (language server diagnostics)
372- `deleteFile` — always use native
373
374## Session management
375
376- At the start of a long task, call `mcp_lean_ctx_ctx_preload` with a task description to warm the cache
377- Use `mcp_lean_ctx_ctx_compress` periodically in long conversations to checkpoint context
378- Use `mcp_lean_ctx_ctx_knowledge` to persist important discoveries across sessions
379
380## Rules
381
382- NEVER loop on edit failures — switch to `mcp_lean_ctx_ctx_edit` immediately
383- For large files, use `mcp_lean_ctx_ctx_read` with `mode: \"signatures\"` or `mode: \"map\"` first
384- For re-reading a file you already read, just call `mcp_lean_ctx_ctx_read` again (cache hit = ~13 tokens)
385- When running tests or build commands, use `mcp_lean_ctx_ctx_shell` for compressed output
386";
387
388pub fn install_agent_hook(agent: &str, global: bool) {
389 match agent {
390 "claude" | "claude-code" => install_claude_hook(global),
391 "cursor" => install_cursor_hook(global),
392 "gemini" | "antigravity" => install_gemini_hook(),
393 "codex" => install_codex_hook(),
394 "windsurf" => install_windsurf_rules(global),
395 "cline" | "roo" => install_cline_rules(global),
396 "copilot" => install_copilot_hook(global),
397 "pi" => install_pi_hook(global),
398 "qwen" => install_mcp_json_agent(
399 "Qwen Code",
400 "~/.qwen/mcp.json",
401 &dirs::home_dir().unwrap_or_default().join(".qwen/mcp.json"),
402 ),
403 "trae" => install_mcp_json_agent(
404 "Trae",
405 "~/.trae/mcp.json",
406 &dirs::home_dir().unwrap_or_default().join(".trae/mcp.json"),
407 ),
408 "amazonq" => install_mcp_json_agent(
409 "Amazon Q Developer",
410 "~/.aws/amazonq/mcp.json",
411 &dirs::home_dir()
412 .unwrap_or_default()
413 .join(".aws/amazonq/mcp.json"),
414 ),
415 "jetbrains" => install_jetbrains_hook(),
416 "kiro" => install_kiro_hook(),
417 "verdent" => install_mcp_json_agent(
418 "Verdent",
419 "~/.verdent/mcp.json",
420 &dirs::home_dir()
421 .unwrap_or_default()
422 .join(".verdent/mcp.json"),
423 ),
424 "opencode" => install_opencode_hook(),
425 "aider" => install_mcp_json_agent(
426 "Aider",
427 "~/.aider/mcp.json",
428 &dirs::home_dir().unwrap_or_default().join(".aider/mcp.json"),
429 ),
430 "amp" => install_amp_hook(),
431 "crush" => install_crush_hook(),
432 _ => {
433 eprintln!("Unknown agent: {agent}");
434 eprintln!(" Supported: claude, cursor, gemini, codex, windsurf, cline, roo, copilot, pi, qwen, trae, amazonq, jetbrains, kiro, verdent, opencode, aider, amp, crush, antigravity");
435 std::process::exit(1);
436 }
437 }
438}
439
440fn install_claude_hook(global: bool) {
441 let home = match dirs::home_dir() {
442 Some(h) => h,
443 None => {
444 eprintln!("Cannot resolve home directory");
445 return;
446 }
447 };
448
449 install_claude_hook_scripts(&home);
450 install_claude_hook_config(&home);
451 install_claude_rules_file(&home);
452 install_claude_global_claude_md(&home);
453 install_claude_skill(&home);
454
455 let _ = global;
456}
457
458const CLAUDE_MD_BLOCK_START: &str = "<!-- lean-ctx -->";
459const CLAUDE_MD_BLOCK_END: &str = "<!-- /lean-ctx -->";
460const CLAUDE_MD_BLOCK_CONTENT: &str = "\
461<!-- lean-ctx -->
462## lean-ctx — Context Runtime
463
464Always prefer lean-ctx MCP tools over native equivalents:
465- `ctx_read` instead of `Read` / `cat` (cached, 10 modes, re-reads ~13 tokens)
466- `ctx_shell` instead of `bash` / `Shell` (90+ compression patterns)
467- `ctx_search` instead of `Grep` / `rg` (compact results)
468- `ctx_tree` instead of `ls` / `find` (compact directory maps)
469- Native Edit/Write/StrReplace stay unchanged.
470
471Full rules: @rules/lean-ctx.md
472
473Verify setup: run `/mcp` to check lean-ctx is connected, `/memory` to confirm this file loaded.
474<!-- /lean-ctx -->";
475
476fn install_claude_global_claude_md(home: &std::path::Path) {
477 let claude_dir = crate::core::editor_registry::claude_state_dir(home);
478 let _ = std::fs::create_dir_all(&claude_dir);
479 let claude_md_path = claude_dir.join("CLAUDE.md");
480
481 let existing = std::fs::read_to_string(&claude_md_path).unwrap_or_default();
482
483 if existing.contains(CLAUDE_MD_BLOCK_START) {
484 if existing.contains(crate::rules_inject::RULES_VERSION_STR)
485 || existing.contains("@rules/lean-ctx.md")
486 {
487 return;
488 }
489 let cleaned = remove_block(&existing, CLAUDE_MD_BLOCK_START, CLAUDE_MD_BLOCK_END);
490 let updated = format!("{}\n\n{}\n", cleaned.trim(), CLAUDE_MD_BLOCK_CONTENT);
491 write_file(&claude_md_path, &updated);
492 return;
493 }
494
495 if existing.trim().is_empty() {
496 write_file(&claude_md_path, CLAUDE_MD_BLOCK_CONTENT);
497 } else {
498 let updated = format!("{}\n\n{}\n", existing.trim(), CLAUDE_MD_BLOCK_CONTENT);
499 write_file(&claude_md_path, &updated);
500 }
501}
502
503fn remove_block(content: &str, start: &str, end: &str) -> String {
504 let s = content.find(start);
505 let e = content.find(end);
506 match (s, e) {
507 (Some(si), Some(ei)) if ei >= si => {
508 let after_end = ei + end.len();
509 let before = content[..si].trim_end_matches('\n');
510 let after = &content[after_end..];
511 let mut out = before.to_string();
512 out.push('\n');
513 if !after.trim().is_empty() {
514 out.push('\n');
515 out.push_str(after.trim_start_matches('\n'));
516 }
517 out
518 }
519 _ => content.to_string(),
520 }
521}
522
523fn install_claude_skill(home: &std::path::Path) {
524 let skill_dir = home.join(".claude/skills/lean-ctx");
525 let _ = std::fs::create_dir_all(skill_dir.join("scripts"));
526
527 let skill_md = include_str!("../skills/lean-ctx/SKILL.md");
528 let install_sh = include_str!("../skills/lean-ctx/scripts/install.sh");
529
530 let skill_path = skill_dir.join("SKILL.md");
531 let script_path = skill_dir.join("scripts/install.sh");
532
533 write_file(&skill_path, skill_md);
534 write_file(&script_path, install_sh);
535
536 #[cfg(unix)]
537 {
538 use std::os::unix::fs::PermissionsExt;
539 if let Ok(mut perms) = std::fs::metadata(&script_path).map(|m| m.permissions()) {
540 perms.set_mode(0o755);
541 let _ = std::fs::set_permissions(&script_path, perms);
542 }
543 }
544}
545
546fn install_claude_rules_file(home: &std::path::Path) {
547 let rules_dir = crate::core::editor_registry::claude_rules_dir(home);
548 let _ = std::fs::create_dir_all(&rules_dir);
549 let rules_path = rules_dir.join("lean-ctx.md");
550
551 let desired = crate::rules_inject::rules_dedicated_markdown();
552 let existing = std::fs::read_to_string(&rules_path).unwrap_or_default();
553
554 if existing.is_empty() {
555 write_file(&rules_path, desired);
556 return;
557 }
558 if existing.contains(crate::rules_inject::RULES_VERSION_STR) {
559 return;
560 }
561 if existing.contains("<!-- lean-ctx-rules-") {
562 write_file(&rules_path, desired);
563 }
564}
565
566fn install_claude_hook_scripts(home: &std::path::Path) {
567 let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
568 let _ = std::fs::create_dir_all(&hooks_dir);
569
570 let binary = resolve_binary_path();
571
572 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
573 let rewrite_script = generate_rewrite_script(&resolve_binary_path_for_bash());
574 write_file(&rewrite_path, &rewrite_script);
575 make_executable(&rewrite_path);
576
577 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
578 write_file(&redirect_path, REDIRECT_SCRIPT_CLAUDE);
579 make_executable(&redirect_path);
580
581 let wrapper = |subcommand: &str| -> String {
582 if cfg!(windows) {
583 format!("{binary} hook {subcommand}")
584 } else {
585 format!("{} hook {subcommand}", resolve_binary_path_for_bash())
586 }
587 };
588
589 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
590 write_file(
591 &rewrite_native,
592 &format!(
593 "#!/bin/sh\nexec {} hook rewrite\n",
594 resolve_binary_path_for_bash()
595 ),
596 );
597 make_executable(&rewrite_native);
598
599 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
600 write_file(
601 &redirect_native,
602 &format!(
603 "#!/bin/sh\nexec {} hook redirect\n",
604 resolve_binary_path_for_bash()
605 ),
606 );
607 make_executable(&redirect_native);
608
609 let _ = wrapper; }
611
612fn install_claude_hook_config(home: &std::path::Path) {
613 let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
614 let binary = resolve_binary_path();
615
616 let rewrite_cmd = format!("{binary} hook rewrite");
617 let redirect_cmd = format!("{binary} hook redirect");
618
619 let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
620 let settings_content = if settings_path.exists() {
621 std::fs::read_to_string(&settings_path).unwrap_or_default()
622 } else {
623 String::new()
624 };
625
626 let needs_update =
627 !settings_content.contains("hook rewrite") || !settings_content.contains("hook redirect");
628 let has_old_hooks = settings_content.contains("lean-ctx-rewrite.sh")
629 || settings_content.contains("lean-ctx-redirect.sh");
630
631 if !needs_update && !has_old_hooks {
632 return;
633 }
634
635 let hook_entry = serde_json::json!({
636 "hooks": {
637 "PreToolUse": [
638 {
639 "matcher": "Bash|bash",
640 "hooks": [{
641 "type": "command",
642 "command": rewrite_cmd
643 }]
644 },
645 {
646 "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
647 "hooks": [{
648 "type": "command",
649 "command": redirect_cmd
650 }]
651 }
652 ]
653 }
654 });
655
656 if settings_content.is_empty() {
657 write_file(
658 &settings_path,
659 &serde_json::to_string_pretty(&hook_entry).unwrap(),
660 );
661 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
662 if let Some(obj) = existing.as_object_mut() {
663 obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
664 write_file(
665 &settings_path,
666 &serde_json::to_string_pretty(&existing).unwrap(),
667 );
668 }
669 }
670 if !mcp_server_quiet_mode() {
671 println!("Installed Claude Code hooks at {}", hooks_dir.display());
672 }
673}
674
675fn install_cursor_hook(global: bool) {
676 let home = match dirs::home_dir() {
677 Some(h) => h,
678 None => {
679 eprintln!("Cannot resolve home directory");
680 return;
681 }
682 };
683
684 install_cursor_hook_scripts(&home);
685 install_cursor_hook_config(&home);
686
687 if !global {
688 let rules_dir = PathBuf::from(".cursor").join("rules");
689 let _ = std::fs::create_dir_all(&rules_dir);
690 let rule_path = rules_dir.join("lean-ctx.mdc");
691 if !rule_path.exists() {
692 let rule_content = include_str!("templates/lean-ctx.mdc");
693 write_file(&rule_path, rule_content);
694 println!("Created .cursor/rules/lean-ctx.mdc in current project.");
695 } else {
696 println!("Cursor rule already exists.");
697 }
698 } else {
699 println!("Global mode: skipping project-local .cursor/rules/ (use without --global in a project).");
700 }
701
702 println!("Restart Cursor to activate.");
703}
704
705fn install_cursor_hook_scripts(home: &std::path::Path) {
706 let hooks_dir = home.join(".cursor").join("hooks");
707 let _ = std::fs::create_dir_all(&hooks_dir);
708
709 let binary = resolve_binary_path_for_bash();
710
711 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
712 let rewrite_script = generate_compact_rewrite_script(&binary);
713 write_file(&rewrite_path, &rewrite_script);
714 make_executable(&rewrite_path);
715
716 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
717 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
718 make_executable(&redirect_path);
719
720 let native_binary = resolve_binary_path();
721 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
722 write_file(
723 &rewrite_native,
724 &format!("#!/bin/sh\nexec {} hook rewrite\n", native_binary),
725 );
726 make_executable(&rewrite_native);
727
728 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
729 write_file(
730 &redirect_native,
731 &format!("#!/bin/sh\nexec {} hook redirect\n", native_binary),
732 );
733 make_executable(&redirect_native);
734}
735
736fn install_cursor_hook_config(home: &std::path::Path) {
737 let binary = resolve_binary_path();
738 let rewrite_cmd = format!("{binary} hook rewrite");
739 let redirect_cmd = format!("{binary} hook redirect");
740
741 let hooks_json = home.join(".cursor").join("hooks.json");
742
743 let hook_config = serde_json::json!({
744 "version": 1,
745 "hooks": {
746 "preToolUse": [
747 {
748 "matcher": "terminal_command",
749 "command": rewrite_cmd
750 },
751 {
752 "matcher": "read_file|grep|search|list_files|list_directory",
753 "command": redirect_cmd
754 }
755 ]
756 }
757 });
758
759 let content = if hooks_json.exists() {
760 std::fs::read_to_string(&hooks_json).unwrap_or_default()
761 } else {
762 String::new()
763 };
764
765 let has_correct_format = content.contains("\"version\"") && content.contains("\"preToolUse\"");
766 if has_correct_format && content.contains("hook rewrite") && content.contains("hook redirect") {
767 return;
768 }
769
770 if content.is_empty() || !content.contains("\"version\"") {
771 write_file(
772 &hooks_json,
773 &serde_json::to_string_pretty(&hook_config).unwrap(),
774 );
775 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&content) {
776 if let Some(obj) = existing.as_object_mut() {
777 obj.insert("version".to_string(), serde_json::json!(1));
778 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
779 write_file(
780 &hooks_json,
781 &serde_json::to_string_pretty(&existing).unwrap(),
782 );
783 }
784 } else {
785 write_file(
786 &hooks_json,
787 &serde_json::to_string_pretty(&hook_config).unwrap(),
788 );
789 }
790
791 if !mcp_server_quiet_mode() {
792 println!("Installed Cursor hooks at {}", hooks_json.display());
793 }
794}
795
796fn install_gemini_hook() {
797 let home = match dirs::home_dir() {
798 Some(h) => h,
799 None => {
800 eprintln!("Cannot resolve home directory");
801 return;
802 }
803 };
804
805 install_gemini_hook_scripts(&home);
806 install_gemini_hook_config(&home);
807}
808
809fn install_gemini_hook_scripts(home: &std::path::Path) {
810 let hooks_dir = home.join(".gemini").join("hooks");
811 let _ = std::fs::create_dir_all(&hooks_dir);
812
813 let binary = resolve_binary_path_for_bash();
814
815 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
816 let rewrite_script = generate_compact_rewrite_script(&binary);
817 write_file(&rewrite_path, &rewrite_script);
818 make_executable(&rewrite_path);
819
820 let redirect_path = hooks_dir.join("lean-ctx-redirect-gemini.sh");
821 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
822 make_executable(&redirect_path);
823}
824
825fn install_gemini_hook_config(home: &std::path::Path) {
826 let binary = resolve_binary_path();
827 let rewrite_cmd = format!("{binary} hook rewrite");
828 let redirect_cmd = format!("{binary} hook redirect");
829
830 let settings_path = home.join(".gemini").join("settings.json");
831 let settings_content = if settings_path.exists() {
832 std::fs::read_to_string(&settings_path).unwrap_or_default()
833 } else {
834 String::new()
835 };
836
837 let has_new_format = settings_content.contains("hook rewrite")
838 && settings_content.contains("hook redirect")
839 && settings_content.contains("\"type\"");
840 let has_old_hooks = settings_content.contains("lean-ctx-rewrite")
841 || settings_content.contains("lean-ctx-redirect")
842 || (settings_content.contains("hook rewrite") && !settings_content.contains("\"type\""));
843
844 if has_new_format && !has_old_hooks {
845 return;
846 }
847
848 let hook_config = serde_json::json!({
849 "hooks": {
850 "BeforeTool": [
851 {
852 "hooks": [{
853 "type": "command",
854 "command": rewrite_cmd
855 }]
856 },
857 {
858 "hooks": [{
859 "type": "command",
860 "command": redirect_cmd
861 }]
862 }
863 ]
864 }
865 });
866
867 if settings_content.is_empty() {
868 write_file(
869 &settings_path,
870 &serde_json::to_string_pretty(&hook_config).unwrap(),
871 );
872 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
873 if let Some(obj) = existing.as_object_mut() {
874 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
875 write_file(
876 &settings_path,
877 &serde_json::to_string_pretty(&existing).unwrap(),
878 );
879 }
880 }
881 if !mcp_server_quiet_mode() {
882 println!(
883 "Installed Gemini CLI hooks at {}",
884 settings_path.parent().unwrap_or(&settings_path).display()
885 );
886 }
887}
888
889fn install_codex_hook() {
890 let home = match dirs::home_dir() {
891 Some(h) => h,
892 None => {
893 eprintln!("Cannot resolve home directory");
894 return;
895 }
896 };
897
898 let codex_dir = home.join(".codex");
899 let _ = std::fs::create_dir_all(&codex_dir);
900
901 install_codex_hook_scripts(&home);
902
903 let agents_path = codex_dir.join("AGENTS.md");
904 let agents_content = "# Global Agent Instructions\n\n@LEAN-CTX.md\n";
905
906 let lean_ctx_md = codex_dir.join("LEAN-CTX.md");
907 let binary = resolve_binary_path();
908 let lean_ctx_content = format!(
909 r#"# lean-ctx — Token Optimization
910
911Prefix all shell commands with `{binary} -c` for compressed output:
912
913```bash
914{binary} -c git status # instead of: git status
915{binary} -c cargo test # instead of: cargo test
916{binary} -c ls src/ # instead of: ls src/
917```
918
919This saves 60-90% tokens per command. Works with: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more commands.
920Use `{binary} -c --raw <cmd>` to skip compression and get full output.
921"#
922 );
923
924 if agents_path.exists() {
925 let content = std::fs::read_to_string(&agents_path).unwrap_or_default();
926 if content.contains("lean-ctx") || content.contains("LEAN-CTX") {
927 println!("Codex AGENTS.md already configured.");
928 return;
929 }
930 }
931
932 write_file(&agents_path, agents_content);
933 write_file(&lean_ctx_md, &lean_ctx_content);
934 println!("Installed Codex instructions at {}", codex_dir.display());
935}
936
937fn install_codex_hook_scripts(home: &std::path::Path) {
938 let hooks_dir = home.join(".codex").join("hooks");
939 let _ = std::fs::create_dir_all(&hooks_dir);
940
941 let binary = resolve_binary_path_for_bash();
942 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-codex.sh");
943 let rewrite_script = generate_compact_rewrite_script(&binary);
944 write_file(&rewrite_path, &rewrite_script);
945 make_executable(&rewrite_path);
946 if !mcp_server_quiet_mode() {
947 println!(
948 " \x1b[32m✓\x1b[0m Installed Codex hook scripts at {}",
949 hooks_dir.display()
950 );
951 }
952}
953
954fn install_windsurf_rules(global: bool) {
955 if global {
956 println!("Global mode: skipping project-local .windsurfrules (use without --global in a project).");
957 return;
958 }
959
960 let cwd = std::env::current_dir().unwrap_or_default();
961 if !is_inside_git_repo(&cwd) || cwd == dirs::home_dir().unwrap_or_default() {
962 eprintln!(" Skipping .windsurfrules: not inside a git repository or in home directory.");
963 return;
964 }
965
966 let rules_path = PathBuf::from(".windsurfrules");
967 if rules_path.exists() {
968 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
969 if content.contains("lean-ctx") {
970 println!(".windsurfrules already configured.");
971 return;
972 }
973 }
974
975 let rules = include_str!("templates/windsurfrules.txt");
976 write_file(&rules_path, rules);
977 println!("Installed .windsurfrules in current project.");
978}
979
980fn install_cline_rules(global: bool) {
981 if global {
982 println!(
983 "Global mode: skipping project-local .clinerules (use without --global in a project)."
984 );
985 return;
986 }
987
988 let cwd = std::env::current_dir().unwrap_or_default();
989 if !is_inside_git_repo(&cwd) || cwd == dirs::home_dir().unwrap_or_default() {
990 eprintln!(" Skipping .clinerules: not inside a git repository or in home directory.");
991 return;
992 }
993
994 let rules_path = PathBuf::from(".clinerules");
995 if rules_path.exists() {
996 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
997 if content.contains("lean-ctx") {
998 println!(".clinerules already configured.");
999 return;
1000 }
1001 }
1002
1003 let binary = resolve_binary_path();
1004 let rules = format!(
1005 r#"# lean-ctx Shell Optimization
1006# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
1007
1008When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
1009- `{binary} -c git status` instead of `git status`
1010- `{binary} -c cargo test` instead of `cargo test`
1011- `{binary} -c ls src/` instead of `ls src/`
1012
1013Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
1014"#
1015 );
1016
1017 write_file(&rules_path, &rules);
1018 println!("Installed .clinerules in current project.");
1019}
1020
1021fn install_pi_hook(global: bool) {
1022 let has_pi = std::process::Command::new("pi")
1023 .arg("--version")
1024 .output()
1025 .is_ok();
1026
1027 if !has_pi {
1028 println!("Pi Coding Agent not found in PATH.");
1029 println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
1030 println!();
1031 }
1032
1033 println!("Installing pi-lean-ctx Pi Package...");
1034 println!();
1035
1036 let install_result = std::process::Command::new("pi")
1037 .args(["install", "npm:pi-lean-ctx"])
1038 .status();
1039
1040 match install_result {
1041 Ok(status) if status.success() => {
1042 println!("Installed pi-lean-ctx Pi Package.");
1043 }
1044 _ => {
1045 println!("Could not auto-install pi-lean-ctx. Install manually:");
1046 println!(" pi install npm:pi-lean-ctx");
1047 println!();
1048 }
1049 }
1050
1051 write_pi_mcp_config();
1052
1053 if !global {
1054 let agents_md = PathBuf::from("AGENTS.md");
1055 if !agents_md.exists()
1056 || !std::fs::read_to_string(&agents_md)
1057 .unwrap_or_default()
1058 .contains("lean-ctx")
1059 {
1060 let content = include_str!("templates/PI_AGENTS.md");
1061 write_file(&agents_md, content);
1062 println!("Created AGENTS.md in current project directory.");
1063 } else {
1064 println!("AGENTS.md already contains lean-ctx configuration.");
1065 }
1066 } else {
1067 println!(
1068 "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
1069 );
1070 }
1071
1072 println!();
1073 println!("Setup complete. All Pi tools (bash, read, grep, find, ls) route through lean-ctx.");
1074 println!("MCP tools (ctx_session, ctx_knowledge, ctx_semantic_search, ...) also available.");
1075 println!("Use /lean-ctx in Pi to verify the binary path and MCP status.");
1076}
1077
1078fn write_pi_mcp_config() {
1079 let home = match dirs::home_dir() {
1080 Some(h) => h,
1081 None => return,
1082 };
1083
1084 let mcp_config_path = home.join(".pi/agent/mcp.json");
1085
1086 if !home.join(".pi/agent").exists() {
1087 println!(" \x1b[2m○ ~/.pi/agent/ not found — skipping MCP config\x1b[0m");
1088 return;
1089 }
1090
1091 if mcp_config_path.exists() {
1092 let content = match std::fs::read_to_string(&mcp_config_path) {
1093 Ok(c) => c,
1094 Err(_) => return,
1095 };
1096 if content.contains("lean-ctx") {
1097 println!(" \x1b[32m✓\x1b[0m Pi MCP config already contains lean-ctx");
1098 return;
1099 }
1100
1101 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1102 if let Some(obj) = json.as_object_mut() {
1103 let servers = obj
1104 .entry("mcpServers")
1105 .or_insert_with(|| serde_json::json!({}));
1106 if let Some(servers_obj) = servers.as_object_mut() {
1107 servers_obj.insert("lean-ctx".to_string(), pi_mcp_server_entry());
1108 }
1109 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1110 let _ = std::fs::write(&mcp_config_path, formatted);
1111 println!(
1112 " \x1b[32m✓\x1b[0m Added lean-ctx to Pi MCP config (~/.pi/agent/mcp.json)"
1113 );
1114 }
1115 }
1116 }
1117 return;
1118 }
1119
1120 let content = serde_json::json!({
1121 "mcpServers": {
1122 "lean-ctx": pi_mcp_server_entry()
1123 }
1124 });
1125 if let Ok(formatted) = serde_json::to_string_pretty(&content) {
1126 let _ = std::fs::write(&mcp_config_path, formatted);
1127 println!(" \x1b[32m✓\x1b[0m Created Pi MCP config (~/.pi/agent/mcp.json)");
1128 }
1129}
1130
1131fn pi_mcp_server_entry() -> serde_json::Value {
1132 let binary = resolve_binary_path();
1133 let mut entry = full_server_entry(&binary);
1134 if let Some(obj) = entry.as_object_mut() {
1135 obj.insert("lifecycle".to_string(), serde_json::json!("lazy"));
1136 obj.insert("directTools".to_string(), serde_json::json!(true));
1137 }
1138 entry
1139}
1140
1141fn install_copilot_hook(global: bool) {
1142 let binary = resolve_binary_path();
1143
1144 if global {
1145 let mcp_path = copilot_global_mcp_path();
1146 if mcp_path.as_os_str() == "/nonexistent" {
1147 println!(" \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
1148 return;
1149 }
1150 write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
1151 install_copilot_pretooluse_hook(true);
1152 } else {
1153 let vscode_dir = PathBuf::from(".vscode");
1154 let _ = std::fs::create_dir_all(&vscode_dir);
1155 let mcp_path = vscode_dir.join("mcp.json");
1156 write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
1157 install_copilot_pretooluse_hook(false);
1158 }
1159}
1160
1161fn install_copilot_pretooluse_hook(global: bool) {
1162 let binary = resolve_binary_path();
1163 let rewrite_cmd = format!("{binary} hook rewrite");
1164 let redirect_cmd = format!("{binary} hook redirect");
1165
1166 let hook_config = serde_json::json!({
1167 "hooks": {
1168 "PreToolUse": [
1169 {
1170 "type": "command",
1171 "command": rewrite_cmd,
1172 "timeout": 15
1173 },
1174 {
1175 "type": "command",
1176 "command": redirect_cmd,
1177 "timeout": 5
1178 }
1179 ]
1180 }
1181 });
1182
1183 if global {
1184 if let Some(home) = dirs::home_dir() {
1185 let copilot_hooks_dir = home.join(".copilot").join("hooks");
1186 let _ = std::fs::create_dir_all(&copilot_hooks_dir);
1187 let hook_path = copilot_hooks_dir.join("lean-ctx.json");
1188 write_file(
1189 &hook_path,
1190 &serde_json::to_string_pretty(&hook_config).unwrap(),
1191 );
1192 if !mcp_server_quiet_mode() {
1193 println!(
1194 "Installed Copilot PreToolUse hooks at {}",
1195 hook_path.display()
1196 );
1197 }
1198 }
1199 } else {
1200 let hooks_dir = PathBuf::from(".github").join("hooks");
1201 let _ = std::fs::create_dir_all(&hooks_dir);
1202 let hook_path = hooks_dir.join("lean-ctx.json");
1203 write_file(
1204 &hook_path,
1205 &serde_json::to_string_pretty(&hook_config).unwrap(),
1206 );
1207 if !mcp_server_quiet_mode() {
1208 println!(
1209 "Installed Copilot PreToolUse hooks at {}",
1210 hook_path.display()
1211 );
1212 }
1213 }
1214}
1215
1216fn copilot_global_mcp_path() -> PathBuf {
1217 if let Some(home) = dirs::home_dir() {
1218 #[cfg(target_os = "macos")]
1219 {
1220 return home.join("Library/Application Support/Code/User/mcp.json");
1221 }
1222 #[cfg(target_os = "linux")]
1223 {
1224 return home.join(".config/Code/User/mcp.json");
1225 }
1226 #[cfg(target_os = "windows")]
1227 {
1228 if let Ok(appdata) = std::env::var("APPDATA") {
1229 return PathBuf::from(appdata).join("Code/User/mcp.json");
1230 }
1231 }
1232 #[allow(unreachable_code)]
1233 home.join(".config/Code/User/mcp.json")
1234 } else {
1235 PathBuf::from("/nonexistent")
1236 }
1237}
1238
1239fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
1240 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1241 .map(|d| d.to_string_lossy().to_string())
1242 .unwrap_or_default();
1243 let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
1244 if mcp_path.exists() {
1245 let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
1246 match serde_json::from_str::<serde_json::Value>(&content) {
1247 Ok(mut json) => {
1248 if let Some(obj) = json.as_object_mut() {
1249 let servers = obj
1250 .entry("servers")
1251 .or_insert_with(|| serde_json::json!({}));
1252 if let Some(servers_obj) = servers.as_object_mut() {
1253 if servers_obj.get("lean-ctx") == Some(&desired) {
1254 println!(" \x1b[32m✓\x1b[0m Copilot already configured in {label}");
1255 return;
1256 }
1257 servers_obj.insert("lean-ctx".to_string(), desired);
1258 }
1259 write_file(
1260 mcp_path,
1261 &serde_json::to_string_pretty(&json).unwrap_or_default(),
1262 );
1263 println!(" \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
1264 return;
1265 }
1266 }
1267 Err(e) => {
1268 eprintln!(
1269 "Could not parse VS Code MCP config at {}: {e}\nAdd to \"servers\": \"lean-ctx\": {{ \"command\": \"{}\", \"args\": [] }}",
1270 mcp_path.display(),
1271 binary
1272 );
1273 return;
1274 }
1275 };
1276 }
1277
1278 if let Some(parent) = mcp_path.parent() {
1279 let _ = std::fs::create_dir_all(parent);
1280 }
1281
1282 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1283 .map(|d| d.to_string_lossy().to_string())
1284 .unwrap_or_default();
1285 let config = serde_json::json!({
1286 "servers": {
1287 "lean-ctx": {
1288 "type": "stdio",
1289 "command": binary,
1290 "args": [],
1291 "env": { "LEAN_CTX_DATA_DIR": data_dir }
1292 }
1293 }
1294 });
1295
1296 write_file(
1297 mcp_path,
1298 &serde_json::to_string_pretty(&config).unwrap_or_default(),
1299 );
1300 println!(" \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
1301}
1302
1303fn write_file(path: &std::path::Path, content: &str) {
1304 if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
1305 eprintln!("Error writing {}: {e}", path.display());
1306 }
1307}
1308
1309fn is_inside_git_repo(path: &std::path::Path) -> bool {
1310 let mut p = path;
1311 loop {
1312 if p.join(".git").exists() {
1313 return true;
1314 }
1315 match p.parent() {
1316 Some(parent) => p = parent,
1317 None => return false,
1318 }
1319 }
1320}
1321
1322#[cfg(unix)]
1323fn make_executable(path: &PathBuf) {
1324 use std::os::unix::fs::PermissionsExt;
1325 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1326}
1327
1328#[cfg(not(unix))]
1329fn make_executable(_path: &PathBuf) {}
1330
1331fn install_amp_hook() {
1332 let binary = resolve_binary_path();
1333 let home = dirs::home_dir().unwrap_or_default();
1334 let config_path = home.join(".config/amp/settings.json");
1335 let display_path = "~/.config/amp/settings.json";
1336
1337 if let Some(parent) = config_path.parent() {
1338 let _ = std::fs::create_dir_all(parent);
1339 }
1340
1341 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1342 .map(|d| d.to_string_lossy().to_string())
1343 .unwrap_or_default();
1344 let entry = serde_json::json!({
1345 "command": binary,
1346 "env": { "LEAN_CTX_DATA_DIR": data_dir }
1347 });
1348
1349 if config_path.exists() {
1350 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1351 if content.contains("lean-ctx") {
1352 println!("Amp MCP already configured at {display_path}");
1353 return;
1354 }
1355
1356 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1357 if let Some(obj) = json.as_object_mut() {
1358 let servers = obj
1359 .entry("amp.mcpServers")
1360 .or_insert_with(|| serde_json::json!({}));
1361 if let Some(servers_obj) = servers.as_object_mut() {
1362 servers_obj.insert("lean-ctx".to_string(), entry.clone());
1363 }
1364 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1365 let _ = std::fs::write(&config_path, formatted);
1366 println!(" \x1b[32m✓\x1b[0m Amp MCP configured at {display_path}");
1367 return;
1368 }
1369 }
1370 }
1371 }
1372
1373 let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1374 if let Ok(json_str) = serde_json::to_string_pretty(&config) {
1375 let _ = std::fs::write(&config_path, json_str);
1376 println!(" \x1b[32m✓\x1b[0m Amp MCP configured at {display_path}");
1377 } else {
1378 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure Amp");
1379 }
1380}
1381
1382fn install_jetbrains_hook() {
1383 let binary = resolve_binary_path();
1384 let home = dirs::home_dir().unwrap_or_default();
1385 let config_path = home.join(".jb-mcp.json");
1386 let display_path = "~/.jb-mcp.json";
1387
1388 let entry = serde_json::json!({
1389 "name": "lean-ctx",
1390 "command": binary,
1391 "args": [],
1392 "env": {
1393 "LEAN_CTX_DATA_DIR": crate::core::data_dir::lean_ctx_data_dir()
1394 .map(|d| d.to_string_lossy().to_string())
1395 .unwrap_or_default()
1396 }
1397 });
1398
1399 if config_path.exists() {
1400 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1401 if content.contains("lean-ctx") {
1402 println!("JetBrains MCP already configured at {display_path}");
1403 return;
1404 }
1405
1406 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1407 if let Some(obj) = json.as_object_mut() {
1408 let servers = obj
1409 .entry("servers")
1410 .or_insert_with(|| serde_json::json!([]));
1411 if let Some(arr) = servers.as_array_mut() {
1412 arr.push(entry.clone());
1413 }
1414 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1415 let _ = std::fs::write(&config_path, formatted);
1416 println!(" \x1b[32m✓\x1b[0m JetBrains MCP configured at {display_path}");
1417 return;
1418 }
1419 }
1420 }
1421 }
1422
1423 let config = serde_json::json!({ "servers": [entry] });
1424 if let Ok(json_str) = serde_json::to_string_pretty(&config) {
1425 let _ = std::fs::write(&config_path, json_str);
1426 println!(" \x1b[32m✓\x1b[0m JetBrains MCP configured at {display_path}");
1427 } else {
1428 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure JetBrains");
1429 }
1430}
1431
1432fn install_opencode_hook() {
1433 let binary = resolve_binary_path();
1434 let home = dirs::home_dir().unwrap_or_default();
1435 let config_path = home.join(".config/opencode/opencode.json");
1436 let display_path = "~/.config/opencode/opencode.json";
1437
1438 if let Some(parent) = config_path.parent() {
1439 let _ = std::fs::create_dir_all(parent);
1440 }
1441
1442 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1443 .map(|d| d.to_string_lossy().to_string())
1444 .unwrap_or_default();
1445 let desired = serde_json::json!({
1446 "type": "local",
1447 "command": [&binary],
1448 "enabled": true,
1449 "environment": { "LEAN_CTX_DATA_DIR": data_dir }
1450 });
1451
1452 if config_path.exists() {
1453 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1454 if content.contains("lean-ctx") {
1455 println!("OpenCode MCP already configured at {display_path}");
1456 } else if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1457 if let Some(obj) = json.as_object_mut() {
1458 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1459 if let Some(mcp_obj) = mcp.as_object_mut() {
1460 mcp_obj.insert("lean-ctx".to_string(), desired.clone());
1461 }
1462 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1463 let _ = std::fs::write(&config_path, formatted);
1464 println!(" \x1b[32m✓\x1b[0m OpenCode MCP configured at {display_path}");
1465 }
1466 }
1467 }
1468 } else {
1469 let content = serde_json::to_string_pretty(&serde_json::json!({
1470 "$schema": "https://opencode.ai/config.json",
1471 "mcp": {
1472 "lean-ctx": desired
1473 }
1474 }));
1475
1476 if let Ok(json_str) = content {
1477 let _ = std::fs::write(&config_path, json_str);
1478 println!(" \x1b[32m✓\x1b[0m OpenCode MCP configured at {display_path}");
1479 } else {
1480 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure OpenCode");
1481 }
1482 }
1483
1484 install_opencode_plugin(&home);
1485}
1486
1487fn install_opencode_plugin(home: &std::path::Path) {
1488 let plugin_dir = home.join(".config/opencode/plugins");
1489 let _ = std::fs::create_dir_all(&plugin_dir);
1490 let plugin_path = plugin_dir.join("lean-ctx.ts");
1491
1492 let plugin_content = include_str!("templates/opencode-plugin.ts");
1493 let _ = std::fs::write(&plugin_path, plugin_content);
1494
1495 if !mcp_server_quiet_mode() {
1496 println!(
1497 " \x1b[32m✓\x1b[0m OpenCode plugin installed at {}",
1498 plugin_path.display()
1499 );
1500 }
1501}
1502
1503fn install_crush_hook() {
1504 let binary = resolve_binary_path();
1505 let home = dirs::home_dir().unwrap_or_default();
1506 let config_path = home.join(".config/crush/crush.json");
1507 let display_path = "~/.config/crush/crush.json";
1508
1509 if let Some(parent) = config_path.parent() {
1510 let _ = std::fs::create_dir_all(parent);
1511 }
1512
1513 if config_path.exists() {
1514 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1515 if content.contains("lean-ctx") {
1516 println!("Crush MCP already configured at {display_path}");
1517 return;
1518 }
1519
1520 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1521 if let Some(obj) = json.as_object_mut() {
1522 let servers = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1523 if let Some(servers_obj) = servers.as_object_mut() {
1524 servers_obj.insert(
1525 "lean-ctx".to_string(),
1526 serde_json::json!({ "type": "stdio", "command": binary }),
1527 );
1528 }
1529 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1530 let _ = std::fs::write(&config_path, formatted);
1531 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1532 return;
1533 }
1534 }
1535 }
1536 }
1537
1538 let content = serde_json::to_string_pretty(&serde_json::json!({
1539 "mcp": {
1540 "lean-ctx": {
1541 "type": "stdio",
1542 "command": binary
1543 }
1544 }
1545 }));
1546
1547 if let Ok(json_str) = content {
1548 let _ = std::fs::write(&config_path, json_str);
1549 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1550 } else {
1551 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure Crush");
1552 }
1553}
1554
1555fn install_kiro_hook() {
1556 let home = dirs::home_dir().unwrap_or_default();
1557
1558 install_mcp_json_agent(
1559 "AWS Kiro",
1560 "~/.kiro/settings/mcp.json",
1561 &home.join(".kiro/settings/mcp.json"),
1562 );
1563
1564 let cwd = std::env::current_dir().unwrap_or_default();
1565 let steering_dir = cwd.join(".kiro").join("steering");
1566 let steering_file = steering_dir.join("lean-ctx.md");
1567
1568 if steering_file.exists()
1569 && std::fs::read_to_string(&steering_file)
1570 .unwrap_or_default()
1571 .contains("lean-ctx")
1572 {
1573 println!(" Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1574 } else {
1575 let _ = std::fs::create_dir_all(&steering_dir);
1576 write_file(&steering_file, KIRO_STEERING_TEMPLATE);
1577 println!(" \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1578 }
1579}
1580
1581fn full_server_entry(binary: &str) -> serde_json::Value {
1582 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1583 .map(|d| d.to_string_lossy().to_string())
1584 .unwrap_or_default();
1585 let auto_approve = crate::core::editor_registry::auto_approve_tools();
1586 serde_json::json!({
1587 "command": binary,
1588 "env": { "LEAN_CTX_DATA_DIR": data_dir },
1589 "autoApprove": auto_approve
1590 })
1591}
1592
1593fn install_mcp_json_agent(name: &str, display_path: &str, config_path: &std::path::Path) {
1594 let binary = resolve_binary_path();
1595 let entry = full_server_entry(&binary);
1596
1597 if let Some(parent) = config_path.parent() {
1598 let _ = std::fs::create_dir_all(parent);
1599 }
1600
1601 if config_path.exists() {
1602 let content = std::fs::read_to_string(config_path).unwrap_or_default();
1603 if content.contains("lean-ctx") {
1604 println!("{name} MCP already configured at {display_path}");
1605 return;
1606 }
1607
1608 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1609 if let Some(obj) = json.as_object_mut() {
1610 let servers = obj
1611 .entry("mcpServers")
1612 .or_insert_with(|| serde_json::json!({}));
1613 if let Some(servers_obj) = servers.as_object_mut() {
1614 servers_obj.insert("lean-ctx".to_string(), entry.clone());
1615 }
1616 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1617 let _ = std::fs::write(config_path, formatted);
1618 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1619 return;
1620 }
1621 }
1622 }
1623 }
1624
1625 let content = serde_json::to_string_pretty(&serde_json::json!({
1626 "mcpServers": {
1627 "lean-ctx": entry
1628 }
1629 }));
1630
1631 if let Ok(json_str) = content {
1632 let _ = std::fs::write(config_path, json_str);
1633 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1634 } else {
1635 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure {name}");
1636 }
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641 use super::*;
1642
1643 #[test]
1644 fn bash_path_unix_unchanged() {
1645 assert_eq!(
1646 to_bash_compatible_path("/usr/local/bin/lean-ctx"),
1647 "/usr/local/bin/lean-ctx"
1648 );
1649 }
1650
1651 #[test]
1652 fn bash_path_home_unchanged() {
1653 assert_eq!(
1654 to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
1655 "/home/user/.cargo/bin/lean-ctx"
1656 );
1657 }
1658
1659 #[test]
1660 fn bash_path_windows_drive_converted() {
1661 assert_eq!(
1662 to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
1663 "/c/Users/Fraser/bin/lean-ctx.exe"
1664 );
1665 }
1666
1667 #[test]
1668 fn bash_path_windows_lowercase_drive() {
1669 assert_eq!(
1670 to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
1671 "/d/tools/lean-ctx.exe"
1672 );
1673 }
1674
1675 #[test]
1676 fn bash_path_windows_forward_slashes() {
1677 assert_eq!(
1678 to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
1679 "/c/Users/Fraser/bin/lean-ctx.exe"
1680 );
1681 }
1682
1683 #[test]
1684 fn bash_path_bare_name_unchanged() {
1685 assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
1686 }
1687
1688 #[test]
1689 fn normalize_msys2_path() {
1690 assert_eq!(
1691 normalize_tool_path("/c/Users/game/Downloads/project"),
1692 "C:/Users/game/Downloads/project"
1693 );
1694 }
1695
1696 #[test]
1697 fn normalize_msys2_drive_d() {
1698 assert_eq!(
1699 normalize_tool_path("/d/Projects/app/src"),
1700 "D:/Projects/app/src"
1701 );
1702 }
1703
1704 #[test]
1705 fn normalize_backslashes() {
1706 assert_eq!(
1707 normalize_tool_path("C:\\Users\\game\\project\\src"),
1708 "C:/Users/game/project/src"
1709 );
1710 }
1711
1712 #[test]
1713 fn normalize_mixed_separators() {
1714 assert_eq!(
1715 normalize_tool_path("C:\\Users/game\\project/src"),
1716 "C:/Users/game/project/src"
1717 );
1718 }
1719
1720 #[test]
1721 fn normalize_double_slashes() {
1722 assert_eq!(
1723 normalize_tool_path("/home/user//project///src"),
1724 "/home/user/project/src"
1725 );
1726 }
1727
1728 #[test]
1729 fn normalize_trailing_slash() {
1730 assert_eq!(
1731 normalize_tool_path("/home/user/project/"),
1732 "/home/user/project"
1733 );
1734 }
1735
1736 #[test]
1737 fn normalize_root_preserved() {
1738 assert_eq!(normalize_tool_path("/"), "/");
1739 }
1740
1741 #[test]
1742 fn normalize_windows_root_preserved() {
1743 assert_eq!(normalize_tool_path("C:/"), "C:/");
1744 }
1745
1746 #[test]
1747 fn normalize_unix_path_unchanged() {
1748 assert_eq!(
1749 normalize_tool_path("/home/user/project/src/main.rs"),
1750 "/home/user/project/src/main.rs"
1751 );
1752 }
1753
1754 #[test]
1755 fn normalize_relative_path_unchanged() {
1756 assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
1757 }
1758
1759 #[test]
1760 fn normalize_dot_unchanged() {
1761 assert_eq!(normalize_tool_path("."), ".");
1762 }
1763
1764 #[test]
1765 fn normalize_unc_path_preserved() {
1766 assert_eq!(
1767 normalize_tool_path("//server/share/file"),
1768 "//server/share/file"
1769 );
1770 }
1771
1772 #[test]
1773 fn cursor_hook_config_has_version_and_object_hooks() {
1774 let config = serde_json::json!({
1775 "version": 1,
1776 "hooks": {
1777 "preToolUse": [
1778 {
1779 "matcher": "terminal_command",
1780 "command": "lean-ctx hook rewrite"
1781 },
1782 {
1783 "matcher": "read_file|grep|search|list_files|list_directory",
1784 "command": "lean-ctx hook redirect"
1785 }
1786 ]
1787 }
1788 });
1789
1790 let json_str = serde_json::to_string_pretty(&config).unwrap();
1791 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1792
1793 assert_eq!(parsed["version"], 1);
1794 assert!(parsed["hooks"].is_object());
1795 assert!(parsed["hooks"]["preToolUse"].is_array());
1796 assert_eq!(parsed["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
1797 assert_eq!(
1798 parsed["hooks"]["preToolUse"][0]["matcher"],
1799 "terminal_command"
1800 );
1801 }
1802
1803 #[test]
1804 fn cursor_hook_detects_old_format_needs_migration() {
1805 let old_format = r#"{"hooks":[{"event":"preToolUse","command":"lean-ctx hook rewrite"}]}"#;
1806 let has_correct =
1807 old_format.contains("\"version\"") && old_format.contains("\"preToolUse\"");
1808 assert!(
1809 !has_correct,
1810 "Old format should be detected as needing migration"
1811 );
1812 }
1813
1814 #[test]
1815 fn gemini_hook_config_has_type_command() {
1816 let binary = "lean-ctx";
1817 let rewrite_cmd = format!("{binary} hook rewrite");
1818 let redirect_cmd = format!("{binary} hook redirect");
1819
1820 let hook_config = serde_json::json!({
1821 "hooks": {
1822 "BeforeTool": [
1823 {
1824 "hooks": [{
1825 "type": "command",
1826 "command": rewrite_cmd
1827 }]
1828 },
1829 {
1830 "hooks": [{
1831 "type": "command",
1832 "command": redirect_cmd
1833 }]
1834 }
1835 ]
1836 }
1837 });
1838
1839 let parsed = hook_config;
1840 let before_tool = parsed["hooks"]["BeforeTool"].as_array().unwrap();
1841 assert_eq!(before_tool.len(), 2);
1842
1843 let first_hook = &before_tool[0]["hooks"][0];
1844 assert_eq!(first_hook["type"], "command");
1845 assert_eq!(first_hook["command"], "lean-ctx hook rewrite");
1846
1847 let second_hook = &before_tool[1]["hooks"][0];
1848 assert_eq!(second_hook["type"], "command");
1849 assert_eq!(second_hook["command"], "lean-ctx hook redirect");
1850 }
1851
1852 #[test]
1853 fn gemini_hook_old_format_detected() {
1854 let old_format = r#"{"hooks":{"BeforeTool":[{"command":"lean-ctx hook rewrite"}]}}"#;
1855 let has_new = old_format.contains("hook rewrite")
1856 && old_format.contains("hook redirect")
1857 && old_format.contains("\"type\"");
1858 assert!(!has_new, "Missing 'type' field should trigger migration");
1859 }
1860
1861 #[test]
1862 fn rewrite_script_uses_registry_pattern() {
1863 let script = generate_rewrite_script("/usr/bin/lean-ctx");
1864 assert!(script.contains(r"git\ *"), "script missing git pattern");
1865 assert!(script.contains(r"cargo\ *"), "script missing cargo pattern");
1866 assert!(script.contains(r"npm\ *"), "script missing npm pattern");
1867 assert!(
1868 !script.contains(r"rg\ *"),
1869 "script should not contain rg pattern"
1870 );
1871 assert!(
1872 script.contains("LEAN_CTX_BIN=\"/usr/bin/lean-ctx\""),
1873 "script missing binary path"
1874 );
1875 }
1876
1877 #[test]
1878 fn compact_rewrite_script_uses_registry_pattern() {
1879 let script = generate_compact_rewrite_script("/usr/bin/lean-ctx");
1880 assert!(script.contains(r"git\ *"), "compact script missing git");
1881 assert!(script.contains(r"cargo\ *"), "compact script missing cargo");
1882 assert!(
1883 !script.contains(r"rg\ *"),
1884 "compact script should not contain rg"
1885 );
1886 }
1887
1888 #[test]
1889 fn rewrite_scripts_contain_all_registry_commands() {
1890 let script = generate_rewrite_script("lean-ctx");
1891 let compact = generate_compact_rewrite_script("lean-ctx");
1892 for entry in crate::rewrite_registry::REWRITE_COMMANDS {
1893 if entry.category == crate::rewrite_registry::Category::Search {
1894 continue;
1895 }
1896 let pattern = if entry.command.contains('-') {
1897 format!("{}*", entry.command.replace('-', r"\-"))
1898 } else {
1899 format!(r"{}\ *", entry.command)
1900 };
1901 assert!(
1902 script.contains(&pattern),
1903 "rewrite_script missing '{}' (pattern: {})",
1904 entry.command,
1905 pattern
1906 );
1907 assert!(
1908 compact.contains(&pattern),
1909 "compact_rewrite_script missing '{}' (pattern: {})",
1910 entry.command,
1911 pattern
1912 );
1913 }
1914 }
1915}