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