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(()) => {
52            #[cfg(unix)]
53            {
54                use std::os::unix::fs::PermissionsExt;
55                let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
56            }
57            Some(path)
58        }
59        Err(e) => {
60            tracing::error!("Error writing {}: {e}", path.display());
61            None
62        }
63    }
64}
65
66fn resolved_hook_dir_display() -> String {
67    lean_ctx_dir().map_or_else(
68        || "$HOME/.lean-ctx".to_string(),
69        |p| p.to_string_lossy().to_string(),
70    )
71}
72
73fn source_line_posix(shell_ext: &str) -> String {
74    let mut dir = resolved_hook_dir_display();
75    // Git Bash / MSYS expects /c/... style paths in bashrc/zshrc.
76    if cfg!(windows) {
77        dir = crate::hooks::to_bash_compatible_path(&dir);
78    }
79    format!(
80        "# lean-ctx shell hook — begin\n\
81         if [ -f \"{dir}/shell-hook.{shell_ext}\" ]; then\n\
82           . \"{dir}/shell-hook.{shell_ext}\"\n\
83         fi\n\
84         # lean-ctx shell hook — end\n"
85    )
86}
87
88fn source_line_fish() -> String {
89    let mut dir = resolved_hook_dir_display();
90    // Fish on Windows (MSYS) also expects /c/... style paths.
91    if cfg!(windows) {
92        dir = crate::hooks::to_bash_compatible_path(&dir);
93    }
94    format!(
95        "# lean-ctx shell hook — begin\n\
96         if test -f \"{dir}/shell-hook.fish\"\n\
97           source \"{dir}/shell-hook.fish\"\n\
98         end\n\
99         # lean-ctx shell hook — end\n"
100    )
101}
102
103fn source_line_powershell() -> String {
104    let dir = resolved_hook_dir_display();
105    let dir_ps = dir.replace('/', "\\");
106    format!(
107        "# lean-ctx shell hook — begin\n\
108         $leanCtxHook = \"{dir_ps}\\shell-hook.ps1\"\n\
109         if ((Test-Path $leanCtxHook) -and -not [Console]::IsOutputRedirected) {{ . $leanCtxHook }}\n"
110    )
111}
112
113fn upsert_source_line(rc_path: &std::path::Path, source_line: &str) {
114    backup_shell_config(rc_path);
115
116    if let Ok(existing) = std::fs::read_to_string(rc_path) {
117        if existing.contains(source_line.trim()) {
118            return;
119        }
120
121        // Remove any legacy blocks and one-liner source lines, then append our canonical block.
122        let cleaned = remove_lean_ctx_block(&existing);
123        let cleaned = cleaned
124            .lines()
125            .filter(|line| {
126                !line.contains("lean-ctx/shell-hook.")
127                    && !line.contains("lean-ctx\\shell-hook.")
128                    && line.trim() != "lean-ctx shell hook"
129            })
130            .collect::<Vec<_>>()
131            .join("\n");
132        let cleaned = if cleaned.ends_with('\n') {
133            cleaned
134        } else {
135            format!("{cleaned}\n")
136        };
137
138        match std::fs::write(rc_path, format!("{cleaned}{source_line}")) {
139            Ok(()) => {
140                qprintln!("Updated lean-ctx hook in {}", rc_path.display());
141            }
142            Err(e) => {
143                tracing::error!("Error updating {}: {e}", rc_path.display());
144            }
145        }
146        return;
147    }
148
149    match std::fs::OpenOptions::new()
150        .append(true)
151        .create(true)
152        .open(rc_path)
153    {
154        Ok(mut f) => {
155            use std::io::Write;
156            let _ = f.write_all(source_line.as_bytes());
157            qprintln!("Added lean-ctx hook to {}", rc_path.display());
158        }
159        Err(e) => tracing::error!("Error writing {}: {e}", rc_path.display()),
160    }
161}
162
163pub fn generate_hook_powershell(binary: &str) -> String {
164    let config = crate::core::config::Config::load();
165    let activation = config.shell_activation_effective();
166    let baked_default = match activation {
167        crate::core::config::ShellActivation::Always => "always",
168        crate::core::config::ShellActivation::AgentsOnly => "agents-only",
169        crate::core::config::ShellActivation::Off => "off",
170    };
171    let binary_escaped = binary.replace('\\', "\\\\");
172    format!(
173        r#"# lean-ctx shell hook — transparent CLI compression (95+ patterns)
174$_leanCtxActivation = if ($env:LEAN_CTX_SHELL_ACTIVATION) {{ $env:LEAN_CTX_SHELL_ACTIVATION }} else {{ "{baked_default}" }}
175$_leanCtxShouldActivate = $false
176if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED -and -not $env:LEAN_CTX_NO_HOOK) {{
177  switch ($_leanCtxActivation) {{
178    {{ $_ -in 'off','none','manual' }} {{ $_leanCtxShouldActivate = $false }}
179    {{ $_ -in 'agents-only','agents_only','agentsonly' }} {{
180      $_leanCtxShouldActivate = $env:LEAN_CTX_AGENT -or $env:CLAUDECODE -or $env:CODEX_CLI_SESSION -or $env:GEMINI_SESSION
181    }}
182    default {{ $_leanCtxShouldActivate = $true }}
183  }}
184}}
185if ($_leanCtxShouldActivate) {{
186  $LeanCtxBin = "{binary_escaped}"
187  function _lc {{
188    if ($env:LEAN_CTX_DISABLED -or $env:LEAN_CTX_NO_HOOK -or [Console]::IsOutputRedirected) {{ & @args; return }}
189    & $LeanCtxBin -c @args
190    if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
191      & @args
192    }}
193  }}
194  function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
195  if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
196    function git {{ _lc git @args }}
197    function cargo {{ _lc cargo @args }}
198    function docker {{ _lc docker @args }}
199    function kubectl {{ _lc kubectl @args }}
200    function gh {{ _lc gh @args }}
201    function pip {{ _lc pip @args }}
202    function pip3 {{ _lc pip3 @args }}
203    function ruff {{ _lc ruff @args }}
204    function go {{ _lc go @args }}
205    function curl {{ _lc curl @args }}
206    function wget {{ _lc wget @args }}
207    foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
208      if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
209        $body = "_lc $c `@args"
210        New-Item -Path "function:$c" -Value ([scriptblock]::Create($body)) -Force | Out-Null
211      }}
212    }}
213  }}
214}}
215"#
216    )
217}
218
219pub fn init_powershell(binary: &str) {
220    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
221    let profile_path = if let Some(dir) = profile_dir {
222        let _ = std::fs::create_dir_all(&dir);
223        dir.join("Microsoft.PowerShell_profile.ps1")
224    } else {
225        tracing::error!("Could not resolve PowerShell profile directory");
226        return;
227    };
228
229    let hook_content = generate_hook_powershell(binary);
230
231    if write_hook_file("shell-hook.ps1", &hook_content).is_some() {
232        upsert_source_line(&profile_path, &source_line_powershell());
233        qprintln!("  Binary: {binary}");
234    }
235}
236
237pub fn remove_lean_ctx_block_ps(content: &str) -> String {
238    let mut result = String::new();
239    let mut in_block = false;
240    let mut brace_depth = 0i32;
241
242    for line in content.lines() {
243        if line.contains("lean-ctx shell hook") {
244            in_block = true;
245            continue;
246        }
247        if in_block {
248            brace_depth += line.matches('{').count() as i32;
249            brace_depth -= line.matches('}').count() as i32;
250            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
251                if line.trim() == "}" {
252                    in_block = false;
253                    brace_depth = 0;
254                }
255                continue;
256            }
257            continue;
258        }
259        result.push_str(line);
260        result.push('\n');
261    }
262    result
263}
264
265pub fn generate_hook_fish(binary: &str) -> String {
266    let config = crate::core::config::Config::load();
267    let activation = config.shell_activation_effective();
268    let baked_default = match activation {
269        crate::core::config::ShellActivation::Always => "always",
270        crate::core::config::ShellActivation::AgentsOnly => "agents-only",
271        crate::core::config::ShellActivation::Off => "off",
272    };
273    let alias_list = crate::rewrite_registry::shell_alias_list();
274    format!(
275        "# lean-ctx shell hook — smart shell mode (track-by-default)\n\
276        set -g _lean_ctx_cmds {alias_list}\n\
277        \n\
278        function _lc_is_agent\n\
279        \tset -q LEAN_CTX_AGENT; or set -q CODEX_CLI_SESSION; or set -q CLAUDECODE; or set -q GEMINI_SESSION\n\
280        end\n\
281        \n\
282        function _lc\n\
283        \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
284        \t\tcommand $argv\n\
285        \t\treturn\n\
286        \tend\n\
287        \tif not isatty stdout; and not _lc_is_agent\n\
288        \t\tcommand $argv\n\
289        \t\treturn\n\
290        \tend\n\
291        \t'{binary}' -t $argv\n\
292        \tset -l _lc_rc $status\n\
293        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
294        \t\tcommand $argv\n\
295        \telse\n\
296        \t\treturn $_lc_rc\n\
297        \tend\n\
298        end\n\
299        \n\
300        function _lc_compress\n\
301        \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
302        \t\tcommand $argv\n\
303        \t\treturn\n\
304        \tend\n\
305        \tif not isatty stdout; and not _lc_is_agent\n\
306        \t\tcommand $argv\n\
307        \t\treturn\n\
308        \tend\n\
309        \t'{binary}' -c $argv\n\
310        \tset -l _lc_rc $status\n\
311        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
312        \t\tcommand $argv\n\
313        \telse\n\
314        \t\treturn $_lc_rc\n\
315        \tend\n\
316        end\n\
317        \n\
318        function lean-ctx-on\n\
319        \tfor _lc_cmd in $_lean_ctx_cmds\n\
320        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
321        \tend\n\
322        \talias k '_lc kubectl'\n\
323        \tset -gx LEAN_CTX_ENABLED 1\n\
324        \tisatty stdout; and echo 'lean-ctx: ON (track mode — output unchanged, token savings recorded)'\n\
325        end\n\
326        \n\
327        function lean-ctx-off\n\
328        \tfor _lc_cmd in $_lean_ctx_cmds\n\
329        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
330        \tend\n\
331        \tfunctions --erase k 2>/dev/null; true\n\
332        \tset -e LEAN_CTX_ENABLED\n\
333        \tisatty stdout; and echo 'lean-ctx: OFF'\n\
334        end\n\
335        \n\
336        function lean-ctx-mode\n\
337        \tswitch $argv[1]\n\
338        \t\tcase compress\n\
339        \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
340        \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
341        \t\t\t\tend\n\
342        \t\t\talias k '_lc_compress kubectl'\n\
343        \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
344        \t\t\tisatty stdout; and echo 'lean-ctx: COMPRESS mode (all output compressed)'\n\
345        \t\tcase track\n\
346        \t\t\tlean-ctx-on\n\
347        \t\tcase off\n\
348        \t\t\tlean-ctx-off\n\
349        \t\tcase '*'\n\
350        \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
351        \t\t\techo '  track    — Full output, stats recorded (default)'\n\
352        \t\t\techo '  compress — Compressed output for all commands'\n\
353        \t\t\techo '  off      — No aliases, raw shell'\n\
354        \tend\n\
355        end\n\
356        \n\
357        function lean-ctx-raw\n\
358        \tset -lx LEAN_CTX_RAW 1\n\
359        \tcommand $argv\n\
360        end\n\
361        \n\
362        function lean-ctx-status\n\
363        \tif set -q LEAN_CTX_DISABLED\n\
364        \t\tisatty stdout; and echo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
365        \telse if set -q LEAN_CTX_ENABLED\n\
366        \t\tisatty stdout; and echo 'lean-ctx: ON'\n\
367        \telse\n\
368        \t\tisatty stdout; and echo 'lean-ctx: OFF'\n\
369        \tend\n\
370        end\n\
371        \n\
372        function _lean_ctx_should_activate\n\
373        \tif set -q LEAN_CTX_ACTIVE; or set -q LEAN_CTX_DISABLED; or test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) = '0'\n\
374        \t\treturn 1\n\
375        \tend\n\
376        \tset -l _lc_mode (set -q LEAN_CTX_SHELL_ACTIVATION; and echo $LEAN_CTX_SHELL_ACTIVATION; or echo '{baked_default}')\n\
377        \tswitch $_lc_mode\n\
378        \t\tcase off none manual\n\
379        \t\t\treturn 1\n\
380        \t\tcase 'agents-only' agents_only agentsonly\n\
381        \t\t\tif set -q LEAN_CTX_AGENT; or set -q CLAUDECODE; or set -q CODEX_CLI_SESSION; or set -q GEMINI_SESSION\n\
382        \t\t\t\treturn 0\n\
383        \t\t\tend\n\
384        \t\t\treturn 1\n\
385        \t\tcase '*'\n\
386        \t\t\treturn 0\n\
387        \tend\n\
388        end\n\
389        \n\
390        if _lean_ctx_should_activate\n\
391        \tif command -q lean-ctx\n\
392        \t\tlean-ctx-on\n\
393        \tend\n\
394        end\n"
395    )
396}
397
398pub fn init_fish(binary: &str) {
399    let config = dirs::home_dir()
400        .map(|h| h.join(".config/fish/config.fish"))
401        .unwrap_or_default();
402
403    let hook_content = generate_hook_fish(binary);
404
405    if write_hook_file("shell-hook.fish", &hook_content).is_some() {
406        upsert_source_line(&config, &source_line_fish());
407        qprintln!("  Binary: {binary}");
408    }
409}
410
411pub fn generate_hook_posix(binary: &str) -> String {
412    let config = crate::core::config::Config::load();
413    let activation = config.shell_activation_effective();
414    let baked_default = match activation {
415        crate::core::config::ShellActivation::Always => "always",
416        crate::core::config::ShellActivation::AgentsOnly => "agents-only",
417        crate::core::config::ShellActivation::Off => "off",
418    };
419    let alias_list = crate::rewrite_registry::shell_alias_list();
420    format!(
421        r#"# lean-ctx shell hook — smart shell mode (track-by-default)
422_lean_ctx_cmds=({alias_list})
423
424_lc_is_agent() {{
425    [ -n "${{LEAN_CTX_AGENT:-}}" ] || [ -n "${{CODEX_CLI_SESSION:-}}" ] || [ -n "${{CLAUDECODE:-}}" ] || [ -n "${{GEMINI_SESSION:-}}" ]
426}}
427
428_lc() {{
429    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
430        command "$@"
431        return
432    fi
433    if [ ! -t 1 ] && ! _lc_is_agent; then
434        command "$@"
435        return
436    fi
437    '{binary}' -t "$@"
438    local _lc_rc=$?
439    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
440        command "$@"
441    else
442        return "$_lc_rc"
443    fi
444}}
445
446_lc_compress() {{
447    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
448        command "$@"
449        return
450    fi
451    if [ ! -t 1 ] && ! _lc_is_agent; then
452        command "$@"
453        return
454    fi
455    '{binary}' -c "$@"
456    local _lc_rc=$?
457    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
458        command "$@"
459    else
460        return "$_lc_rc"
461    fi
462}}
463
464lean-ctx-on() {{
465    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
466        # shellcheck disable=SC2139
467        alias "$_lc_cmd"='_lc '"$_lc_cmd"
468    done
469    alias k='_lc kubectl'
470    export LEAN_CTX_ENABLED=1
471    [ -t 1 ] && echo "lean-ctx: ON (track mode — output unchanged, token savings recorded)"
472}}
473
474lean-ctx-off() {{
475    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
476        unalias "$_lc_cmd" 2>/dev/null || true
477    done
478    unalias k 2>/dev/null || true
479    unset LEAN_CTX_ENABLED
480    [ -t 1 ] && echo "lean-ctx: OFF"
481}}
482
483lean-ctx-mode() {{
484    case "${{1:-}}" in
485        compress)
486            for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
487                # shellcheck disable=SC2139
488                alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
489            done
490            alias k='_lc_compress kubectl'
491            export LEAN_CTX_ENABLED=1
492            [ -t 1 ] && echo "lean-ctx: COMPRESS mode (all output compressed)"
493            ;;
494        track)
495            lean-ctx-on
496            ;;
497        off)
498            lean-ctx-off
499            ;;
500        *)
501            echo "Usage: lean-ctx-mode <track|compress|off>"
502            echo "  track    — Full output, stats recorded (default)"
503            echo "  compress — Compressed output for all commands"
504            echo "  off      — No aliases, raw shell"
505            ;;
506    esac
507}}
508
509lean-ctx-raw() {{
510    LEAN_CTX_RAW=1 command "$@"
511}}
512
513lean-ctx-status() {{
514    if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
515        [ -t 1 ] && echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
516    elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
517        [ -t 1 ] && echo "lean-ctx: ON"
518    else
519        [ -t 1 ] && echo "lean-ctx: OFF"
520    fi
521}}
522
523if [ -n "${{ZSH_VERSION:-}}" ]; then
524    _lean_ctx_comp() {{
525        shift words
526        (( CURRENT-- ))
527        _normal
528    }}
529    compdef _lean_ctx_comp _lc 2>/dev/null
530    compdef _lean_ctx_comp _lc_compress 2>/dev/null
531fi
532
533_lean_ctx_should_activate() {{
534    [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ] || return 1
535    case "${{LEAN_CTX_SHELL_ACTIVATION:-{baked_default}}}" in
536        off|none|manual) return 1 ;;
537        agents-only|agents_only|agentsonly)
538            [ -n "${{LEAN_CTX_AGENT:-}}" ] || [ -n "${{CLAUDECODE:-}}" ] || [ -n "${{CODEX_CLI_SESSION:-}}" ] || [ -n "${{GEMINI_SESSION:-}}" ] ;;
539        *) return 0 ;;
540    esac
541}}
542
543if _lean_ctx_should_activate; then
544    command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
545fi
546"#
547    )
548}
549
550pub fn init_posix(is_zsh: bool, binary: &str) {
551    let rc_file = if is_zsh {
552        dirs::home_dir()
553            .map(|h| h.join(".zshrc"))
554            .unwrap_or_default()
555    } else {
556        dirs::home_dir()
557            .map(|h| h.join(".bashrc"))
558            .unwrap_or_default()
559    };
560
561    let shell_ext = if is_zsh { "zsh" } else { "bash" };
562    let hook_content = generate_hook_posix(binary);
563
564    if let Some(hook_path) = write_hook_file(&format!("shell-hook.{shell_ext}"), &hook_content) {
565        upsert_source_line(&rc_file, &source_line_posix(shell_ext));
566        qprintln!("  Binary: {binary}");
567
568        write_env_sh_for_containers(&hook_content);
569        print_docker_env_hints(is_zsh);
570
571        let _ = hook_path;
572    }
573}
574
575pub fn write_env_sh_for_containers(aliases: &str) {
576    let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
577        Ok(d) => d.join("env.sh"),
578        Err(_) => return,
579    };
580    if let Some(parent) = env_sh.parent() {
581        let _ = std::fs::create_dir_all(parent);
582    }
583    let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
584    let mut content = String::from(
585        r#"# lean-ctx: passthrough stubs for non-interactive subshells (fixes #255).
586# These ensure _lc/_lc_compress exist so inherited aliases don't break.
587# The full hook definitions override these when the interactive shell loads.
588_lc()          { command "$@"; }
589_lc_compress() { command "$@"; }
590
591"#,
592    );
593    content.push_str(&sanitized_aliases);
594    content.push_str(
595        r#"
596
597# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
598# Guards: container-only + no recursion + no re-entry via BASH_ENV + 60s cooldown + PID-lock
599if [ -f /.dockerenv ] || grep -qsE '/docker/|/lxc/' /proc/1/cgroup 2>/dev/null; then
600  if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ -z "${_LEAN_CTX_HEAL:-}" ]; then
601    _LEAN_CTX_HEAL_TS="${HOME}/.lean-ctx/.heal_ts"
602    _LEAN_CTX_HEAL_COOLDOWN=60
603    _lean_ctx_heal_needed=1
604    if [ -f "$_LEAN_CTX_HEAL_TS" ]; then
605      _last_heal=$(cat "$_LEAN_CTX_HEAL_TS" 2>/dev/null || echo 0)
606      _now=$(date +%s 2>/dev/null || echo 0)
607      if [ $(( _now - _last_heal )) -lt $_LEAN_CTX_HEAL_COOLDOWN ]; then
608        _lean_ctx_heal_needed=0
609      fi
610    fi
611    _lean_ctx_lock_count=0
612    for _lf in "${HOME}/.lean-ctx/locks"/slot-*.lock; do
613      [ -f "$_lf" ] && _lean_ctx_lock_count=$(( _lean_ctx_lock_count + 1 ))
614    done
615    if [ "$_lean_ctx_heal_needed" = "1" ] && [ "$_lean_ctx_lock_count" -lt 4 ]; then
616      export _LEAN_CTX_HEAL=1
617      if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
618        if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
619          LEAN_CTX_ACTIVE=1 LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
620          date +%s > "$_LEAN_CTX_HEAL_TS" 2>/dev/null
621        fi
622      fi
623    fi
624  fi
625fi
626"#,
627    );
628    match std::fs::write(&env_sh, content) {
629        Ok(()) => {
630            // Keep JSON-mode stdout clean; non-quiet hints go to stderr.
631            if !super::quiet_enabled() {
632                eprintln!("  env.sh: {}", env_sh.display());
633            }
634        }
635        Err(e) => tracing::warn!("could not write {}: {e}", env_sh.display()),
636    }
637}
638
639fn print_docker_env_hints(is_zsh: bool) {
640    if is_zsh || !crate::shell::is_container() {
641        return;
642    }
643    let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
644        |_| "/root/.lean-ctx/env.sh".to_string(),
645        |d| d.join("env.sh").to_string_lossy().to_string(),
646    );
647
648    let has_bash_env = std::env::var("BASH_ENV").is_ok();
649    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
650
651    if has_bash_env && has_claude_env {
652        return;
653    }
654
655    eprintln!();
656    eprintln!("  \x1b[33m⚠  Docker detected — environment hints:\x1b[0m");
657
658    if !has_bash_env {
659        eprintln!("  For generic bash -c usage (non-interactive shells):");
660        eprintln!("    \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
661    }
662    if !has_claude_env {
663        eprintln!("  For Claude Code (sources before each command):");
664        eprintln!("    \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
665    }
666    eprintln!();
667}
668
669pub fn remove_lean_ctx_block(content: &str) -> String {
670    if content.contains("# lean-ctx shell hook — end") {
671        return remove_lean_ctx_block_by_marker(content);
672    }
673    remove_lean_ctx_block_legacy(content)
674}
675
676fn remove_lean_ctx_block_by_marker(content: &str) -> String {
677    let mut result = String::new();
678    let mut in_block = false;
679
680    for line in content.lines() {
681        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
682            in_block = true;
683            continue;
684        }
685        if in_block {
686            if line.trim() == "# lean-ctx shell hook — end" {
687                in_block = false;
688            }
689            continue;
690        }
691        result.push_str(line);
692        result.push('\n');
693    }
694    result
695}
696
697fn remove_lean_ctx_block_legacy(content: &str) -> String {
698    let mut result = String::new();
699    let mut in_block = false;
700
701    for line in content.lines() {
702        if line.contains("lean-ctx shell hook") {
703            in_block = true;
704            continue;
705        }
706        if in_block {
707            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
708                if line.trim() == "fi" || line.trim() == "end" {
709                    in_block = false;
710                }
711                continue;
712            }
713            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
714                in_block = false;
715                result.push_str(line);
716                result.push('\n');
717            }
718            continue;
719        }
720        result.push_str(line);
721        result.push('\n');
722    }
723    result
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    #[test]
731    fn test_remove_lean_ctx_block_posix() {
732        let input = r#"# existing config
733export PATH="$HOME/bin:$PATH"
734
735# lean-ctx shell hook — transparent CLI compression (95+ patterns)
736if [ -z "$LEAN_CTX_ACTIVE" ]; then
737alias git='lean-ctx -c git'
738alias npm='lean-ctx -c npm'
739fi
740
741# other stuff
742export EDITOR=vim
743"#;
744        let result = remove_lean_ctx_block(input);
745        assert!(!result.contains("lean-ctx"), "block should be removed");
746        assert!(result.contains("export PATH"), "other content preserved");
747        assert!(
748            result.contains("export EDITOR"),
749            "trailing content preserved"
750        );
751    }
752
753    #[test]
754    fn test_remove_lean_ctx_block_fish() {
755        let input = "# other fish config\nset -x FOO bar\n\n# lean-ctx shell hook — transparent CLI compression (95+ 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";
756        let result = remove_lean_ctx_block(input);
757        assert!(!result.contains("lean-ctx"), "block should be removed");
758        assert!(result.contains("set -x FOO"), "other content preserved");
759        assert!(result.contains("set -x BAZ"), "trailing content preserved");
760    }
761
762    #[test]
763    fn test_remove_lean_ctx_block_ps() {
764        let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (95+ 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";
765        let result = remove_lean_ctx_block_ps(input);
766        assert!(
767            !result.contains("lean-ctx shell hook"),
768            "block should be removed"
769        );
770        assert!(result.contains("$env:FOO"), "other content preserved");
771        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
772    }
773
774    #[test]
775    fn test_remove_lean_ctx_block_ps_nested() {
776        let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (95+ 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";
777        let result = remove_lean_ctx_block_ps(input);
778        assert!(
779            !result.contains("lean-ctx shell hook"),
780            "block should be removed"
781        );
782        assert!(!result.contains("_lc"), "function should be removed");
783        assert!(result.contains("$env:FOO"), "other content preserved");
784        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
785    }
786
787    #[test]
788    fn test_remove_block_no_lean_ctx() {
789        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
790        let result = remove_lean_ctx_block(input);
791        assert!(result.contains("export PATH"), "content unchanged");
792    }
793
794    #[test]
795    fn test_bash_hook_contains_pipe_guard_and_agent_bypass() {
796        let output = generate_hook_posix("/usr/local/bin/lean-ctx");
797        assert!(
798            output.contains("! -t 1"),
799            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
800        );
801        assert!(
802            output.contains("_lc_is_agent"),
803            "bash/zsh hook must have agent-aware bypass"
804        );
805        assert!(
806            output.contains("CODEX_CLI_SESSION"),
807            "agent check must include CODEX_CLI_SESSION"
808        );
809    }
810
811    #[test]
812    fn test_lc_uses_track_mode_by_default() {
813        let binary = "/usr/local/bin/lean-ctx";
814        let alias_list = crate::rewrite_registry::shell_alias_list();
815        let aliases = format!(
816            r#"_lc() {{
817    '{binary}' -t "$@"
818}}
819_lc_compress() {{
820    '{binary}' -c "$@"
821}}"#
822        );
823        assert!(
824            aliases.contains("-t \"$@\""),
825            "_lc must use -t (track mode) by default"
826        );
827        assert!(
828            aliases.contains("-c \"$@\""),
829            "_lc_compress must use -c (compress mode)"
830        );
831        let _ = alias_list;
832    }
833
834    #[test]
835    fn test_posix_shell_has_lean_ctx_mode() {
836        let alias_list = crate::rewrite_registry::shell_alias_list();
837        let aliases = r#"
838lean-ctx-mode() {{
839    case "${{1:-}}" in
840        compress) echo compress ;;
841        track) echo track ;;
842        off) echo off ;;
843    esac
844}}
845"#
846        .to_string();
847        assert!(
848            aliases.contains("lean-ctx-mode()"),
849            "lean-ctx-mode function must exist"
850        );
851        assert!(
852            aliases.contains("compress"),
853            "compress mode must be available"
854        );
855        assert!(aliases.contains("track"), "track mode must be available");
856        let _ = alias_list;
857    }
858
859    #[test]
860    fn test_fish_hook_contains_pipe_guard_and_agent_bypass() {
861        let output = generate_hook_fish("/usr/local/bin/lean-ctx");
862        assert!(
863            output.contains("isatty stdout"),
864            "fish hook must contain pipe guard (isatty stdout)"
865        );
866        assert!(
867            output.contains("_lc_is_agent"),
868            "fish hook must have agent-aware bypass"
869        );
870    }
871
872    #[test]
873    fn test_powershell_hook_contains_pipe_guard() {
874        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
875        assert!(
876            hook.contains("IsOutputRedirected"),
877            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
878        );
879    }
880
881    #[test]
882    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
883        let input = r#"# existing config
884export PATH="$HOME/bin:$PATH"
885
886# lean-ctx shell hook — transparent CLI compression (95+ patterns)
887_lean_ctx_cmds=(git npm pnpm)
888
889lean-ctx-on() {
890    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
891        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
892    done
893    export LEAN_CTX_ENABLED=1
894    [ -t 1 ] && echo "lean-ctx: ON"
895}
896
897lean-ctx-off() {
898    unset LEAN_CTX_ENABLED
899    [ -t 1 ] && echo "lean-ctx: OFF"
900}
901
902if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
903    lean-ctx-on
904fi
905# lean-ctx shell hook — end
906
907# other stuff
908export EDITOR=vim
909"#;
910        let result = remove_lean_ctx_block(input);
911        assert!(!result.contains("lean-ctx-on"), "block should be removed");
912        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
913        assert!(result.contains("export PATH"), "other content preserved");
914        assert!(
915            result.contains("export EDITOR"),
916            "trailing content preserved"
917        );
918    }
919
920    #[test]
921    fn env_sh_for_containers_includes_self_heal() {
922        let _g = crate::core::data_dir::test_env_lock();
923        let tmp = tempfile::tempdir().expect("tempdir");
924        let data_dir = tmp.path().join("data");
925        std::fs::create_dir_all(&data_dir).expect("mkdir data");
926        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
927
928        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
929        let env_sh = data_dir.join("env.sh");
930        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
931        if !cfg!(windows) {
932            if let Ok(mut bash) = std::process::Command::new("bash")
933                .arg("-n")
934                .arg(&env_sh)
935                .spawn()
936            {
937                let ok = bash.wait().is_ok_and(|s| s.success());
938                assert!(ok, "generated env.sh must be valid bash");
939            }
940        }
941        assert!(
942            content.contains(r#"_lc()          { command "$@"; }"#),
943            "env.sh must contain _lc passthrough stub for non-interactive shells"
944        );
945        assert!(
946            content.contains(r#"_lc_compress() { command "$@"; }"#),
947            "env.sh must contain _lc_compress passthrough stub"
948        );
949        assert!(content.contains("lean-ctx docker self-heal"));
950        assert!(content.contains("claude mcp list"));
951        assert!(content.contains("lean-ctx init --agent claude"));
952        assert!(
953            content.contains("_LEAN_CTX_HEAL"),
954            "env.sh must guard against recursive self-heal"
955        );
956        assert!(
957            content.contains("LEAN_CTX_ACTIVE"),
958            "env.sh must check LEAN_CTX_ACTIVE to prevent re-entry"
959        );
960        assert!(
961            content.contains("/.dockerenv"),
962            "env.sh self-heal must be gated to container environments"
963        );
964
965        std::env::remove_var("LEAN_CTX_DATA_DIR");
966    }
967
968    #[test]
969    fn test_source_line_posix() {
970        let line = source_line_posix("zsh");
971        assert!(line.contains("shell-hook.zsh"));
972        assert!(line.contains("[ -f"));
973    }
974
975    #[test]
976    fn test_source_line_fish() {
977        let line = source_line_fish();
978        assert!(line.contains("shell-hook.fish"));
979        assert!(line.contains("source"));
980    }
981
982    #[test]
983    fn test_source_line_powershell() {
984        let line = source_line_powershell();
985        assert!(line.contains("shell-hook.ps1"));
986        assert!(line.contains("Test-Path"));
987    }
988}