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