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