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