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