1use std::path::PathBuf;
2
3fn mcp_server_quiet_mode() -> bool {
4 std::env::var_os("LEAN_CTX_MCP_SERVER").is_some()
5}
6
7pub fn refresh_installed_hooks() {
10 let home = match dirs::home_dir() {
11 Some(h) => h,
12 None => return,
13 };
14
15 let claude_dir = crate::setup::claude_config_dir(&home);
16 let claude_hooks = claude_dir.join("hooks/lean-ctx-rewrite.sh").exists()
17 || claude_dir.join("settings.json").exists()
18 && std::fs::read_to_string(claude_dir.join("settings.json"))
19 .unwrap_or_default()
20 .contains("lean-ctx");
21
22 if claude_hooks {
23 install_claude_hook_scripts(&home);
24 install_claude_hook_config(&home);
25 }
26
27 let cursor_hooks = home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
28 || home.join(".cursor/hooks.json").exists()
29 && std::fs::read_to_string(home.join(".cursor/hooks.json"))
30 .unwrap_or_default()
31 .contains("lean-ctx");
32
33 if cursor_hooks {
34 install_cursor_hook_scripts(&home);
35 install_cursor_hook_config(&home);
36 }
37
38 let gemini_rewrite = home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh");
39 let gemini_legacy = home.join(".gemini/hooks/lean-ctx-hook-gemini.sh");
40 if gemini_rewrite.exists() || gemini_legacy.exists() {
41 install_gemini_hook_scripts(&home);
42 install_gemini_hook_config(&home);
43 }
44
45 if home.join(".codex/hooks/lean-ctx-rewrite-codex.sh").exists() {
46 install_codex_hook_scripts(&home);
47 }
48}
49
50fn resolve_binary_path() -> String {
51 if is_lean_ctx_in_path() {
52 return "lean-ctx".to_string();
53 }
54 std::env::current_exe()
55 .map(|p| p.to_string_lossy().to_string())
56 .unwrap_or_else(|_| "lean-ctx".to_string())
57}
58
59fn is_lean_ctx_in_path() -> bool {
60 let which_cmd = if cfg!(windows) { "where" } else { "which" };
61 std::process::Command::new(which_cmd)
62 .arg("lean-ctx")
63 .stdout(std::process::Stdio::null())
64 .stderr(std::process::Stdio::null())
65 .status()
66 .map(|s| s.success())
67 .unwrap_or(false)
68}
69
70fn resolve_binary_path_for_bash() -> String {
71 let path = resolve_binary_path();
72 to_bash_compatible_path(&path)
73}
74
75pub fn to_bash_compatible_path(path: &str) -> String {
76 let path = path.replace('\\', "/");
77 if path.len() >= 2 && path.as_bytes()[1] == b':' {
78 let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
79 format!("/{drive}{}", &path[2..])
80 } else {
81 path
82 }
83}
84
85pub fn normalize_tool_path(path: &str) -> String {
89 let mut p = path.to_string();
90
91 if p.len() >= 3
93 && p.starts_with('/')
94 && p.as_bytes()[1].is_ascii_alphabetic()
95 && p.as_bytes()[2] == b'/'
96 {
97 let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
98 p = format!("{drive}:{}", &p[2..]);
99 }
100
101 p = p.replace('\\', "/");
102
103 while p.contains("//") && !p.starts_with("//") {
105 p = p.replace("//", "/");
106 }
107
108 if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
110 p.pop();
111 }
112
113 p
114}
115
116pub fn generate_rewrite_script(binary: &str) -> String {
117 format!(
118 r#"#!/usr/bin/env bash
119# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
120set -euo pipefail
121
122LEAN_CTX_BIN="{binary}"
123
124INPUT=$(cat)
125TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
126
127if [ "$TOOL" != "Bash" ] && [ "$TOOL" != "bash" ]; then
128 exit 0
129fi
130
131CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
132
133if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
134 exit 0
135fi
136
137case "$CMD" in
138 git\ *|gh\ *|cargo\ *|npm\ *|pnpm\ *|yarn\ *|docker\ *|kubectl\ *|pip\ *|pip3\ *|ruff\ *|go\ *|curl\ *|grep\ *|rg\ *|find\ *|cat\ *|head\ *|tail\ *|ls\ *|ls|eslint*|prettier*|tsc*|pytest*|mypy*|aws\ *|helm\ *)
139 # Shell-escape then JSON-escape (two passes)
140 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
141 REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
142 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
143 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
144 ;;
145 *) exit 0 ;;
146esac
147"#
148 )
149}
150
151pub fn generate_compact_rewrite_script(binary: &str) -> String {
152 format!(
153 r#"#!/usr/bin/env bash
154# lean-ctx hook — rewrites shell commands
155set -euo pipefail
156LEAN_CTX_BIN="{binary}"
157INPUT=$(cat)
158CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
159if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
160case "$CMD" in
161 git\ *|gh\ *|cargo\ *|npm\ *|pnpm\ *|docker\ *|kubectl\ *|pip\ *|ruff\ *|go\ *|curl\ *|grep\ *|rg\ *|find\ *|ls\ *|ls|cat\ *|aws\ *|helm\ *)
162 SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
163 REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
164 JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
165 printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
166 *) exit 0 ;;
167esac
168"#
169 )
170}
171
172const REDIRECT_SCRIPT_CLAUDE: &str = r#"#!/usr/bin/env bash
173# lean-ctx PreToolUse hook — all native tools pass through
174# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
175# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
176exit 0
177"#;
178
179const REDIRECT_SCRIPT_GENERIC: &str = r#"#!/usr/bin/env bash
180# lean-ctx hook — all native tools pass through
181exit 0
182"#;
183
184pub fn install_project_rules() {
185 let cwd = std::env::current_dir().unwrap_or_default();
186
187 let agents_md = cwd.join("AGENTS.md");
188 if !agents_md.exists()
189 || !std::fs::read_to_string(&agents_md)
190 .unwrap_or_default()
191 .contains("lean-ctx")
192 {
193 let content = AGENTS_MD_TEMPLATE;
194 write_file(&agents_md, content);
195 println!("Created AGENTS.md in project root.");
196 }
197
198 let cursorrules = cwd.join(".cursorrules");
199 if !cursorrules.exists()
200 || !std::fs::read_to_string(&cursorrules)
201 .unwrap_or_default()
202 .contains("lean-ctx")
203 {
204 let content = CURSORRULES_TEMPLATE;
205 if cursorrules.exists() {
206 let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
207 if !existing.ends_with('\n') {
208 existing.push('\n');
209 }
210 existing.push('\n');
211 existing.push_str(content);
212 write_file(&cursorrules, &existing);
213 } else {
214 write_file(&cursorrules, content);
215 }
216 println!("Created/updated .cursorrules in project root.");
217 }
218
219 let kiro_dir = cwd.join(".kiro");
220 if kiro_dir.exists() {
221 let steering_dir = kiro_dir.join("steering");
222 let steering_file = steering_dir.join("lean-ctx.md");
223 if !steering_file.exists()
224 || !std::fs::read_to_string(&steering_file)
225 .unwrap_or_default()
226 .contains("lean-ctx")
227 {
228 let _ = std::fs::create_dir_all(&steering_dir);
229 write_file(&steering_file, KIRO_STEERING_TEMPLATE);
230 println!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
231 }
232 }
233}
234
235const AGENTS_MD_TEMPLATE: &str = "\
236# lean-ctx — Context Engineering Layer
237
238PREFER lean-ctx MCP tools over native equivalents for token savings:
239
240| PREFER | OVER | Why |
241|--------|------|-----|
242| `ctx_read(path)` | Read / cat / head / tail | Cached, 8 compression modes, re-reads ~13 tokens |
243| `ctx_shell(command)` | Shell / bash / terminal | Pattern compression for git/npm/cargo output |
244| `ctx_search(pattern, path)` | Grep / rg / search | Compact, token-efficient results |
245| `ctx_tree(path, depth)` | ls / find / tree | Compact directory maps |
246| `ctx_edit(path, old_string, new_string)` | Edit (when Read unavailable) | Search-and-replace without native Read |
247
248Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
249Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
250";
251
252const CURSORRULES_TEMPLATE: &str = "\
253# lean-ctx — Context Engineering Layer
254
255PREFER lean-ctx MCP tools over native equivalents for token savings:
256
257| PREFER | OVER | Why |
258|--------|------|-----|
259| `ctx_read(path)` | `Read` | Cached, 8 compression modes |
260| `ctx_shell(command)` | `Shell` | Pattern compression |
261| `ctx_search(pattern, path)` | `Grep` | Compact results |
262| `ctx_tree(path, depth)` | `ls` / `find` | Directory maps |
263| `ctx_edit(path, old_string, new_string)` | `Edit` (when Read unavailable) | Search-and-replace without native Read |
264
265Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
266Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
267";
268
269pub const KIRO_STEERING_TEMPLATE: &str = "\
270---
271inclusion: always
272---
273
274# lean-ctx — Context Engineering Layer
275
276The workspace has the `lean-ctx` MCP server installed. You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.
277
278## Mandatory Tool Preferences
279
280| Use this | Instead of | Why |
281|----------|-----------|-----|
282| `mcp_lean_ctx_ctx_read` | `readFile`, `readCode` | Cached reads, 8 compression modes, re-reads cost ~13 tokens |
283| `mcp_lean_ctx_ctx_multi_read` | `readMultipleFiles` | Batch cached reads in one call |
284| `mcp_lean_ctx_ctx_shell` | `executeBash` | Pattern compression for git/npm/test output |
285| `mcp_lean_ctx_ctx_search` | `grepSearch` | Compact, .gitignore-aware results |
286| `mcp_lean_ctx_ctx_tree` | `listDirectory` | Compact directory maps with file counts |
287
288## When to use native Kiro tools instead
289
290- `fsWrite` / `fsAppend` — always use native (lean-ctx doesn't write files)
291- `strReplace` — always use native (precise string replacement)
292- `semanticRename` / `smartRelocate` — always use native (IDE integration)
293- `getDiagnostics` — always use native (language server diagnostics)
294- `deleteFile` — always use native
295
296## Session management
297
298- At the start of a long task, call `mcp_lean_ctx_ctx_preload` with a task description to warm the cache
299- Use `mcp_lean_ctx_ctx_compress` periodically in long conversations to checkpoint context
300- Use `mcp_lean_ctx_ctx_knowledge` to persist important discoveries across sessions
301
302## Rules
303
304- NEVER loop on edit failures — switch to `mcp_lean_ctx_ctx_edit` immediately
305- For large files, use `mcp_lean_ctx_ctx_read` with `mode: \"signatures\"` or `mode: \"map\"` first
306- For re-reading a file you already read, just call `mcp_lean_ctx_ctx_read` again (cache hit = ~13 tokens)
307- When running tests or build commands, use `mcp_lean_ctx_ctx_shell` for compressed output
308";
309
310pub fn install_agent_hook(agent: &str, global: bool) {
311 match agent {
312 "claude" | "claude-code" => install_claude_hook(global),
313 "cursor" => install_cursor_hook(global),
314 "gemini" | "antigravity" => install_gemini_hook(),
315 "codex" => install_codex_hook(),
316 "windsurf" => install_windsurf_rules(global),
317 "cline" | "roo" => install_cline_rules(global),
318 "copilot" => install_copilot_hook(global),
319 "pi" => install_pi_hook(global),
320 "qwen" => install_mcp_json_agent(
321 "Qwen Code",
322 "~/.qwen/mcp.json",
323 &dirs::home_dir().unwrap_or_default().join(".qwen/mcp.json"),
324 ),
325 "trae" => install_mcp_json_agent(
326 "Trae",
327 "~/.trae/mcp.json",
328 &dirs::home_dir().unwrap_or_default().join(".trae/mcp.json"),
329 ),
330 "amazonq" => install_mcp_json_agent(
331 "Amazon Q Developer",
332 "~/.aws/amazonq/mcp.json",
333 &dirs::home_dir()
334 .unwrap_or_default()
335 .join(".aws/amazonq/mcp.json"),
336 ),
337 "jetbrains" => install_mcp_json_agent(
338 "JetBrains IDEs",
339 "~/.jb-mcp.json",
340 &dirs::home_dir().unwrap_or_default().join(".jb-mcp.json"),
341 ),
342 "kiro" => install_kiro_hook(),
343 "verdent" => install_mcp_json_agent(
344 "Verdent",
345 "~/.verdent/mcp.json",
346 &dirs::home_dir()
347 .unwrap_or_default()
348 .join(".verdent/mcp.json"),
349 ),
350 "opencode" => install_mcp_json_agent(
351 "OpenCode",
352 "~/.opencode/mcp.json",
353 &dirs::home_dir()
354 .unwrap_or_default()
355 .join(".opencode/mcp.json"),
356 ),
357 "aider" => install_mcp_json_agent(
358 "Aider",
359 "~/.aider/mcp.json",
360 &dirs::home_dir().unwrap_or_default().join(".aider/mcp.json"),
361 ),
362 "amp" => install_mcp_json_agent(
363 "Amp",
364 "~/.amp/mcp.json",
365 &dirs::home_dir().unwrap_or_default().join(".amp/mcp.json"),
366 ),
367 "crush" => install_crush_hook(),
368 _ => {
369 eprintln!("Unknown agent: {agent}");
370 eprintln!(" Supported: claude, cursor, gemini, codex, windsurf, cline, roo, copilot, pi, qwen, trae, amazonq, jetbrains, kiro, verdent, opencode, aider, amp, crush, antigravity");
371 std::process::exit(1);
372 }
373 }
374}
375
376fn install_claude_hook(global: bool) {
377 let home = match dirs::home_dir() {
378 Some(h) => h,
379 None => {
380 eprintln!("Cannot resolve home directory");
381 return;
382 }
383 };
384
385 install_claude_hook_scripts(&home);
386 install_claude_hook_config(&home);
387
388 install_claude_global_md(&home);
389
390 if !global {
391 let claude_md = PathBuf::from("CLAUDE.md");
392 if !claude_md.exists()
393 || !std::fs::read_to_string(&claude_md)
394 .unwrap_or_default()
395 .contains("lean-ctx")
396 {
397 let content = include_str!("templates/CLAUDE.md");
398 write_file(&claude_md, content);
399 println!("Created CLAUDE.md in current project directory.");
400 } else {
401 println!("CLAUDE.md already configured.");
402 }
403 }
404}
405
406fn install_claude_global_md(home: &std::path::Path) {
407 let claude_dir = crate::setup::claude_config_dir(home);
408 let _ = std::fs::create_dir_all(&claude_dir);
409 let global_md = claude_dir.join("CLAUDE.md");
410
411 let existing = std::fs::read_to_string(&global_md).unwrap_or_default();
412 if existing.contains("lean-ctx") {
413 println!(
414 " \x1b[32m✓\x1b[0m {}/CLAUDE.md already configured",
415 claude_dir.display()
416 );
417 return;
418 }
419
420 let content = include_str!("templates/CLAUDE_GLOBAL.md");
421
422 if existing.is_empty() {
423 write_file(&global_md, content);
424 } else {
425 let mut merged = existing;
426 if !merged.ends_with('\n') {
427 merged.push('\n');
428 }
429 merged.push('\n');
430 merged.push_str(content);
431 write_file(&global_md, &merged);
432 }
433 println!(
434 " \x1b[32m✓\x1b[0m Installed global {}/CLAUDE.md",
435 claude_dir.display()
436 );
437}
438
439fn install_claude_hook_scripts(home: &std::path::Path) {
440 let hooks_dir = crate::setup::claude_config_dir(home).join("hooks");
441 let _ = std::fs::create_dir_all(&hooks_dir);
442
443 let binary = resolve_binary_path();
444
445 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
446 let rewrite_script = generate_rewrite_script(&resolve_binary_path_for_bash());
447 write_file(&rewrite_path, &rewrite_script);
448 make_executable(&rewrite_path);
449
450 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
451 write_file(&redirect_path, REDIRECT_SCRIPT_CLAUDE);
452 make_executable(&redirect_path);
453
454 let wrapper = |subcommand: &str| -> String {
455 if cfg!(windows) {
456 format!("{binary} hook {subcommand}")
457 } else {
458 format!("{} hook {subcommand}", resolve_binary_path_for_bash())
459 }
460 };
461
462 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
463 write_file(
464 &rewrite_native,
465 &format!(
466 "#!/bin/sh\nexec {} hook rewrite\n",
467 resolve_binary_path_for_bash()
468 ),
469 );
470 make_executable(&rewrite_native);
471
472 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
473 write_file(
474 &redirect_native,
475 &format!(
476 "#!/bin/sh\nexec {} hook redirect\n",
477 resolve_binary_path_for_bash()
478 ),
479 );
480 make_executable(&redirect_native);
481
482 let _ = wrapper; }
484
485fn install_claude_hook_config(home: &std::path::Path) {
486 let hooks_dir = crate::setup::claude_config_dir(home).join("hooks");
487 let binary = resolve_binary_path();
488
489 let rewrite_cmd = format!("{binary} hook rewrite");
490 let redirect_cmd = format!("{binary} hook redirect");
491
492 let settings_path = crate::setup::claude_config_dir(home).join("settings.json");
493 let settings_content = if settings_path.exists() {
494 std::fs::read_to_string(&settings_path).unwrap_or_default()
495 } else {
496 String::new()
497 };
498
499 let needs_update =
500 !settings_content.contains("hook rewrite") || !settings_content.contains("hook redirect");
501 let has_old_hooks = settings_content.contains("lean-ctx-rewrite.sh")
502 || settings_content.contains("lean-ctx-redirect.sh");
503
504 if !needs_update && !has_old_hooks {
505 return;
506 }
507
508 let hook_entry = serde_json::json!({
509 "hooks": {
510 "PreToolUse": [
511 {
512 "matcher": "Bash|bash",
513 "hooks": [{
514 "type": "command",
515 "command": rewrite_cmd
516 }]
517 },
518 {
519 "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
520 "hooks": [{
521 "type": "command",
522 "command": redirect_cmd
523 }]
524 }
525 ]
526 }
527 });
528
529 if settings_content.is_empty() {
530 write_file(
531 &settings_path,
532 &serde_json::to_string_pretty(&hook_entry).unwrap(),
533 );
534 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
535 if let Some(obj) = existing.as_object_mut() {
536 obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
537 write_file(
538 &settings_path,
539 &serde_json::to_string_pretty(&existing).unwrap(),
540 );
541 }
542 }
543 if !mcp_server_quiet_mode() {
544 println!("Installed Claude Code hooks at {}", hooks_dir.display());
545 }
546}
547
548fn install_cursor_hook(global: bool) {
549 let home = match dirs::home_dir() {
550 Some(h) => h,
551 None => {
552 eprintln!("Cannot resolve home directory");
553 return;
554 }
555 };
556
557 install_cursor_hook_scripts(&home);
558 install_cursor_hook_config(&home);
559
560 if !global {
561 let rules_dir = PathBuf::from(".cursor").join("rules");
562 let _ = std::fs::create_dir_all(&rules_dir);
563 let rule_path = rules_dir.join("lean-ctx.mdc");
564 if !rule_path.exists() {
565 let rule_content = include_str!("templates/lean-ctx.mdc");
566 write_file(&rule_path, rule_content);
567 println!("Created .cursor/rules/lean-ctx.mdc in current project.");
568 } else {
569 println!("Cursor rule already exists.");
570 }
571 } else {
572 println!("Global mode: skipping project-local .cursor/rules/ (use without --global in a project).");
573 }
574
575 println!("Restart Cursor to activate.");
576}
577
578fn install_cursor_hook_scripts(home: &std::path::Path) {
579 let hooks_dir = home.join(".cursor").join("hooks");
580 let _ = std::fs::create_dir_all(&hooks_dir);
581
582 let binary = resolve_binary_path_for_bash();
583
584 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
585 let rewrite_script = generate_compact_rewrite_script(&binary);
586 write_file(&rewrite_path, &rewrite_script);
587 make_executable(&rewrite_path);
588
589 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
590 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
591 make_executable(&redirect_path);
592
593 let native_binary = resolve_binary_path();
594 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
595 write_file(
596 &rewrite_native,
597 &format!("#!/bin/sh\nexec {} hook rewrite\n", native_binary),
598 );
599 make_executable(&rewrite_native);
600
601 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
602 write_file(
603 &redirect_native,
604 &format!("#!/bin/sh\nexec {} hook redirect\n", native_binary),
605 );
606 make_executable(&redirect_native);
607}
608
609fn install_cursor_hook_config(home: &std::path::Path) {
610 let binary = resolve_binary_path();
611 let rewrite_cmd = format!("{binary} hook rewrite");
612 let redirect_cmd = format!("{binary} hook redirect");
613
614 let hooks_json = home.join(".cursor").join("hooks.json");
615
616 let hook_config = serde_json::json!({
617 "version": 1,
618 "hooks": {
619 "preToolUse": [
620 {
621 "matcher": "terminal_command",
622 "command": rewrite_cmd
623 },
624 {
625 "matcher": "read_file|grep|search|list_files|list_directory",
626 "command": redirect_cmd
627 }
628 ]
629 }
630 });
631
632 let content = if hooks_json.exists() {
633 std::fs::read_to_string(&hooks_json).unwrap_or_default()
634 } else {
635 String::new()
636 };
637
638 let has_correct_format = content.contains("\"version\"") && content.contains("\"preToolUse\"");
639 if has_correct_format && content.contains("hook rewrite") && content.contains("hook redirect") {
640 return;
641 }
642
643 if content.is_empty() || !content.contains("\"version\"") {
644 write_file(
645 &hooks_json,
646 &serde_json::to_string_pretty(&hook_config).unwrap(),
647 );
648 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&content) {
649 if let Some(obj) = existing.as_object_mut() {
650 obj.insert("version".to_string(), serde_json::json!(1));
651 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
652 write_file(
653 &hooks_json,
654 &serde_json::to_string_pretty(&existing).unwrap(),
655 );
656 }
657 } else {
658 write_file(
659 &hooks_json,
660 &serde_json::to_string_pretty(&hook_config).unwrap(),
661 );
662 }
663
664 if !mcp_server_quiet_mode() {
665 println!("Installed Cursor hooks at {}", hooks_json.display());
666 }
667}
668
669fn install_gemini_hook() {
670 let home = match dirs::home_dir() {
671 Some(h) => h,
672 None => {
673 eprintln!("Cannot resolve home directory");
674 return;
675 }
676 };
677
678 install_gemini_hook_scripts(&home);
679 install_gemini_hook_config(&home);
680}
681
682fn install_gemini_hook_scripts(home: &std::path::Path) {
683 let hooks_dir = home.join(".gemini").join("hooks");
684 let _ = std::fs::create_dir_all(&hooks_dir);
685
686 let binary = resolve_binary_path_for_bash();
687
688 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
689 let rewrite_script = generate_compact_rewrite_script(&binary);
690 write_file(&rewrite_path, &rewrite_script);
691 make_executable(&rewrite_path);
692
693 let redirect_path = hooks_dir.join("lean-ctx-redirect-gemini.sh");
694 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
695 make_executable(&redirect_path);
696}
697
698fn install_gemini_hook_config(home: &std::path::Path) {
699 let binary = resolve_binary_path();
700 let rewrite_cmd = format!("{binary} hook rewrite");
701 let redirect_cmd = format!("{binary} hook redirect");
702
703 let settings_path = home.join(".gemini").join("settings.json");
704 let settings_content = if settings_path.exists() {
705 std::fs::read_to_string(&settings_path).unwrap_or_default()
706 } else {
707 String::new()
708 };
709
710 let has_new_format = settings_content.contains("hook rewrite")
711 && settings_content.contains("hook redirect")
712 && settings_content.contains("\"type\"");
713 let has_old_hooks = settings_content.contains("lean-ctx-rewrite")
714 || settings_content.contains("lean-ctx-redirect")
715 || (settings_content.contains("hook rewrite") && !settings_content.contains("\"type\""));
716
717 if has_new_format && !has_old_hooks {
718 return;
719 }
720
721 let hook_config = serde_json::json!({
722 "hooks": {
723 "BeforeTool": [
724 {
725 "hooks": [{
726 "type": "command",
727 "command": rewrite_cmd
728 }]
729 },
730 {
731 "hooks": [{
732 "type": "command",
733 "command": redirect_cmd
734 }]
735 }
736 ]
737 }
738 });
739
740 if settings_content.is_empty() {
741 write_file(
742 &settings_path,
743 &serde_json::to_string_pretty(&hook_config).unwrap(),
744 );
745 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
746 if let Some(obj) = existing.as_object_mut() {
747 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
748 write_file(
749 &settings_path,
750 &serde_json::to_string_pretty(&existing).unwrap(),
751 );
752 }
753 }
754 if !mcp_server_quiet_mode() {
755 println!(
756 "Installed Gemini CLI hooks at {}",
757 settings_path.parent().unwrap_or(&settings_path).display()
758 );
759 }
760}
761
762fn install_codex_hook() {
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 let codex_dir = home.join(".codex");
772 let _ = std::fs::create_dir_all(&codex_dir);
773
774 install_codex_hook_scripts(&home);
775
776 let agents_path = codex_dir.join("AGENTS.md");
777 let agents_content = "# Global Agent Instructions\n\n@LEAN-CTX.md\n";
778
779 let lean_ctx_md = codex_dir.join("LEAN-CTX.md");
780 let binary = resolve_binary_path();
781 let lean_ctx_content = format!(
782 r#"# lean-ctx — Token Optimization
783
784Prefix all shell commands with `{binary} -c` for compressed output:
785
786```bash
787{binary} -c git status # instead of: git status
788{binary} -c cargo test # instead of: cargo test
789{binary} -c ls src/ # instead of: ls src/
790```
791
792This 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.
793Use `{binary} -c --raw <cmd>` to skip compression and get full output.
794"#
795 );
796
797 if agents_path.exists() {
798 let content = std::fs::read_to_string(&agents_path).unwrap_or_default();
799 if content.contains("lean-ctx") || content.contains("LEAN-CTX") {
800 println!("Codex AGENTS.md already configured.");
801 return;
802 }
803 }
804
805 write_file(&agents_path, agents_content);
806 write_file(&lean_ctx_md, &lean_ctx_content);
807 println!("Installed Codex instructions at {}", codex_dir.display());
808}
809
810fn install_codex_hook_scripts(home: &std::path::Path) {
811 let hooks_dir = home.join(".codex").join("hooks");
812 let _ = std::fs::create_dir_all(&hooks_dir);
813
814 let binary = resolve_binary_path_for_bash();
815 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-codex.sh");
816 let rewrite_script = generate_compact_rewrite_script(&binary);
817 write_file(&rewrite_path, &rewrite_script);
818 make_executable(&rewrite_path);
819 if !mcp_server_quiet_mode() {
820 println!(
821 " \x1b[32m✓\x1b[0m Installed Codex hook scripts at {}",
822 hooks_dir.display()
823 );
824 }
825}
826
827fn install_windsurf_rules(global: bool) {
828 if global {
829 println!("Global mode: skipping project-local .windsurfrules (use without --global in a project).");
830 return;
831 }
832
833 let rules_path = PathBuf::from(".windsurfrules");
834 if rules_path.exists() {
835 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
836 if content.contains("lean-ctx") {
837 println!(".windsurfrules already configured.");
838 return;
839 }
840 }
841
842 let rules = include_str!("templates/windsurfrules.txt");
843 write_file(&rules_path, rules);
844 println!("Installed .windsurfrules in current project.");
845}
846
847fn install_cline_rules(global: bool) {
848 if global {
849 println!(
850 "Global mode: skipping project-local .clinerules (use without --global in a project)."
851 );
852 return;
853 }
854
855 let rules_path = PathBuf::from(".clinerules");
856 if rules_path.exists() {
857 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
858 if content.contains("lean-ctx") {
859 println!(".clinerules already configured.");
860 return;
861 }
862 }
863
864 let binary = resolve_binary_path();
865 let rules = format!(
866 r#"# lean-ctx Shell Optimization
867# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
868
869When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
870- `{binary} -c git status` instead of `git status`
871- `{binary} -c cargo test` instead of `cargo test`
872- `{binary} -c ls src/` instead of `ls src/`
873
874Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
875"#
876 );
877
878 write_file(&rules_path, &rules);
879 println!("Installed .clinerules in current project.");
880}
881
882fn install_pi_hook(global: bool) {
883 let has_pi = std::process::Command::new("pi")
884 .arg("--version")
885 .output()
886 .is_ok();
887
888 if !has_pi {
889 println!("Pi Coding Agent not found in PATH.");
890 println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
891 println!();
892 }
893
894 println!("Installing pi-lean-ctx Pi Package...");
895 println!();
896
897 let install_result = std::process::Command::new("pi")
898 .args(["install", "npm:pi-lean-ctx"])
899 .status();
900
901 match install_result {
902 Ok(status) if status.success() => {
903 println!("Installed pi-lean-ctx Pi Package.");
904 }
905 _ => {
906 println!("Could not auto-install pi-lean-ctx. Install manually:");
907 println!(" pi install npm:pi-lean-ctx");
908 println!();
909 }
910 }
911
912 write_pi_mcp_config();
913
914 if !global {
915 let agents_md = PathBuf::from("AGENTS.md");
916 if !agents_md.exists()
917 || !std::fs::read_to_string(&agents_md)
918 .unwrap_or_default()
919 .contains("lean-ctx")
920 {
921 let content = include_str!("templates/PI_AGENTS.md");
922 write_file(&agents_md, content);
923 println!("Created AGENTS.md in current project directory.");
924 } else {
925 println!("AGENTS.md already contains lean-ctx configuration.");
926 }
927 } else {
928 println!(
929 "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
930 );
931 }
932
933 println!();
934 println!("Setup complete. All Pi tools (bash, read, grep, find, ls) route through lean-ctx.");
935 println!("MCP tools (ctx_session, ctx_knowledge, ctx_semantic_search, ...) also available.");
936 println!("Use /lean-ctx in Pi to verify the binary path and MCP status.");
937}
938
939fn write_pi_mcp_config() {
940 let home = match dirs::home_dir() {
941 Some(h) => h,
942 None => return,
943 };
944
945 let mcp_config_path = home.join(".pi/agent/mcp.json");
946
947 if !home.join(".pi/agent").exists() {
948 println!(" \x1b[2m○ ~/.pi/agent/ not found — skipping MCP config\x1b[0m");
949 return;
950 }
951
952 if mcp_config_path.exists() {
953 let content = match std::fs::read_to_string(&mcp_config_path) {
954 Ok(c) => c,
955 Err(_) => return,
956 };
957 if content.contains("lean-ctx") {
958 println!(" \x1b[32m✓\x1b[0m Pi MCP config already contains lean-ctx");
959 return;
960 }
961
962 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
963 if let Some(obj) = json.as_object_mut() {
964 let servers = obj
965 .entry("mcpServers")
966 .or_insert_with(|| serde_json::json!({}));
967 if let Some(servers_obj) = servers.as_object_mut() {
968 servers_obj.insert("lean-ctx".to_string(), pi_mcp_server_entry());
969 }
970 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
971 let _ = std::fs::write(&mcp_config_path, formatted);
972 println!(
973 " \x1b[32m✓\x1b[0m Added lean-ctx to Pi MCP config (~/.pi/agent/mcp.json)"
974 );
975 }
976 }
977 }
978 return;
979 }
980
981 let content = serde_json::json!({
982 "mcpServers": {
983 "lean-ctx": pi_mcp_server_entry()
984 }
985 });
986 if let Ok(formatted) = serde_json::to_string_pretty(&content) {
987 let _ = std::fs::write(&mcp_config_path, formatted);
988 println!(" \x1b[32m✓\x1b[0m Created Pi MCP config (~/.pi/agent/mcp.json)");
989 }
990}
991
992fn pi_mcp_server_entry() -> serde_json::Value {
993 let binary = resolve_binary_path();
994 serde_json::json!({
995 "command": binary,
996 "lifecycle": "lazy",
997 "directTools": true
998 })
999}
1000
1001fn install_copilot_hook(global: bool) {
1002 let binary = resolve_binary_path();
1003
1004 if global {
1005 let mcp_path = copilot_global_mcp_path();
1006 if mcp_path.as_os_str() == "/nonexistent" {
1007 println!(" \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
1008 return;
1009 }
1010 write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
1011 } else {
1012 let vscode_dir = PathBuf::from(".vscode");
1013 let _ = std::fs::create_dir_all(&vscode_dir);
1014 let mcp_path = vscode_dir.join("mcp.json");
1015 write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
1016 }
1017}
1018
1019fn copilot_global_mcp_path() -> PathBuf {
1020 if let Some(home) = dirs::home_dir() {
1021 #[cfg(target_os = "macos")]
1022 {
1023 return home.join("Library/Application Support/Code/User/mcp.json");
1024 }
1025 #[cfg(target_os = "linux")]
1026 {
1027 return home.join(".config/Code/User/mcp.json");
1028 }
1029 #[cfg(target_os = "windows")]
1030 {
1031 if let Ok(appdata) = std::env::var("APPDATA") {
1032 return PathBuf::from(appdata).join("Code/User/mcp.json");
1033 }
1034 }
1035 #[allow(unreachable_code)]
1036 home.join(".config/Code/User/mcp.json")
1037 } else {
1038 PathBuf::from("/nonexistent")
1039 }
1040}
1041
1042fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
1043 let desired = serde_json::json!({ "command": binary, "args": [] });
1044 if mcp_path.exists() {
1045 let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
1046 match serde_json::from_str::<serde_json::Value>(&content) {
1047 Ok(mut json) => {
1048 if let Some(obj) = json.as_object_mut() {
1049 let servers = obj
1050 .entry("servers")
1051 .or_insert_with(|| serde_json::json!({}));
1052 if let Some(servers_obj) = servers.as_object_mut() {
1053 if servers_obj.get("lean-ctx") == Some(&desired) {
1054 println!(" \x1b[32m✓\x1b[0m Copilot already configured in {label}");
1055 return;
1056 }
1057 servers_obj.insert("lean-ctx".to_string(), desired);
1058 }
1059 write_file(
1060 mcp_path,
1061 &serde_json::to_string_pretty(&json).unwrap_or_default(),
1062 );
1063 println!(" \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
1064 return;
1065 }
1066 }
1067 Err(e) => {
1068 eprintln!(
1069 "Could not parse VS Code MCP config at {}: {e}\nAdd to \"servers\": \"lean-ctx\": {{ \"command\": \"{}\", \"args\": [] }}",
1070 mcp_path.display(),
1071 binary
1072 );
1073 return;
1074 }
1075 };
1076 }
1077
1078 if let Some(parent) = mcp_path.parent() {
1079 let _ = std::fs::create_dir_all(parent);
1080 }
1081
1082 let config = serde_json::json!({
1083 "servers": {
1084 "lean-ctx": {
1085 "command": binary,
1086 "args": []
1087 }
1088 }
1089 });
1090
1091 write_file(
1092 mcp_path,
1093 &serde_json::to_string_pretty(&config).unwrap_or_default(),
1094 );
1095 println!(" \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
1096}
1097
1098fn write_file(path: &std::path::Path, content: &str) {
1099 if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
1100 eprintln!("Error writing {}: {e}", path.display());
1101 }
1102}
1103
1104#[cfg(unix)]
1105fn make_executable(path: &PathBuf) {
1106 use std::os::unix::fs::PermissionsExt;
1107 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1108}
1109
1110#[cfg(not(unix))]
1111fn make_executable(_path: &PathBuf) {}
1112
1113fn install_crush_hook() {
1114 let binary = resolve_binary_path();
1115 let home = dirs::home_dir().unwrap_or_default();
1116 let config_path = home.join(".config/crush/crush.json");
1117 let display_path = "~/.config/crush/crush.json";
1118
1119 if let Some(parent) = config_path.parent() {
1120 let _ = std::fs::create_dir_all(parent);
1121 }
1122
1123 if config_path.exists() {
1124 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1125 if content.contains("lean-ctx") {
1126 println!("Crush MCP already configured at {display_path}");
1127 return;
1128 }
1129
1130 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1131 if let Some(obj) = json.as_object_mut() {
1132 let servers = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1133 if let Some(servers_obj) = servers.as_object_mut() {
1134 servers_obj.insert(
1135 "lean-ctx".to_string(),
1136 serde_json::json!({ "type": "stdio", "command": binary }),
1137 );
1138 }
1139 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1140 let _ = std::fs::write(&config_path, formatted);
1141 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1142 return;
1143 }
1144 }
1145 }
1146 }
1147
1148 let content = serde_json::to_string_pretty(&serde_json::json!({
1149 "mcp": {
1150 "lean-ctx": {
1151 "type": "stdio",
1152 "command": binary
1153 }
1154 }
1155 }));
1156
1157 if let Ok(json_str) = content {
1158 let _ = std::fs::write(&config_path, json_str);
1159 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1160 } else {
1161 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure Crush");
1162 }
1163}
1164
1165fn install_kiro_hook() {
1166 let home = dirs::home_dir().unwrap_or_default();
1167
1168 install_mcp_json_agent(
1169 "AWS Kiro",
1170 "~/.kiro/settings/mcp.json",
1171 &home.join(".kiro/settings/mcp.json"),
1172 );
1173
1174 let cwd = std::env::current_dir().unwrap_or_default();
1175 let steering_dir = cwd.join(".kiro").join("steering");
1176 let steering_file = steering_dir.join("lean-ctx.md");
1177
1178 if steering_file.exists()
1179 && std::fs::read_to_string(&steering_file)
1180 .unwrap_or_default()
1181 .contains("lean-ctx")
1182 {
1183 println!(" Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1184 } else {
1185 let _ = std::fs::create_dir_all(&steering_dir);
1186 write_file(&steering_file, KIRO_STEERING_TEMPLATE);
1187 println!(" \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1188 }
1189}
1190
1191fn install_mcp_json_agent(name: &str, display_path: &str, config_path: &std::path::Path) {
1192 let binary = resolve_binary_path();
1193
1194 if let Some(parent) = config_path.parent() {
1195 let _ = std::fs::create_dir_all(parent);
1196 }
1197
1198 if config_path.exists() {
1199 let content = std::fs::read_to_string(config_path).unwrap_or_default();
1200 if content.contains("lean-ctx") {
1201 println!("{name} MCP already configured at {display_path}");
1202 return;
1203 }
1204
1205 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1206 if let Some(obj) = json.as_object_mut() {
1207 let servers = obj
1208 .entry("mcpServers")
1209 .or_insert_with(|| serde_json::json!({}));
1210 if let Some(servers_obj) = servers.as_object_mut() {
1211 servers_obj.insert(
1212 "lean-ctx".to_string(),
1213 serde_json::json!({ "command": binary }),
1214 );
1215 }
1216 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1217 let _ = std::fs::write(config_path, formatted);
1218 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1219 return;
1220 }
1221 }
1222 }
1223 }
1224
1225 let content = serde_json::to_string_pretty(&serde_json::json!({
1226 "mcpServers": {
1227 "lean-ctx": {
1228 "command": binary
1229 }
1230 }
1231 }));
1232
1233 if let Ok(json_str) = content {
1234 let _ = std::fs::write(config_path, json_str);
1235 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1236 } else {
1237 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure {name}");
1238 }
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use super::*;
1244
1245 #[test]
1246 fn bash_path_unix_unchanged() {
1247 assert_eq!(
1248 to_bash_compatible_path("/usr/local/bin/lean-ctx"),
1249 "/usr/local/bin/lean-ctx"
1250 );
1251 }
1252
1253 #[test]
1254 fn bash_path_home_unchanged() {
1255 assert_eq!(
1256 to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
1257 "/home/user/.cargo/bin/lean-ctx"
1258 );
1259 }
1260
1261 #[test]
1262 fn bash_path_windows_drive_converted() {
1263 assert_eq!(
1264 to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
1265 "/c/Users/Fraser/bin/lean-ctx.exe"
1266 );
1267 }
1268
1269 #[test]
1270 fn bash_path_windows_lowercase_drive() {
1271 assert_eq!(
1272 to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
1273 "/d/tools/lean-ctx.exe"
1274 );
1275 }
1276
1277 #[test]
1278 fn bash_path_windows_forward_slashes() {
1279 assert_eq!(
1280 to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
1281 "/c/Users/Fraser/bin/lean-ctx.exe"
1282 );
1283 }
1284
1285 #[test]
1286 fn bash_path_bare_name_unchanged() {
1287 assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
1288 }
1289
1290 #[test]
1291 fn normalize_msys2_path() {
1292 assert_eq!(
1293 normalize_tool_path("/c/Users/game/Downloads/project"),
1294 "C:/Users/game/Downloads/project"
1295 );
1296 }
1297
1298 #[test]
1299 fn normalize_msys2_drive_d() {
1300 assert_eq!(
1301 normalize_tool_path("/d/Projects/app/src"),
1302 "D:/Projects/app/src"
1303 );
1304 }
1305
1306 #[test]
1307 fn normalize_backslashes() {
1308 assert_eq!(
1309 normalize_tool_path("C:\\Users\\game\\project\\src"),
1310 "C:/Users/game/project/src"
1311 );
1312 }
1313
1314 #[test]
1315 fn normalize_mixed_separators() {
1316 assert_eq!(
1317 normalize_tool_path("C:\\Users/game\\project/src"),
1318 "C:/Users/game/project/src"
1319 );
1320 }
1321
1322 #[test]
1323 fn normalize_double_slashes() {
1324 assert_eq!(
1325 normalize_tool_path("/home/user//project///src"),
1326 "/home/user/project/src"
1327 );
1328 }
1329
1330 #[test]
1331 fn normalize_trailing_slash() {
1332 assert_eq!(
1333 normalize_tool_path("/home/user/project/"),
1334 "/home/user/project"
1335 );
1336 }
1337
1338 #[test]
1339 fn normalize_root_preserved() {
1340 assert_eq!(normalize_tool_path("/"), "/");
1341 }
1342
1343 #[test]
1344 fn normalize_windows_root_preserved() {
1345 assert_eq!(normalize_tool_path("C:/"), "C:/");
1346 }
1347
1348 #[test]
1349 fn normalize_unix_path_unchanged() {
1350 assert_eq!(
1351 normalize_tool_path("/home/user/project/src/main.rs"),
1352 "/home/user/project/src/main.rs"
1353 );
1354 }
1355
1356 #[test]
1357 fn normalize_relative_path_unchanged() {
1358 assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
1359 }
1360
1361 #[test]
1362 fn normalize_dot_unchanged() {
1363 assert_eq!(normalize_tool_path("."), ".");
1364 }
1365
1366 #[test]
1367 fn normalize_unc_path_preserved() {
1368 assert_eq!(
1369 normalize_tool_path("//server/share/file"),
1370 "//server/share/file"
1371 );
1372 }
1373
1374 #[test]
1375 fn cursor_hook_config_has_version_and_object_hooks() {
1376 let config = serde_json::json!({
1377 "version": 1,
1378 "hooks": {
1379 "preToolUse": [
1380 {
1381 "matcher": "terminal_command",
1382 "command": "lean-ctx hook rewrite"
1383 },
1384 {
1385 "matcher": "read_file|grep|search|list_files|list_directory",
1386 "command": "lean-ctx hook redirect"
1387 }
1388 ]
1389 }
1390 });
1391
1392 let json_str = serde_json::to_string_pretty(&config).unwrap();
1393 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1394
1395 assert_eq!(parsed["version"], 1);
1396 assert!(parsed["hooks"].is_object());
1397 assert!(parsed["hooks"]["preToolUse"].is_array());
1398 assert_eq!(parsed["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
1399 assert_eq!(
1400 parsed["hooks"]["preToolUse"][0]["matcher"],
1401 "terminal_command"
1402 );
1403 }
1404
1405 #[test]
1406 fn cursor_hook_detects_old_format_needs_migration() {
1407 let old_format = r#"{"hooks":[{"event":"preToolUse","command":"lean-ctx hook rewrite"}]}"#;
1408 let has_correct =
1409 old_format.contains("\"version\"") && old_format.contains("\"preToolUse\"");
1410 assert!(
1411 !has_correct,
1412 "Old format should be detected as needing migration"
1413 );
1414 }
1415
1416 #[test]
1417 fn gemini_hook_config_has_type_command() {
1418 let binary = "lean-ctx";
1419 let rewrite_cmd = format!("{binary} hook rewrite");
1420 let redirect_cmd = format!("{binary} hook redirect");
1421
1422 let hook_config = serde_json::json!({
1423 "hooks": {
1424 "BeforeTool": [
1425 {
1426 "hooks": [{
1427 "type": "command",
1428 "command": rewrite_cmd
1429 }]
1430 },
1431 {
1432 "hooks": [{
1433 "type": "command",
1434 "command": redirect_cmd
1435 }]
1436 }
1437 ]
1438 }
1439 });
1440
1441 let parsed = hook_config;
1442 let before_tool = parsed["hooks"]["BeforeTool"].as_array().unwrap();
1443 assert_eq!(before_tool.len(), 2);
1444
1445 let first_hook = &before_tool[0]["hooks"][0];
1446 assert_eq!(first_hook["type"], "command");
1447 assert_eq!(first_hook["command"], "lean-ctx hook rewrite");
1448
1449 let second_hook = &before_tool[1]["hooks"][0];
1450 assert_eq!(second_hook["type"], "command");
1451 assert_eq!(second_hook["command"], "lean-ctx hook redirect");
1452 }
1453
1454 #[test]
1455 fn gemini_hook_old_format_detected() {
1456 let old_format = r#"{"hooks":{"BeforeTool":[{"command":"lean-ctx hook rewrite"}]}}"#;
1457 let has_new = old_format.contains("hook rewrite")
1458 && old_format.contains("hook redirect")
1459 && old_format.contains("\"type\"");
1460 assert!(!has_new, "Missing 'type' field should trigger migration");
1461 }
1462}