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