1use std::path::PathBuf;
2
3use super::{
4 ensure_codex_hooks_enabled as shared_ensure_codex_hooks_enabled, full_server_entry,
5 generate_compact_rewrite_script, generate_rewrite_script, install_codex_instruction_docs,
6 install_mcp_json_agent, install_named_json_server, install_project_rules, is_inside_git_repo,
7 make_executable, mcp_server_quiet_mode, resolve_binary_path, resolve_binary_path_for_bash,
8 upsert_lean_ctx_codex_hook_entries, write_file, KIRO_STEERING_TEMPLATE, REDIRECT_SCRIPT_CLAUDE,
9 REDIRECT_SCRIPT_GENERIC,
10};
11
12pub(super) fn install_claude_hook(global: bool) {
13 let home = match dirs::home_dir() {
14 Some(h) => h,
15 None => {
16 eprintln!("Cannot resolve home directory");
17 return;
18 }
19 };
20
21 install_claude_hook_scripts(&home);
22 install_claude_hook_config(&home);
23
24 let scope = crate::core::config::Config::load().rules_scope_effective();
25 if scope != crate::core::config::RulesScope::Project {
26 install_claude_rules_file(&home);
27 install_claude_global_claude_md(&home);
28 install_claude_skill(&home);
29 }
30
31 let _ = global;
32}
33
34const CLAUDE_MD_BLOCK_START: &str = "<!-- lean-ctx -->";
35const CLAUDE_MD_BLOCK_END: &str = "<!-- /lean-ctx -->";
36const CLAUDE_MD_BLOCK_VERSION: &str = "lean-ctx-claude-v2";
37
38const CLAUDE_MD_BLOCK_CONTENT: &str = "\
39<!-- lean-ctx -->
40<!-- lean-ctx-claude-v2 -->
41## lean-ctx — Context Runtime
42
43Always prefer lean-ctx MCP tools over native equivalents:
44- `ctx_read` instead of `Read` / `cat` (cached, 10 modes, re-reads ~13 tokens)
45- `ctx_shell` instead of `bash` / `Shell` (90+ compression patterns)
46- `ctx_search` instead of `Grep` / `rg` (compact results)
47- `ctx_tree` instead of `ls` / `find` (compact directory maps)
48- Native Edit/StrReplace stay unchanged. If Edit requires Read and Read is unavailable, use `ctx_edit(path, old_string, new_string)` instead.
49- Write, Delete, Glob — use normally.
50
51Full rules: @rules/lean-ctx.md
52
53Verify setup: run `/mcp` to check lean-ctx is connected, `/memory` to confirm this file loaded.
54<!-- /lean-ctx -->";
55
56fn install_claude_global_claude_md(home: &std::path::Path) {
57 let claude_dir = crate::core::editor_registry::claude_state_dir(home);
58 let _ = std::fs::create_dir_all(&claude_dir);
59 let claude_md_path = claude_dir.join("CLAUDE.md");
60
61 let existing = std::fs::read_to_string(&claude_md_path).unwrap_or_default();
62
63 if existing.contains(CLAUDE_MD_BLOCK_START) {
64 if existing.contains(CLAUDE_MD_BLOCK_VERSION) {
65 return;
66 }
67 let cleaned = remove_block(&existing, CLAUDE_MD_BLOCK_START, CLAUDE_MD_BLOCK_END);
68 let updated = format!("{}\n\n{}\n", cleaned.trim(), CLAUDE_MD_BLOCK_CONTENT);
69 write_file(&claude_md_path, &updated);
70 return;
71 }
72
73 if existing.trim().is_empty() {
74 write_file(&claude_md_path, CLAUDE_MD_BLOCK_CONTENT);
75 } else {
76 let updated = format!("{}\n\n{}\n", existing.trim(), CLAUDE_MD_BLOCK_CONTENT);
77 write_file(&claude_md_path, &updated);
78 }
79}
80
81fn remove_block(content: &str, start: &str, end: &str) -> String {
82 let s = content.find(start);
83 let e = content.find(end);
84 match (s, e) {
85 (Some(si), Some(ei)) if ei >= si => {
86 let after_end = ei + end.len();
87 let before = content[..si].trim_end_matches('\n');
88 let after = &content[after_end..];
89 let mut out = before.to_string();
90 out.push('\n');
91 if !after.trim().is_empty() {
92 out.push('\n');
93 out.push_str(after.trim_start_matches('\n'));
94 }
95 out
96 }
97 _ => content.to_string(),
98 }
99}
100
101fn install_claude_skill(home: &std::path::Path) {
102 let skill_dir = home.join(".claude/skills/lean-ctx");
103 let _ = std::fs::create_dir_all(skill_dir.join("scripts"));
104
105 let skill_md = include_str!("../../skills/lean-ctx/SKILL.md");
106 let install_sh = include_str!("../../skills/lean-ctx/scripts/install.sh");
107
108 let skill_path = skill_dir.join("SKILL.md");
109 let script_path = skill_dir.join("scripts/install.sh");
110
111 write_file(&skill_path, skill_md);
112 write_file(&script_path, install_sh);
113
114 #[cfg(unix)]
115 {
116 use std::os::unix::fs::PermissionsExt;
117 if let Ok(mut perms) = std::fs::metadata(&script_path).map(|m| m.permissions()) {
118 perms.set_mode(0o755);
119 let _ = std::fs::set_permissions(&script_path, perms);
120 }
121 }
122}
123
124fn install_claude_rules_file(home: &std::path::Path) {
125 let rules_dir = crate::core::editor_registry::claude_rules_dir(home);
126 let _ = std::fs::create_dir_all(&rules_dir);
127 let rules_path = rules_dir.join("lean-ctx.md");
128
129 let desired = crate::rules_inject::rules_dedicated_markdown();
130 let existing = std::fs::read_to_string(&rules_path).unwrap_or_default();
131
132 if existing.is_empty() {
133 write_file(&rules_path, desired);
134 return;
135 }
136 if existing.contains(crate::rules_inject::RULES_VERSION_STR) {
137 return;
138 }
139 if existing.contains("<!-- lean-ctx-rules-") {
140 write_file(&rules_path, desired);
141 }
142}
143
144pub(super) fn install_claude_hook_scripts(home: &std::path::Path) {
145 let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
146 let _ = std::fs::create_dir_all(&hooks_dir);
147
148 let binary = resolve_binary_path();
149
150 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
151 let rewrite_script = generate_rewrite_script(&resolve_binary_path_for_bash());
152 write_file(&rewrite_path, &rewrite_script);
153 make_executable(&rewrite_path);
154
155 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
156 write_file(&redirect_path, REDIRECT_SCRIPT_CLAUDE);
157 make_executable(&redirect_path);
158
159 let wrapper = |subcommand: &str| -> String {
160 if cfg!(windows) {
161 format!("{binary} hook {subcommand}")
162 } else {
163 format!("{} hook {subcommand}", resolve_binary_path_for_bash())
164 }
165 };
166
167 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
168 write_file(
169 &rewrite_native,
170 &format!(
171 "#!/bin/sh\nexec {} hook rewrite\n",
172 resolve_binary_path_for_bash()
173 ),
174 );
175 make_executable(&rewrite_native);
176
177 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
178 write_file(
179 &redirect_native,
180 &format!(
181 "#!/bin/sh\nexec {} hook redirect\n",
182 resolve_binary_path_for_bash()
183 ),
184 );
185 make_executable(&redirect_native);
186
187 let _ = wrapper; }
189
190pub(super) fn install_claude_hook_config(home: &std::path::Path) {
191 let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
192 let binary = resolve_binary_path();
193
194 let rewrite_cmd = format!("{binary} hook rewrite");
195 let redirect_cmd = format!("{binary} hook redirect");
196
197 let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
198 let settings_content = if settings_path.exists() {
199 std::fs::read_to_string(&settings_path).unwrap_or_default()
200 } else {
201 String::new()
202 };
203
204 let needs_update =
205 !settings_content.contains("hook rewrite") || !settings_content.contains("hook redirect");
206 let has_old_hooks = settings_content.contains("lean-ctx-rewrite.sh")
207 || settings_content.contains("lean-ctx-redirect.sh");
208
209 if !needs_update && !has_old_hooks {
210 return;
211 }
212
213 let hook_entry = serde_json::json!({
214 "hooks": {
215 "PreToolUse": [
216 {
217 "matcher": "Bash|bash",
218 "hooks": [{
219 "type": "command",
220 "command": rewrite_cmd
221 }]
222 },
223 {
224 "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
225 "hooks": [{
226 "type": "command",
227 "command": redirect_cmd
228 }]
229 }
230 ]
231 }
232 });
233
234 if settings_content.is_empty() {
235 write_file(
236 &settings_path,
237 &serde_json::to_string_pretty(&hook_entry).unwrap(),
238 );
239 } else if let Ok(mut existing) = crate::core::jsonc::parse_jsonc(&settings_content) {
240 if let Some(obj) = existing.as_object_mut() {
241 obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
242 write_file(
243 &settings_path,
244 &serde_json::to_string_pretty(&existing).unwrap(),
245 );
246 }
247 }
248 if !mcp_server_quiet_mode() {
249 println!("Installed Claude Code hooks at {}", hooks_dir.display());
250 }
251}
252
253pub(super) fn install_claude_project_hooks(cwd: &std::path::Path) {
254 let binary = resolve_binary_path();
255 let rewrite_cmd = format!("{binary} hook rewrite");
256 let redirect_cmd = format!("{binary} hook redirect");
257
258 let settings_path = cwd.join(".claude").join("settings.local.json");
259 let _ = std::fs::create_dir_all(cwd.join(".claude"));
260
261 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
262 if existing.contains("hook rewrite") && existing.contains("hook redirect") {
263 return;
264 }
265
266 let hook_entry = serde_json::json!({
267 "hooks": {
268 "PreToolUse": [
269 {
270 "matcher": "Bash|bash",
271 "hooks": [{
272 "type": "command",
273 "command": rewrite_cmd
274 }]
275 },
276 {
277 "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
278 "hooks": [{
279 "type": "command",
280 "command": redirect_cmd
281 }]
282 }
283 ]
284 }
285 });
286
287 if existing.is_empty() {
288 write_file(
289 &settings_path,
290 &serde_json::to_string_pretty(&hook_entry).unwrap(),
291 );
292 } else if let Ok(mut json) = crate::core::jsonc::parse_jsonc(&existing) {
293 if let Some(obj) = json.as_object_mut() {
294 obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
295 write_file(
296 &settings_path,
297 &serde_json::to_string_pretty(&json).unwrap(),
298 );
299 }
300 }
301 println!("Created .claude/settings.local.json (project-local PreToolUse hooks).");
302}
303
304pub fn install_cursor_hook(global: bool) {
305 let home = match dirs::home_dir() {
306 Some(h) => h,
307 None => {
308 eprintln!("Cannot resolve home directory");
309 return;
310 }
311 };
312
313 install_cursor_hook_scripts(&home);
314 install_cursor_hook_config(&home);
315
316 let scope = crate::core::config::Config::load().rules_scope_effective();
317 let skip_project = global || scope == crate::core::config::RulesScope::Global;
318
319 if !skip_project {
320 let rules_dir = PathBuf::from(".cursor").join("rules");
321 let _ = std::fs::create_dir_all(&rules_dir);
322 let rule_path = rules_dir.join("lean-ctx.mdc");
323 if !rule_path.exists() {
324 let rule_content = include_str!("../templates/lean-ctx.mdc");
325 write_file(&rule_path, rule_content);
326 println!("Created .cursor/rules/lean-ctx.mdc in current project.");
327 } else {
328 println!("Cursor rule already exists.");
329 }
330 } else {
331 println!("Global mode: skipping project-local .cursor/rules/ (use without --global in a project).");
332 }
333
334 println!("Restart Cursor to activate.");
335}
336
337pub(super) fn install_cursor_hook_scripts(home: &std::path::Path) {
338 let hooks_dir = home.join(".cursor").join("hooks");
339 install_standard_hook_scripts(&hooks_dir, "lean-ctx-rewrite.sh", "lean-ctx-redirect.sh");
340
341 let native_binary = resolve_binary_path();
342 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
343 write_file(
344 &rewrite_native,
345 &format!("#!/bin/sh\nexec {} hook rewrite\n", native_binary),
346 );
347 make_executable(&rewrite_native);
348
349 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
350 write_file(
351 &redirect_native,
352 &format!("#!/bin/sh\nexec {} hook redirect\n", native_binary),
353 );
354 make_executable(&redirect_native);
355}
356
357pub(super) fn install_cursor_hook_config(home: &std::path::Path) {
358 let binary = resolve_binary_path();
359 let rewrite_cmd = format!("{binary} hook rewrite");
360 let redirect_cmd = format!("{binary} hook redirect");
361
362 let hooks_json = home.join(".cursor").join("hooks.json");
363
364 let hook_config = serde_json::json!({
365 "version": 1,
366 "hooks": {
367 "preToolUse": [
368 {
369 "matcher": "Shell",
370 "command": rewrite_cmd
371 },
372 {
373 "matcher": "Read|Grep",
374 "command": redirect_cmd
375 }
376 ]
377 }
378 });
379
380 let content = if hooks_json.exists() {
381 std::fs::read_to_string(&hooks_json).unwrap_or_default()
382 } else {
383 String::new()
384 };
385
386 let has_correct_matchers = content.contains("\"Shell\"")
387 && (content.contains("\"Read|Grep\"") || content.contains("\"Read\""));
388 let has_correct_format = content.contains("\"version\"") && content.contains("\"preToolUse\"");
389 if has_correct_format
390 && has_correct_matchers
391 && content.contains("hook rewrite")
392 && content.contains("hook redirect")
393 {
394 return;
395 }
396
397 if content.is_empty() || !content.contains("\"version\"") {
398 write_file(
399 &hooks_json,
400 &serde_json::to_string_pretty(&hook_config).unwrap(),
401 );
402 } else if let Ok(mut existing) = crate::core::jsonc::parse_jsonc(&content) {
403 if let Some(obj) = existing.as_object_mut() {
404 obj.insert("version".to_string(), serde_json::json!(1));
405 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
406 write_file(
407 &hooks_json,
408 &serde_json::to_string_pretty(&existing).unwrap(),
409 );
410 }
411 } else {
412 write_file(
413 &hooks_json,
414 &serde_json::to_string_pretty(&hook_config).unwrap(),
415 );
416 }
417
418 if !mcp_server_quiet_mode() {
419 println!("Installed Cursor hooks at {}", hooks_json.display());
420 }
421}
422
423pub(super) fn install_gemini_hook() {
424 let home = match dirs::home_dir() {
425 Some(h) => h,
426 None => {
427 eprintln!("Cannot resolve home directory");
428 return;
429 }
430 };
431
432 install_gemini_hook_scripts(&home);
433 install_gemini_hook_config(&home);
434}
435
436fn install_standard_hook_scripts(
437 hooks_dir: &std::path::Path,
438 rewrite_name: &str,
439 redirect_name: &str,
440) {
441 let _ = std::fs::create_dir_all(hooks_dir);
442
443 let binary = resolve_binary_path_for_bash();
444 let rewrite_path = hooks_dir.join(rewrite_name);
445 let rewrite_script = generate_compact_rewrite_script(&binary);
446 write_file(&rewrite_path, &rewrite_script);
447 make_executable(&rewrite_path);
448
449 let redirect_path = hooks_dir.join(redirect_name);
450 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
451 make_executable(&redirect_path);
452}
453
454pub(super) fn install_gemini_hook_scripts(home: &std::path::Path) {
455 let hooks_dir = home.join(".gemini").join("hooks");
456 install_standard_hook_scripts(
457 &hooks_dir,
458 "lean-ctx-rewrite-gemini.sh",
459 "lean-ctx-redirect-gemini.sh",
460 );
461}
462
463pub(super) fn install_gemini_hook_config(home: &std::path::Path) {
464 let binary = resolve_binary_path();
465 let rewrite_cmd = format!("{binary} hook rewrite");
466 let redirect_cmd = format!("{binary} hook redirect");
467
468 let settings_path = home.join(".gemini").join("settings.json");
469 let settings_content = if settings_path.exists() {
470 std::fs::read_to_string(&settings_path).unwrap_or_default()
471 } else {
472 String::new()
473 };
474
475 let has_new_format = settings_content.contains("hook rewrite")
476 && settings_content.contains("hook redirect")
477 && settings_content.contains("\"type\"")
478 && settings_content.contains("\"matcher\"");
479 let has_old_hooks = settings_content.contains("lean-ctx-rewrite")
480 || settings_content.contains("lean-ctx-redirect")
481 || (settings_content.contains("hook rewrite") && !settings_content.contains("\"matcher\""));
482
483 if has_new_format && !has_old_hooks {
484 return;
485 }
486
487 let hook_config = serde_json::json!({
488 "hooks": {
489 "BeforeTool": [
490 {
491 "matcher": "shell|execute_command|run_shell_command",
492 "hooks": [{
493 "type": "command",
494 "command": rewrite_cmd
495 }]
496 },
497 {
498 "matcher": "read_file|read_many_files|grep|search|list_dir",
499 "hooks": [{
500 "type": "command",
501 "command": redirect_cmd
502 }]
503 }
504 ]
505 }
506 });
507
508 if settings_content.is_empty() {
509 write_file(
510 &settings_path,
511 &serde_json::to_string_pretty(&hook_config).unwrap(),
512 );
513 } else if let Ok(mut existing) = crate::core::jsonc::parse_jsonc(&settings_content) {
514 if let Some(obj) = existing.as_object_mut() {
515 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
516 write_file(
517 &settings_path,
518 &serde_json::to_string_pretty(&existing).unwrap(),
519 );
520 }
521 }
522 if !mcp_server_quiet_mode() {
523 println!(
524 "Installed Gemini CLI hooks at {}",
525 settings_path.parent().unwrap_or(&settings_path).display()
526 );
527 }
528}
529
530pub fn install_codex_hook() {
531 let home = match dirs::home_dir() {
532 Some(h) => h,
533 None => {
534 eprintln!("Cannot resolve home directory");
535 return;
536 }
537 };
538
539 let codex_dir = home.join(".codex");
540 let _ = std::fs::create_dir_all(&codex_dir);
541
542 let hook_config_changed = install_codex_hook_config(&home);
543 let installed_docs = install_codex_instruction_docs(&codex_dir);
544
545 if !mcp_server_quiet_mode() {
546 if hook_config_changed {
547 eprintln!(
548 "Installed Codex-compatible SessionStart/PreToolUse hooks at {}",
549 codex_dir.display()
550 );
551 }
552 if installed_docs {
553 eprintln!("Installed Codex instructions at {}", codex_dir.display());
554 } else {
555 eprintln!("Codex AGENTS.md already configured.");
556 }
557 }
558}
559
560fn install_codex_hook_config(home: &std::path::Path) -> bool {
561 let binary = resolve_binary_path();
562 let session_start_cmd = format!("{binary} hook codex-session-start");
563 let pre_tool_use_cmd = format!("{binary} hook codex-pretooluse");
564 let codex_dir = home.join(".codex");
565 let hooks_json_path = codex_dir.join("hooks.json");
566
567 let mut changed = false;
568 let mut root = if hooks_json_path.exists() {
569 match std::fs::read_to_string(&hooks_json_path)
570 .ok()
571 .and_then(|content| crate::core::jsonc::parse_jsonc(&content).ok())
572 {
573 Some(parsed) => parsed,
574 None => {
575 changed = true;
576 serde_json::json!({ "hooks": {} })
577 }
578 }
579 } else {
580 changed = true;
581 serde_json::json!({ "hooks": {} })
582 };
583
584 if upsert_lean_ctx_codex_hook_entries(&mut root, &session_start_cmd, &pre_tool_use_cmd) {
585 changed = true;
586 }
587 if changed {
588 write_file(
589 &hooks_json_path,
590 &serde_json::to_string_pretty(&root).unwrap(),
591 );
592 }
593
594 let rewrite_path = codex_dir.join("hooks").join("lean-ctx-rewrite-codex.sh");
595 if rewrite_path.exists() && std::fs::remove_file(&rewrite_path).is_ok() {
596 changed = true;
597 }
598
599 let config_toml_path = codex_dir.join("config.toml");
600 let config_content = std::fs::read_to_string(&config_toml_path).unwrap_or_default();
601 if let Some(updated) = ensure_codex_hooks_enabled(&config_content) {
602 write_file(&config_toml_path, &updated);
603 changed = true;
604 if !mcp_server_quiet_mode() {
605 eprintln!(
606 "Enabled codex_hooks feature in {}",
607 config_toml_path.display()
608 );
609 }
610 }
611
612 changed
613}
614
615fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
616 shared_ensure_codex_hooks_enabled(config_content)
617}
618
619pub(super) fn install_windsurf_rules(global: bool) {
620 let Some(rules_path) = prepare_project_rules_path(global, ".windsurfrules") else {
621 return;
622 };
623
624 let rules = include_str!("../templates/windsurfrules.txt");
625 write_file(&rules_path, rules);
626 println!("Installed .windsurfrules in current project.");
627}
628
629pub(super) fn install_cline_rules(global: bool) {
630 let Some(rules_path) = prepare_project_rules_path(global, ".clinerules") else {
631 return;
632 };
633
634 let binary = resolve_binary_path();
635 let rules = format!(
636 r#"# lean-ctx Shell Optimization
637# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
638
639When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
640- `{binary} -c git status` instead of `git status`
641- `{binary} -c cargo test` instead of `cargo test`
642- `{binary} -c ls src/` instead of `ls src/`
643
644Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
645"#
646 );
647
648 write_file(&rules_path, &rules);
649 println!("Installed .clinerules in current project.");
650}
651
652fn prepare_project_rules_path(global: bool, file_name: &str) -> Option<PathBuf> {
653 let scope = crate::core::config::Config::load().rules_scope_effective();
654 if global || scope == crate::core::config::RulesScope::Global {
655 println!(
656 "Global mode: skipping project-local {file_name} (use without --global in a project)."
657 );
658 return None;
659 }
660
661 let cwd = std::env::current_dir().unwrap_or_default();
662 if !is_inside_git_repo(&cwd) || cwd == dirs::home_dir().unwrap_or_default() {
663 eprintln!(" Skipping {file_name}: not inside a git repository or in home directory.");
664 return None;
665 }
666
667 let rules_path = PathBuf::from(file_name);
668 if rules_path.exists() {
669 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
670 if content.contains("lean-ctx") {
671 println!("{file_name} already configured.");
672 return None;
673 }
674 }
675
676 Some(rules_path)
677}
678
679pub(super) fn install_pi_hook(global: bool) {
680 let has_pi = std::process::Command::new("pi")
681 .arg("--version")
682 .output()
683 .is_ok();
684
685 if !has_pi {
686 println!("Pi Coding Agent not found in PATH.");
687 println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
688 println!();
689 }
690
691 println!("Installing pi-lean-ctx Pi Package...");
692 println!();
693
694 let install_result = std::process::Command::new("pi")
695 .args(["install", "npm:pi-lean-ctx"])
696 .status();
697
698 match install_result {
699 Ok(status) if status.success() => {
700 println!("Installed pi-lean-ctx Pi Package.");
701 }
702 _ => {
703 println!("Could not auto-install pi-lean-ctx. Install manually:");
704 println!(" pi install npm:pi-lean-ctx");
705 println!();
706 }
707 }
708
709 write_pi_mcp_config();
710
711 let scope = crate::core::config::Config::load().rules_scope_effective();
712 let skip_project = global || scope == crate::core::config::RulesScope::Global;
713
714 if !skip_project {
715 let agents_md = PathBuf::from("AGENTS.md");
716 if !agents_md.exists()
717 || !std::fs::read_to_string(&agents_md)
718 .unwrap_or_default()
719 .contains("lean-ctx")
720 {
721 let content = include_str!("../templates/PI_AGENTS.md");
722 write_file(&agents_md, content);
723 println!("Created AGENTS.md in current project directory.");
724 } else {
725 println!("AGENTS.md already contains lean-ctx configuration.");
726 }
727 } else {
728 println!(
729 "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
730 );
731 }
732
733 println!();
734 println!("Setup complete. All Pi tools (bash, read, grep, find, ls) route through lean-ctx.");
735 println!("MCP tools (ctx_session, ctx_knowledge, ctx_semantic_search, ...) also available.");
736 println!("Use /lean-ctx in Pi to verify the binary path and MCP status.");
737}
738
739fn write_pi_mcp_config() {
740 let home = match dirs::home_dir() {
741 Some(h) => h,
742 None => return,
743 };
744
745 let mcp_config_path = home.join(".pi/agent/mcp.json");
746
747 if !home.join(".pi/agent").exists() {
748 println!(" \x1b[2m○ ~/.pi/agent/ not found — skipping MCP config\x1b[0m");
749 return;
750 }
751
752 if mcp_config_path.exists() {
753 let content = match std::fs::read_to_string(&mcp_config_path) {
754 Ok(c) => c,
755 Err(_) => return,
756 };
757 if content.contains("lean-ctx") {
758 println!(" \x1b[32m✓\x1b[0m Pi MCP config already contains lean-ctx");
759 return;
760 }
761
762 if let Ok(mut json) = crate::core::jsonc::parse_jsonc(&content) {
763 if let Some(obj) = json.as_object_mut() {
764 let servers = obj
765 .entry("mcpServers")
766 .or_insert_with(|| serde_json::json!({}));
767 if let Some(servers_obj) = servers.as_object_mut() {
768 servers_obj.insert("lean-ctx".to_string(), pi_mcp_server_entry());
769 }
770 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
771 let _ = std::fs::write(&mcp_config_path, formatted);
772 println!(
773 " \x1b[32m✓\x1b[0m Added lean-ctx to Pi MCP config (~/.pi/agent/mcp.json)"
774 );
775 }
776 }
777 }
778 return;
779 }
780
781 let content = serde_json::json!({
782 "mcpServers": {
783 "lean-ctx": pi_mcp_server_entry()
784 }
785 });
786 if let Ok(formatted) = serde_json::to_string_pretty(&content) {
787 let _ = std::fs::write(&mcp_config_path, formatted);
788 println!(" \x1b[32m✓\x1b[0m Created Pi MCP config (~/.pi/agent/mcp.json)");
789 }
790}
791
792fn pi_mcp_server_entry() -> serde_json::Value {
793 let binary = resolve_binary_path();
794 let mut entry = full_server_entry(&binary);
795 if let Some(obj) = entry.as_object_mut() {
796 obj.insert("lifecycle".to_string(), serde_json::json!("lazy"));
797 obj.insert("directTools".to_string(), serde_json::json!(true));
798 }
799 entry
800}
801
802pub(super) fn install_copilot_hook(global: bool) {
803 let binary = resolve_binary_path();
804
805 if global {
806 let mcp_path = crate::core::editor_registry::vscode_mcp_path();
807 if mcp_path.as_os_str() == "/nonexistent" {
808 println!(" \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
809 return;
810 }
811 write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
812 install_copilot_pretooluse_hook(true);
813 } else {
814 let vscode_dir = PathBuf::from(".vscode");
815 let _ = std::fs::create_dir_all(&vscode_dir);
816 let mcp_path = vscode_dir.join("mcp.json");
817 write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
818 install_copilot_pretooluse_hook(false);
819 }
820}
821
822fn install_copilot_pretooluse_hook(global: bool) {
823 let binary = resolve_binary_path();
824 let rewrite_cmd = format!("{binary} hook rewrite");
825 let redirect_cmd = format!("{binary} hook redirect");
826
827 let hook_config = serde_json::json!({
828 "version": 1,
829 "hooks": {
830 "preToolUse": [
831 {
832 "type": "command",
833 "bash": rewrite_cmd,
834 "timeoutSec": 15
835 },
836 {
837 "type": "command",
838 "bash": redirect_cmd,
839 "timeoutSec": 5
840 }
841 ]
842 }
843 });
844
845 let hook_path = if global {
846 let Some(home) = dirs::home_dir() else { return };
847 let dir = home.join(".github").join("hooks");
848 let _ = std::fs::create_dir_all(&dir);
849 dir.join("hooks.json")
850 } else {
851 let dir = PathBuf::from(".github").join("hooks");
852 let _ = std::fs::create_dir_all(&dir);
853 dir.join("hooks.json")
854 };
855
856 let needs_write = if hook_path.exists() {
857 let content = std::fs::read_to_string(&hook_path).unwrap_or_default();
858 !content.contains("hook rewrite") || content.contains("\"PreToolUse\"")
859 } else {
860 true
861 };
862
863 if !needs_write {
864 return;
865 }
866
867 if hook_path.exists() {
868 if let Ok(mut existing) = crate::core::jsonc::parse_jsonc(
869 &std::fs::read_to_string(&hook_path).unwrap_or_default(),
870 ) {
871 if let Some(obj) = existing.as_object_mut() {
872 obj.insert("version".to_string(), serde_json::json!(1));
873 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
874 write_file(
875 &hook_path,
876 &serde_json::to_string_pretty(&existing).unwrap(),
877 );
878 if !mcp_server_quiet_mode() {
879 println!("Updated Copilot hooks at {}", hook_path.display());
880 }
881 return;
882 }
883 }
884 }
885
886 write_file(
887 &hook_path,
888 &serde_json::to_string_pretty(&hook_config).unwrap(),
889 );
890 if !mcp_server_quiet_mode() {
891 println!("Installed Copilot hooks at {}", hook_path.display());
892 }
893}
894
895fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
896 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
897 .map(|d| d.to_string_lossy().to_string())
898 .unwrap_or_default();
899 let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
900 if mcp_path.exists() {
901 let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
902 match crate::core::jsonc::parse_jsonc(&content) {
903 Ok(mut json) => {
904 if let Some(obj) = json.as_object_mut() {
905 let servers = obj
906 .entry("servers")
907 .or_insert_with(|| serde_json::json!({}));
908 if let Some(servers_obj) = servers.as_object_mut() {
909 if servers_obj.get("lean-ctx") == Some(&desired) {
910 println!(" \x1b[32m✓\x1b[0m Copilot already configured in {label}");
911 return;
912 }
913 servers_obj.insert("lean-ctx".to_string(), desired);
914 }
915 write_file(
916 mcp_path,
917 &serde_json::to_string_pretty(&json).unwrap_or_default(),
918 );
919 println!(" \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
920 return;
921 }
922 }
923 Err(e) => {
924 eprintln!(
925 "Could not parse VS Code MCP config at {}: {e}\nAdd to \"servers\": \"lean-ctx\": {{ \"command\": \"{}\", \"args\": [] }}",
926 mcp_path.display(),
927 binary
928 );
929 return;
930 }
931 };
932 }
933
934 if let Some(parent) = mcp_path.parent() {
935 let _ = std::fs::create_dir_all(parent);
936 }
937
938 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
939 .map(|d| d.to_string_lossy().to_string())
940 .unwrap_or_default();
941 let config = serde_json::json!({
942 "servers": {
943 "lean-ctx": {
944 "type": "stdio",
945 "command": binary,
946 "args": [],
947 "env": { "LEAN_CTX_DATA_DIR": data_dir }
948 }
949 }
950 });
951
952 write_file(
953 mcp_path,
954 &serde_json::to_string_pretty(&config).unwrap_or_default(),
955 );
956 println!(" \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
957}
958
959pub(super) fn install_amp_hook() {
960 let binary = resolve_binary_path();
961 let home = dirs::home_dir().unwrap_or_default();
962 let config_path = home.join(".config/amp/settings.json");
963 let display_path = "~/.config/amp/settings.json";
964
965 if let Some(parent) = config_path.parent() {
966 let _ = std::fs::create_dir_all(parent);
967 }
968
969 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
970 .map(|d| d.to_string_lossy().to_string())
971 .unwrap_or_default();
972 let entry = serde_json::json!({
973 "command": binary,
974 "env": { "LEAN_CTX_DATA_DIR": data_dir }
975 });
976 install_named_json_server("Amp", display_path, &config_path, "amp.mcpServers", entry);
977}
978
979pub(super) fn install_jetbrains_hook() {
980 let binary = resolve_binary_path();
981 let home = dirs::home_dir().unwrap_or_default();
982 let config_path = home.join(".jb-mcp.json");
983 let display_path = "~/.jb-mcp.json";
984
985 let entry = serde_json::json!({
986 "name": "lean-ctx",
987 "command": binary,
988 "args": [],
989 "env": {
990 "LEAN_CTX_DATA_DIR": crate::core::data_dir::lean_ctx_data_dir()
991 .map(|d| d.to_string_lossy().to_string())
992 .unwrap_or_default()
993 }
994 });
995
996 if config_path.exists() {
997 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
998 if content.contains("lean-ctx") {
999 println!("JetBrains MCP already configured at {display_path}");
1000 return;
1001 }
1002
1003 if let Ok(mut json) = crate::core::jsonc::parse_jsonc(&content) {
1004 if let Some(obj) = json.as_object_mut() {
1005 let servers = obj
1006 .entry("servers")
1007 .or_insert_with(|| serde_json::json!([]));
1008 if let Some(arr) = servers.as_array_mut() {
1009 arr.push(entry.clone());
1010 }
1011 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1012 let _ = std::fs::write(&config_path, formatted);
1013 println!(" \x1b[32m✓\x1b[0m JetBrains MCP configured at {display_path}");
1014 return;
1015 }
1016 }
1017 }
1018 }
1019
1020 let config = serde_json::json!({ "servers": [entry] });
1021 if let Ok(json_str) = serde_json::to_string_pretty(&config) {
1022 let _ = std::fs::write(&config_path, json_str);
1023 println!(" \x1b[32m✓\x1b[0m JetBrains MCP configured at {display_path}");
1024 } else {
1025 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure JetBrains");
1026 }
1027}
1028
1029pub(super) fn install_opencode_hook() {
1030 let binary = resolve_binary_path();
1031 let home = dirs::home_dir().unwrap_or_default();
1032 let config_path = home.join(".config/opencode/opencode.json");
1033 let display_path = "~/.config/opencode/opencode.json";
1034
1035 if let Some(parent) = config_path.parent() {
1036 let _ = std::fs::create_dir_all(parent);
1037 }
1038
1039 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1040 .map(|d| d.to_string_lossy().to_string())
1041 .unwrap_or_default();
1042 let desired = serde_json::json!({
1043 "type": "local",
1044 "command": [&binary],
1045 "enabled": true,
1046 "environment": { "LEAN_CTX_DATA_DIR": data_dir }
1047 });
1048
1049 if config_path.exists() {
1050 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1051 if content.contains("lean-ctx") {
1052 println!("OpenCode MCP already configured at {display_path}");
1053 } else if let Ok(mut json) = crate::core::jsonc::parse_jsonc(&content) {
1054 if let Some(obj) = json.as_object_mut() {
1055 let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1056 if let Some(mcp_obj) = mcp.as_object_mut() {
1057 mcp_obj.insert("lean-ctx".to_string(), desired.clone());
1058 }
1059 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1060 let _ = std::fs::write(&config_path, formatted);
1061 println!(" \x1b[32m✓\x1b[0m OpenCode MCP configured at {display_path}");
1062 }
1063 }
1064 }
1065 } else {
1066 let content = serde_json::to_string_pretty(&serde_json::json!({
1067 "$schema": "https://opencode.ai/config.json",
1068 "mcp": {
1069 "lean-ctx": desired
1070 }
1071 }));
1072
1073 if let Ok(json_str) = content {
1074 let _ = std::fs::write(&config_path, json_str);
1075 println!(" \x1b[32m✓\x1b[0m OpenCode MCP configured at {display_path}");
1076 } else {
1077 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure OpenCode");
1078 }
1079 }
1080
1081 install_opencode_plugin(&home);
1082}
1083
1084fn install_opencode_plugin(home: &std::path::Path) {
1085 let plugin_dir = home.join(".config/opencode/plugins");
1086 let _ = std::fs::create_dir_all(&plugin_dir);
1087 let plugin_path = plugin_dir.join("lean-ctx.ts");
1088
1089 let plugin_content = include_str!("../templates/opencode-plugin.ts");
1090 let _ = std::fs::write(&plugin_path, plugin_content);
1091
1092 if !mcp_server_quiet_mode() {
1093 println!(
1094 " \x1b[32m✓\x1b[0m OpenCode plugin installed at {}",
1095 plugin_path.display()
1096 );
1097 }
1098}
1099
1100pub(super) fn install_crush_hook() {
1101 let binary = resolve_binary_path();
1102 let home = dirs::home_dir().unwrap_or_default();
1103 let config_path = home.join(".config/crush/crush.json");
1104 let display_path = "~/.config/crush/crush.json";
1105
1106 if let Some(parent) = config_path.parent() {
1107 let _ = std::fs::create_dir_all(parent);
1108 }
1109
1110 if config_path.exists() {
1111 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1112 if content.contains("lean-ctx") {
1113 println!("Crush MCP already configured at {display_path}");
1114 return;
1115 }
1116
1117 if let Ok(mut json) = crate::core::jsonc::parse_jsonc(&content) {
1118 if let Some(obj) = json.as_object_mut() {
1119 let servers = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1120 if let Some(servers_obj) = servers.as_object_mut() {
1121 servers_obj.insert(
1122 "lean-ctx".to_string(),
1123 serde_json::json!({ "type": "stdio", "command": binary }),
1124 );
1125 }
1126 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1127 let _ = std::fs::write(&config_path, formatted);
1128 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1129 return;
1130 }
1131 }
1132 }
1133 }
1134
1135 let content = serde_json::to_string_pretty(&serde_json::json!({
1136 "mcp": {
1137 "lean-ctx": {
1138 "type": "stdio",
1139 "command": binary
1140 }
1141 }
1142 }));
1143
1144 if let Ok(json_str) = content {
1145 let _ = std::fs::write(&config_path, json_str);
1146 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1147 } else {
1148 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure Crush");
1149 }
1150}
1151
1152pub(super) fn install_kiro_hook() {
1153 let home = dirs::home_dir().unwrap_or_default();
1154
1155 install_mcp_json_agent(
1156 "AWS Kiro",
1157 "~/.kiro/settings/mcp.json",
1158 &home.join(".kiro/settings/mcp.json"),
1159 );
1160
1161 let cwd = std::env::current_dir().unwrap_or_default();
1162 let steering_dir = cwd.join(".kiro").join("steering");
1163 let steering_file = steering_dir.join("lean-ctx.md");
1164
1165 if steering_file.exists()
1166 && std::fs::read_to_string(&steering_file)
1167 .unwrap_or_default()
1168 .contains("lean-ctx")
1169 {
1170 println!(" Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1171 } else {
1172 let _ = std::fs::create_dir_all(&steering_dir);
1173 write_file(&steering_file, KIRO_STEERING_TEMPLATE);
1174 println!(" \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1175 }
1176}
1177
1178pub(super) fn install_hermes_hook(global: bool) {
1179 let home = match dirs::home_dir() {
1180 Some(h) => h,
1181 None => {
1182 eprintln!("Cannot resolve home directory");
1183 return;
1184 }
1185 };
1186
1187 let binary = resolve_binary_path();
1188 let config_path = home.join(".hermes/config.yaml");
1189 let target = crate::core::editor_registry::EditorTarget {
1190 name: "Hermes Agent",
1191 agent_key: "hermes".to_string(),
1192 config_path: config_path.clone(),
1193 detect_path: home.join(".hermes"),
1194 config_type: crate::core::editor_registry::ConfigType::HermesYaml,
1195 };
1196
1197 match crate::core::editor_registry::write_config_with_options(
1198 &target,
1199 &binary,
1200 crate::core::editor_registry::WriteOptions {
1201 overwrite_invalid: true,
1202 },
1203 ) {
1204 Ok(res) => match res.action {
1205 crate::core::editor_registry::WriteAction::Created => {
1206 println!(" \x1b[32m✓\x1b[0m Hermes Agent MCP configured at ~/.hermes/config.yaml");
1207 }
1208 crate::core::editor_registry::WriteAction::Updated => {
1209 println!(" \x1b[32m✓\x1b[0m Hermes Agent MCP updated at ~/.hermes/config.yaml");
1210 }
1211 crate::core::editor_registry::WriteAction::Already => {
1212 println!(" Hermes Agent MCP already configured at ~/.hermes/config.yaml");
1213 }
1214 },
1215 Err(e) => {
1216 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure Hermes Agent MCP: {e}");
1217 }
1218 }
1219
1220 let scope = crate::core::config::Config::load().rules_scope_effective();
1221
1222 match scope {
1223 crate::core::config::RulesScope::Global => {
1224 install_hermes_rules(&home);
1225 }
1226 crate::core::config::RulesScope::Project => {
1227 if !global {
1228 install_project_hermes_rules();
1229 install_project_rules();
1230 }
1231 }
1232 crate::core::config::RulesScope::Both => {
1233 if global {
1234 install_hermes_rules(&home);
1235 } else {
1236 install_hermes_rules(&home);
1237 install_project_hermes_rules();
1238 install_project_rules();
1239 }
1240 }
1241 }
1242}
1243
1244fn install_hermes_rules(home: &std::path::Path) {
1245 let rules_path = home.join(".hermes/HERMES.md");
1246 let content = HERMES_RULES_TEMPLATE;
1247
1248 if rules_path.exists() {
1249 let existing = std::fs::read_to_string(&rules_path).unwrap_or_default();
1250 if existing.contains("lean-ctx") {
1251 println!(" Hermes rules already present in ~/.hermes/HERMES.md");
1252 return;
1253 }
1254 let mut updated = existing;
1255 if !updated.ends_with('\n') {
1256 updated.push('\n');
1257 }
1258 updated.push('\n');
1259 updated.push_str(content);
1260 let _ = std::fs::write(&rules_path, updated);
1261 println!(" \x1b[32m✓\x1b[0m Appended lean-ctx rules to ~/.hermes/HERMES.md");
1262 } else {
1263 let _ = std::fs::create_dir_all(rules_path.parent().unwrap());
1264 let _ = std::fs::write(&rules_path, content);
1265 println!(" \x1b[32m✓\x1b[0m Created ~/.hermes/HERMES.md with lean-ctx rules");
1266 }
1267}
1268
1269fn install_project_hermes_rules() {
1270 let cwd = match std::env::current_dir() {
1271 Ok(d) => d,
1272 Err(_) => return,
1273 };
1274 let rules_path = cwd.join(".hermes.md");
1275 if rules_path.exists() {
1276 let existing = std::fs::read_to_string(&rules_path).unwrap_or_default();
1277 if existing.contains("lean-ctx") {
1278 println!(" .hermes.md already contains lean-ctx rules");
1279 return;
1280 }
1281 let mut updated = existing;
1282 if !updated.ends_with('\n') {
1283 updated.push('\n');
1284 }
1285 updated.push('\n');
1286 updated.push_str(HERMES_RULES_TEMPLATE);
1287 let _ = std::fs::write(&rules_path, updated);
1288 println!(" \x1b[32m✓\x1b[0m Appended lean-ctx rules to .hermes.md");
1289 } else {
1290 let _ = std::fs::write(&rules_path, HERMES_RULES_TEMPLATE);
1291 println!(" \x1b[32m✓\x1b[0m Created .hermes.md with lean-ctx rules");
1292 }
1293}
1294
1295const HERMES_RULES_TEMPLATE: &str = "\
1296# lean-ctx — Context Engineering Layer
1297
1298PREFER lean-ctx MCP tools over native equivalents for token savings:
1299
1300| PREFER | OVER | Why |
1301|--------|------|-----|
1302| `ctx_read(path, mode)` | `Read` / `cat` | Cached, 10 read modes, re-reads ~13 tokens |
1303| `ctx_shell(command)` | `Shell` / `bash` | Pattern compression for git/npm/cargo output |
1304| `ctx_search(pattern, path)` | `Grep` / `rg` | Compact search results |
1305| `ctx_tree(path, depth)` | `ls` / `find` | Compact directory maps |
1306
1307- Native Edit/StrReplace stay unchanged. If Edit requires Read and Read is unavailable, use `ctx_edit(path, old_string, new_string)`.
1308- Write, Delete, Glob — use normally.
1309
1310ctx_read modes: full|map|signatures|diff|task|reference|aggressive|entropy|lines:N-M. Auto-selects optimal mode.
1311Re-reads cost ~13 tokens (cached).
1312
1313Available tools: ctx_overview, ctx_preload, ctx_dedup, ctx_compress, ctx_session, ctx_knowledge, ctx_semantic_search.
1314Multi-agent: ctx_agent(action=handoff|sync). Diary: ctx_agent(action=diary, category=discovery|decision|blocker|progress|insight).
1315";
1316
1317#[cfg(test)]
1318mod tests {
1319 use super::{ensure_codex_hooks_enabled, upsert_lean_ctx_codex_hook_entries};
1320 use serde_json::json;
1321
1322 #[test]
1323 fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
1324 let mut input = json!({
1325 "hooks": {
1326 "PreToolUse": [
1327 {
1328 "matcher": "Bash",
1329 "hooks": [{
1330 "type": "command",
1331 "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
1332 "timeout": 15
1333 }]
1334 },
1335 {
1336 "matcher": "Bash",
1337 "hooks": [{
1338 "type": "command",
1339 "command": "echo keep-me",
1340 "timeout": 5
1341 }]
1342 }
1343 ],
1344 "SessionStart": [
1345 {
1346 "matcher": "startup|resume|clear",
1347 "hooks": [{
1348 "type": "command",
1349 "command": "lean-ctx hook codex-session-start",
1350 "timeout": 15
1351 }]
1352 }
1353 ],
1354 "PostToolUse": [
1355 {
1356 "matcher": "Bash",
1357 "hooks": [{
1358 "type": "command",
1359 "command": "echo keep-post",
1360 "timeout": 5
1361 }]
1362 }
1363 ]
1364 }
1365 });
1366
1367 let changed = upsert_lean_ctx_codex_hook_entries(
1368 &mut input,
1369 "lean-ctx hook codex-session-start",
1370 "lean-ctx hook codex-pretooluse",
1371 );
1372 assert!(changed, "legacy hooks should be migrated");
1373
1374 let pre_tool_use = input["hooks"]["PreToolUse"]
1375 .as_array()
1376 .expect("PreToolUse array should remain");
1377 assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
1378 assert_eq!(
1379 pre_tool_use[0]["hooks"][0]["command"].as_str(),
1380 Some("echo keep-me")
1381 );
1382 assert_eq!(
1383 pre_tool_use[1]["hooks"][0]["command"].as_str(),
1384 Some("lean-ctx hook codex-pretooluse")
1385 );
1386 assert_eq!(
1387 input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
1388 Some("lean-ctx hook codex-session-start")
1389 );
1390 assert_eq!(
1391 input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
1392 Some("echo keep-post")
1393 );
1394 }
1395
1396 #[test]
1397 fn ignores_non_lean_ctx_codex_entries() {
1398 let custom = json!({
1399 "matcher": "Bash",
1400 "hooks": [{
1401 "type": "command",
1402 "command": "echo keep-me",
1403 "timeout": 5
1404 }]
1405 });
1406 assert!(
1407 !super::super::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
1408 "custom Codex hooks must be preserved"
1409 );
1410 }
1411
1412 #[test]
1413 fn detects_managed_codex_session_start_entry() {
1414 let managed = json!({
1415 "matcher": "startup|resume|clear",
1416 "hooks": [{
1417 "type": "command",
1418 "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
1419 "timeout": 15
1420 }]
1421 });
1422 assert!(super::super::support::is_lean_ctx_codex_managed_entry(
1423 "SessionStart",
1424 &managed
1425 ));
1426 }
1427
1428 #[test]
1429 fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
1430 let input = "\
1431[features]
1432other = true
1433codex_hooks = false
1434
1435[mcp_servers.other]
1436command = \"other\"
1437";
1438
1439 let output =
1440 ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
1441
1442 assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
1443 assert!(!output.contains("codex_hooks = false"));
1444 }
1445
1446 #[test]
1447 fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
1448 let input = "\
1449[features]
1450other = true
1451
1452[mcp_servers.lean-ctx]
1453command = \"lean-ctx\"
1454codex_hooks = true
1455";
1456
1457 let output = ensure_codex_hooks_enabled(input)
1458 .expect("stray codex_hooks assignment should be normalized");
1459
1460 assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
1461 assert_eq!(output.matches("codex_hooks = true").count(), 1);
1462 assert!(
1463 !output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\ncodex_hooks = true")
1464 );
1465 }
1466
1467 #[test]
1468 fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
1469 let input = "\
1470[mcp_servers.lean-ctx]
1471command = \"lean-ctx\"
1472";
1473
1474 let output =
1475 ensure_codex_hooks_enabled(input).expect("missing features section should be added");
1476
1477 assert!(output.ends_with("\n[features]\ncodex_hooks = true\n"));
1478 }
1479}