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