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