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