lean_ctx/cli/
shell_init.rs1use crate::hooks::to_bash_compatible_path;
2
3fn quiet_enabled() -> bool {
4 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
5}
6
7macro_rules! qprintln {
8 ($($t:tt)*) => {
9 if !quiet_enabled() {
10 println!($($t)*);
11 }
12 };
13}
14
15pub fn cmd_init(args: &[String]) {
16 let global = args.iter().any(|a| a == "--global" || a == "-g");
17 let dry_run = args.iter().any(|a| a == "--dry-run");
18
19 let agents: Vec<&str> = args
20 .windows(2)
21 .filter(|w| w[0] == "--agent")
22 .map(|w| w[1].as_str())
23 .collect();
24
25 if !agents.is_empty() {
26 for agent_name in &agents {
27 crate::hooks::install_agent_hook(agent_name, global);
28 if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
29 eprintln!("MCP config for '{agent_name}' not updated: {e}");
30 }
31 }
32 if !global {
33 crate::hooks::install_project_rules();
34 }
35 qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
36 return;
37 }
38
39 let shell_name = std::env::var("SHELL").unwrap_or_default();
40 let is_zsh = shell_name.contains("zsh");
41 let is_fish = shell_name.contains("fish");
42 let is_powershell = cfg!(windows) && shell_name.is_empty();
43
44 let binary = std::env::current_exe()
45 .map(|p| p.to_string_lossy().to_string())
46 .unwrap_or_else(|_| "lean-ctx".to_string());
47
48 if dry_run {
49 let rc = if is_powershell {
50 "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
51 } else if is_fish {
52 "~/.config/fish/config.fish".to_string()
53 } else if is_zsh {
54 "~/.zshrc".to_string()
55 } else {
56 "~/.bashrc".to_string()
57 };
58 qprintln!("\nlean-ctx init --dry-run\n");
59 qprintln!(" Would modify: {rc}");
60 qprintln!(" Would backup: {rc}.lean-ctx.bak");
61 qprintln!(" Would alias: git npm pnpm yarn cargo docker docker-compose kubectl");
62 qprintln!(" gh pip pip3 ruff go golangci-lint eslint prettier tsc");
63 qprintln!(" ls find grep curl wget php composer (24 commands + k)");
64 qprintln!(" Would create: ~/.lean-ctx/");
65 qprintln!(" Binary: {binary}");
66 qprintln!("\n Safety: aliases auto-fallback to original command if lean-ctx is removed.");
67 qprintln!("\n Run without --dry-run to apply.");
68 return;
69 }
70
71 if is_powershell {
72 init_powershell(&binary);
73 } else {
74 let bash_binary = to_bash_compatible_path(&binary);
75 if is_fish {
76 init_fish(&bash_binary);
77 } else {
78 init_posix(is_zsh, &bash_binary);
79 }
80 }
81
82 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
83 if let Some(dir) = lean_dir {
84 if !dir.exists() {
85 let _ = std::fs::create_dir_all(&dir);
86 qprintln!("Created {}", dir.display());
87 }
88 }
89
90 let rc = if is_powershell {
91 "$PROFILE"
92 } else if is_fish {
93 "config.fish"
94 } else if is_zsh {
95 ".zshrc"
96 } else {
97 ".bashrc"
98 };
99
100 qprintln!("\nlean-ctx init complete (24 aliases installed)");
101 qprintln!();
102 qprintln!(" Disable temporarily: lean-ctx-off");
103 qprintln!(" Re-enable: lean-ctx-on");
104 qprintln!(" Check status: lean-ctx-status");
105 qprintln!(" Full uninstall: lean-ctx uninstall");
106 qprintln!(" Diagnose issues: lean-ctx doctor");
107 qprintln!(" Preview changes: lean-ctx init --global --dry-run");
108 qprintln!();
109 if is_powershell {
110 qprintln!(" Restart PowerShell or run: . {rc}");
111 } else {
112 qprintln!(" Restart your shell or run: source ~/{rc}");
113 }
114 qprintln!();
115 qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
116 qprintln!(" Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, crush, pi");
117}
118
119pub fn cmd_init_quiet(args: &[String]) {
120 std::env::set_var("LEAN_CTX_QUIET", "1");
121 cmd_init(args);
122 std::env::remove_var("LEAN_CTX_QUIET");
123}
124
125fn backup_shell_config(path: &std::path::Path) {
126 if !path.exists() {
127 return;
128 }
129 let bak = path.with_extension("lean-ctx.bak");
130 if std::fs::copy(path, &bak).is_ok() {
131 qprintln!(
132 " Backup: {}",
133 bak.file_name()
134 .map(|n| format!("~/{}", n.to_string_lossy()))
135 .unwrap_or_else(|| bak.display().to_string())
136 );
137 }
138}
139
140pub fn init_powershell(binary: &str) {
141 let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
142 let profile_path = match profile_dir {
143 Some(dir) => {
144 let _ = std::fs::create_dir_all(&dir);
145 dir.join("Microsoft.PowerShell_profile.ps1")
146 }
147 None => {
148 eprintln!("Could not resolve PowerShell profile directory");
149 return;
150 }
151 };
152
153 let binary_escaped = binary.replace('\\', "\\\\");
154 let functions = format!(
155 r#"
156# lean-ctx shell hook — transparent CLI compression (90+ patterns)
157if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED) {{
158 $LeanCtxBin = "{binary_escaped}"
159 function _lc {{
160 if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) {{ & @args; return }}
161 & $LeanCtxBin -c @args
162 if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
163 & @args
164 }}
165 }}
166 function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
167 if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
168 function git {{ _lc git @args }}
169 function cargo {{ _lc cargo @args }}
170 function docker {{ _lc docker @args }}
171 function kubectl {{ _lc kubectl @args }}
172 function gh {{ _lc gh @args }}
173 function pip {{ _lc pip @args }}
174 function pip3 {{ _lc pip3 @args }}
175 function ruff {{ _lc ruff @args }}
176 function go {{ _lc go @args }}
177 function curl {{ _lc curl @args }}
178 function wget {{ _lc wget @args }}
179 foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
180 if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
181 New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc $c @args")) -Force | Out-Null
182 }}
183 }}
184 }}
185}}
186"#
187 );
188
189 backup_shell_config(&profile_path);
190
191 if let Ok(existing) = std::fs::read_to_string(&profile_path) {
192 if existing.contains("lean-ctx shell hook") {
193 let cleaned = remove_lean_ctx_block_ps(&existing);
194 match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
195 Ok(()) => {
196 qprintln!("Updated lean-ctx functions in {}", profile_path.display());
197 qprintln!(" Binary: {binary}");
198 return;
199 }
200 Err(e) => {
201 eprintln!("Error updating {}: {e}", profile_path.display());
202 return;
203 }
204 }
205 }
206 }
207
208 match std::fs::OpenOptions::new()
209 .append(true)
210 .create(true)
211 .open(&profile_path)
212 {
213 Ok(mut f) => {
214 use std::io::Write;
215 let _ = f.write_all(functions.as_bytes());
216 qprintln!("Added lean-ctx functions to {}", profile_path.display());
217 qprintln!(" Binary: {binary}");
218 }
219 Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
220 }
221}
222
223fn remove_lean_ctx_block_ps(content: &str) -> String {
224 let mut result = String::new();
225 let mut in_block = false;
226 let mut brace_depth = 0i32;
227
228 for line in content.lines() {
229 if line.contains("lean-ctx shell hook") {
230 in_block = true;
231 continue;
232 }
233 if in_block {
234 brace_depth += line.matches('{').count() as i32;
235 brace_depth -= line.matches('}').count() as i32;
236 if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
237 if line.trim() == "}" {
238 in_block = false;
239 brace_depth = 0;
240 }
241 continue;
242 }
243 continue;
244 }
245 result.push_str(line);
246 result.push('\n');
247 }
248 result
249}
250
251pub fn init_fish(binary: &str) {
252 let config = dirs::home_dir()
253 .map(|h| h.join(".config/fish/config.fish"))
254 .unwrap_or_default();
255
256 let aliases = format!(
257 "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
258 set -g _lean_ctx_cmds git npm pnpm yarn cargo docker docker-compose kubectl gh pip pip3 ruff go golangci-lint eslint prettier tsc ls find grep curl wget\n\
259 \n\
260 function _lc\n\
261 \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
262 \t\tcommand $argv\n\
263 \t\treturn\n\
264 \tend\n\
265 \t'{binary}' -c $argv\n\
266 \tset -l _lc_rc $status\n\
267 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
268 \t\tcommand $argv\n\
269 \telse\n\
270 \t\treturn $_lc_rc\n\
271 \tend\n\
272 end\n\
273 \n\
274 function lean-ctx-on\n\
275 \tfor _lc_cmd in $_lean_ctx_cmds\n\
276 \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
277 \tend\n\
278 \talias k '_lc kubectl'\n\
279 \tset -gx LEAN_CTX_ENABLED 1\n\
280 \techo 'lean-ctx: ON'\n\
281 end\n\
282 \n\
283 function lean-ctx-off\n\
284 \tfor _lc_cmd in $_lean_ctx_cmds\n\
285 \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
286 \tend\n\
287 \tfunctions --erase k 2>/dev/null; true\n\
288 \tset -e LEAN_CTX_ENABLED\n\
289 \techo 'lean-ctx: OFF'\n\
290 end\n\
291 \n\
292 function lean-ctx-raw\n\
293 \tset -lx LEAN_CTX_RAW 1\n\
294 \tcommand $argv\n\
295 end\n\
296 \n\
297 function lean-ctx-status\n\
298 \tif set -q LEAN_CTX_DISABLED\n\
299 \t\techo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
300 \telse if set -q LEAN_CTX_ENABLED\n\
301 \t\techo 'lean-ctx: ON'\n\
302 \telse\n\
303 \t\techo 'lean-ctx: OFF'\n\
304 \tend\n\
305 end\n\
306 \n\
307 if not set -q LEAN_CTX_ACTIVE; and not set -q LEAN_CTX_DISABLED; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
308 \tif command -q lean-ctx\n\
309 \t\tlean-ctx-on\n\
310 \tend\n\
311 end\n\
312 # lean-ctx shell hook — end\n"
313 );
314
315 backup_shell_config(&config);
316
317 if let Ok(existing) = std::fs::read_to_string(&config) {
318 if existing.contains("lean-ctx shell hook") {
319 let cleaned = remove_lean_ctx_block(&existing);
320 match std::fs::write(&config, format!("{cleaned}{aliases}")) {
321 Ok(()) => {
322 qprintln!("Updated lean-ctx aliases in {}", config.display());
323 qprintln!(" Binary: {binary}");
324 return;
325 }
326 Err(e) => {
327 eprintln!("Error updating {}: {e}", config.display());
328 return;
329 }
330 }
331 }
332 }
333
334 match std::fs::OpenOptions::new()
335 .append(true)
336 .create(true)
337 .open(&config)
338 {
339 Ok(mut f) => {
340 use std::io::Write;
341 let _ = f.write_all(aliases.as_bytes());
342 qprintln!("Added lean-ctx aliases to {}", config.display());
343 qprintln!(" Binary: {binary}");
344 }
345 Err(e) => eprintln!("Error writing {}: {e}", config.display()),
346 }
347}
348
349pub fn init_posix(is_zsh: bool, binary: &str) {
350 let rc_file = if is_zsh {
351 dirs::home_dir()
352 .map(|h| h.join(".zshrc"))
353 .unwrap_or_default()
354 } else {
355 dirs::home_dir()
356 .map(|h| h.join(".bashrc"))
357 .unwrap_or_default()
358 };
359
360 let aliases = format!(
361 r#"
362# lean-ctx shell hook — transparent CLI compression (90+ patterns)
363_lean_ctx_cmds=(git npm pnpm yarn cargo docker docker-compose kubectl gh pip pip3 ruff go golangci-lint eslint prettier tsc ls find grep curl wget php composer)
364
365_lc() {{
366 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
367 command "$@"
368 return
369 fi
370 '{binary}' -c "$@"
371 local _lc_rc=$?
372 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
373 command "$@"
374 else
375 return "$_lc_rc"
376 fi
377}}
378
379lean-ctx-on() {{
380 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
381 # shellcheck disable=SC2139
382 alias "$_lc_cmd"='_lc '"$_lc_cmd"
383 done
384 alias k='_lc kubectl'
385 export LEAN_CTX_ENABLED=1
386 echo "lean-ctx: ON"
387}}
388
389lean-ctx-off() {{
390 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
391 unalias "$_lc_cmd" 2>/dev/null || true
392 done
393 unalias k 2>/dev/null || true
394 unset LEAN_CTX_ENABLED
395 echo "lean-ctx: OFF"
396}}
397
398lean-ctx-raw() {{
399 LEAN_CTX_RAW=1 command "$@"
400}}
401
402lean-ctx-status() {{
403 if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
404 echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
405 elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
406 echo "lean-ctx: ON"
407 else
408 echo "lean-ctx: OFF"
409 fi
410}}
411
412if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
413 command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
414fi
415# lean-ctx shell hook — end
416"#
417 );
418
419 backup_shell_config(&rc_file);
420
421 if let Ok(existing) = std::fs::read_to_string(&rc_file) {
422 if existing.contains("lean-ctx shell hook") {
423 let cleaned = remove_lean_ctx_block(&existing);
424 match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
425 Ok(()) => {
426 qprintln!("Updated lean-ctx aliases in {}", rc_file.display());
427 qprintln!(" Binary: {binary}");
428 return;
429 }
430 Err(e) => {
431 eprintln!("Error updating {}: {e}", rc_file.display());
432 return;
433 }
434 }
435 }
436 }
437
438 match std::fs::OpenOptions::new()
439 .append(true)
440 .create(true)
441 .open(&rc_file)
442 {
443 Ok(mut f) => {
444 use std::io::Write;
445 let _ = f.write_all(aliases.as_bytes());
446 qprintln!("Added lean-ctx aliases to {}", rc_file.display());
447 qprintln!(" Binary: {binary}");
448 }
449 Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
450 }
451
452 write_env_sh_for_containers(&aliases);
453 print_docker_env_hints(is_zsh);
454}
455
456fn write_env_sh_for_containers(aliases: &str) {
457 let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
458 Ok(d) => d.join("env.sh"),
459 Err(_) => return,
460 };
461 if let Some(parent) = env_sh.parent() {
462 let _ = std::fs::create_dir_all(parent);
463 }
464 let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
465 let mut content = sanitized_aliases;
466 content.push_str(
467 r#"
468
469# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
470if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
471 if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
472 LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
473 fi
474fi
475"#,
476 );
477 match std::fs::write(&env_sh, content) {
478 Ok(()) => qprintln!(" env.sh: {}", env_sh.display()),
479 Err(e) => eprintln!(" Warning: could not write {}: {e}", env_sh.display()),
480 }
481}
482
483fn print_docker_env_hints(is_zsh: bool) {
484 if is_zsh || !crate::shell::is_container() {
485 return;
486 }
487 let env_sh = crate::core::data_dir::lean_ctx_data_dir()
488 .map(|d| d.join("env.sh").to_string_lossy().to_string())
489 .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
490
491 let has_bash_env = std::env::var("BASH_ENV").is_ok();
492 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
493
494 if has_bash_env && has_claude_env {
495 return;
496 }
497
498 eprintln!();
499 eprintln!(" \x1b[33m⚠ Docker detected — environment hints:\x1b[0m");
500
501 if !has_bash_env {
502 eprintln!(" For generic bash -c usage (non-interactive shells):");
503 eprintln!(" \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
504 }
505 if !has_claude_env {
506 eprintln!(" For Claude Code (sources before each command):");
507 eprintln!(" \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
508 }
509 eprintln!();
510}
511
512fn remove_lean_ctx_block(content: &str) -> String {
513 if content.contains("# lean-ctx shell hook — end") {
515 return remove_lean_ctx_block_by_marker(content);
516 }
517 remove_lean_ctx_block_legacy(content)
518}
519
520fn remove_lean_ctx_block_by_marker(content: &str) -> String {
521 let mut result = String::new();
522 let mut in_block = false;
523
524 for line in content.lines() {
525 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
526 in_block = true;
527 continue;
528 }
529 if in_block {
530 if line.trim() == "# lean-ctx shell hook — end" {
531 in_block = false;
532 }
533 continue;
534 }
535 result.push_str(line);
536 result.push('\n');
537 }
538 result
539}
540
541fn remove_lean_ctx_block_legacy(content: &str) -> String {
542 let mut result = String::new();
543 let mut in_block = false;
544
545 for line in content.lines() {
546 if line.contains("lean-ctx shell hook") {
547 in_block = true;
548 continue;
549 }
550 if in_block {
551 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
552 if line.trim() == "fi" || line.trim() == "end" {
553 in_block = false;
554 }
555 continue;
556 }
557 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
558 in_block = false;
559 result.push_str(line);
560 result.push('\n');
561 }
562 continue;
563 }
564 result.push_str(line);
565 result.push('\n');
566 }
567 result
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn test_remove_lean_ctx_block_posix() {
576 let input = r#"# existing config
577export PATH="$HOME/bin:$PATH"
578
579# lean-ctx shell hook — transparent CLI compression (90+ patterns)
580if [ -z "$LEAN_CTX_ACTIVE" ]; then
581alias git='lean-ctx -c git'
582alias npm='lean-ctx -c npm'
583fi
584
585# other stuff
586export EDITOR=vim
587"#;
588 let result = remove_lean_ctx_block(input);
589 assert!(!result.contains("lean-ctx"), "block should be removed");
590 assert!(result.contains("export PATH"), "other content preserved");
591 assert!(
592 result.contains("export EDITOR"),
593 "trailing content preserved"
594 );
595 }
596
597 #[test]
598 fn test_remove_lean_ctx_block_fish() {
599 let input = "# other fish config\nset -x FOO bar\n\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\nif not set -q LEAN_CTX_ACTIVE\n\talias git 'lean-ctx -c git'\n\talias npm 'lean-ctx -c npm'\nend\n\n# more config\nset -x BAZ qux\n";
600 let result = remove_lean_ctx_block(input);
601 assert!(!result.contains("lean-ctx"), "block should be removed");
602 assert!(result.contains("set -x FOO"), "other content preserved");
603 assert!(result.contains("set -x BAZ"), "trailing content preserved");
604 }
605
606 #[test]
607 fn test_remove_lean_ctx_block_ps() {
608 let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\nif (-not $env:LEAN_CTX_ACTIVE) {\n $LeanCtxBin = \"C:\\\\bin\\\\lean-ctx.exe\"\n function git { & $LeanCtxBin -c \"git $($args -join ' ')\" }\n}\n\n# other stuff\n$env:EDITOR = 'vim'\n";
609 let result = remove_lean_ctx_block_ps(input);
610 assert!(
611 !result.contains("lean-ctx shell hook"),
612 "block should be removed"
613 );
614 assert!(result.contains("$env:FOO"), "other content preserved");
615 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
616 }
617
618 #[test]
619 fn test_remove_lean_ctx_block_ps_nested() {
620 let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\nif (-not $env:LEAN_CTX_ACTIVE) {\n $LeanCtxBin = \"lean-ctx\"\n function _lc {\n & $LeanCtxBin -c \"$($args -join ' ')\"\n }\n if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {\n function git { _lc git @args }\n foreach ($c in @('npm','pnpm')) {\n if ($a) {\n Set-Variable -Name \"_lc_$c\" -Value $a.Source -Scope Script\n }\n }\n }\n}\n\n# other stuff\n$env:EDITOR = 'vim'\n";
621 let result = remove_lean_ctx_block_ps(input);
622 assert!(
623 !result.contains("lean-ctx shell hook"),
624 "block should be removed"
625 );
626 assert!(!result.contains("_lc"), "function should be removed");
627 assert!(result.contains("$env:FOO"), "other content preserved");
628 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
629 }
630
631 #[test]
632 fn test_remove_block_no_lean_ctx() {
633 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
634 let result = remove_lean_ctx_block(input);
635 assert!(result.contains("export PATH"), "content unchanged");
636 }
637
638 #[test]
639 fn test_bash_hook_contains_pipe_guard() {
640 let binary = "/usr/local/bin/lean-ctx";
641 let hook = format!(
642 r#"_lc() {{
643 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
644 command "$@"
645 return
646 fi
647 '{binary}' -c "$@"
648}}"#
649 );
650 assert!(
651 hook.contains("! -t 1"),
652 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
653 );
654 assert!(
655 hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
656 "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
657 );
658 }
659
660 #[test]
661 fn test_fish_hook_contains_pipe_guard() {
662 let hook = "function _lc\n\tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\t\tcommand $argv\n\t\treturn\n\tend\nend";
663 assert!(
664 hook.contains("isatty stdout"),
665 "fish hook must contain pipe guard (isatty stdout)"
666 );
667 }
668
669 #[test]
670 fn test_powershell_hook_contains_pipe_guard() {
671 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
672 assert!(
673 hook.contains("IsOutputRedirected"),
674 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
675 );
676 }
677
678 #[test]
679 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
680 let input = r#"# existing config
681export PATH="$HOME/bin:$PATH"
682
683# lean-ctx shell hook — transparent CLI compression (90+ patterns)
684_lean_ctx_cmds=(git npm pnpm)
685
686lean-ctx-on() {
687 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
688 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
689 done
690 export LEAN_CTX_ENABLED=1
691 echo "lean-ctx: ON"
692}
693
694lean-ctx-off() {
695 unset LEAN_CTX_ENABLED
696 echo "lean-ctx: OFF"
697}
698
699if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
700 lean-ctx-on
701fi
702# lean-ctx shell hook — end
703
704# other stuff
705export EDITOR=vim
706"#;
707 let result = remove_lean_ctx_block(input);
708 assert!(!result.contains("lean-ctx-on"), "block should be removed");
709 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
710 assert!(result.contains("export PATH"), "other content preserved");
711 assert!(
712 result.contains("export EDITOR"),
713 "trailing content preserved"
714 );
715 }
716
717 #[test]
718 fn env_sh_for_containers_includes_self_heal() {
719 let _g = crate::core::data_dir::test_env_lock();
720 let tmp = tempfile::tempdir().expect("tempdir");
721 let data_dir = tmp.path().join("data");
722 std::fs::create_dir_all(&data_dir).expect("mkdir data");
723 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
724
725 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
726 let env_sh = data_dir.join("env.sh");
727 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
728 assert!(content.contains("lean-ctx docker self-heal"));
729 assert!(content.contains("claude mcp list"));
730 assert!(content.contains("lean-ctx init --agent claude"));
731
732 std::env::remove_var("LEAN_CTX_DATA_DIR");
733 }
734}