Skip to main content

lean_ctx/cli/
shell_init.rs

1macro_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}