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