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