Skip to main content

lean_ctx/cli/
shell_init.rs

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