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