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                print_shell_write_error(rc_path, source_line, &e);
145            }
146        }
147        return;
148    }
149
150    match std::fs::OpenOptions::new()
151        .append(true)
152        .create(true)
153        .open(rc_path)
154    {
155        Ok(mut f) => {
156            use std::io::Write;
157            let _ = f.write_all(source_line.as_bytes());
158            qprintln!("Added lean-ctx hook to {}", rc_path.display());
159        }
160        Err(e) => {
161            tracing::error!("Error writing {}: {e}", rc_path.display());
162            print_shell_write_error(rc_path, source_line, &e);
163        }
164    }
165}
166
167fn print_shell_write_error(rc_path: &std::path::Path, source_line: &str, err: &std::io::Error) {
168    eprintln!();
169    eprintln!("  \x1B[33m⚠ Cannot write to {}\x1B[0m", rc_path.display());
170    eprintln!("    Error: {err}");
171    if err.kind() == std::io::ErrorKind::PermissionDenied {
172        eprintln!();
173        eprintln!("    Your shell config is read-only (nix-darwin, Home Manager, or similar).");
174        eprintln!("    Add the following to a writable shell config file manually:");
175    } else {
176        eprintln!();
177        eprintln!("    Add the following to your shell config manually:");
178    }
179    eprintln!();
180    for line in source_line.lines() {
181        eprintln!("      {line}");
182    }
183    eprintln!();
184    eprintln!("    Or source it from a writable file (e.g. ~/.zshrc.local):");
185    eprintln!("      echo 'source ~/.zshrc.local' # (add to nix config)");
186    eprintln!("      Then add the hook lines to ~/.zshrc.local");
187    eprintln!();
188}
189
190pub fn generate_hook_powershell(binary: &str) -> String {
191    let config = crate::core::config::Config::load();
192    let activation = config.shell_activation_effective();
193    let baked_default = match activation {
194        crate::core::config::ShellActivation::Always => "always",
195        crate::core::config::ShellActivation::AgentsOnly => "agents-only",
196        crate::core::config::ShellActivation::Off => "off",
197    };
198    let binary_escaped = binary.replace('\\', "\\\\");
199    format!(
200        r#"# lean-ctx shell hook — transparent CLI compression (95+ patterns)
201$_leanCtxActivation = if ($env:LEAN_CTX_SHELL_ACTIVATION) {{ $env:LEAN_CTX_SHELL_ACTIVATION }} else {{ "{baked_default}" }}
202$_leanCtxShouldActivate = $false
203if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED -and -not $env:LEAN_CTX_NO_HOOK) {{
204  switch ($_leanCtxActivation) {{
205    {{ $_ -in 'off','none','manual' }} {{ $_leanCtxShouldActivate = $false }}
206    {{ $_ -in 'agents-only','agents_only','agentsonly' }} {{
207      $_leanCtxShouldActivate = $env:LEAN_CTX_AGENT -or $env:CLAUDECODE -or $env:CODEX_CLI_SESSION -or $env:GEMINI_SESSION
208    }}
209    default {{ $_leanCtxShouldActivate = $true }}
210  }}
211}}
212if ($_leanCtxShouldActivate) {{
213  $LeanCtxBin = "{binary_escaped}"
214  function _lc {{
215    $nativeCmd = Get-Command $args[0] -CommandType Application -ErrorAction SilentlyContinue
216    if ($env:LEAN_CTX_DISABLED -or $env:LEAN_CTX_NO_HOOK -or [Console]::IsOutputRedirected) {{
217      if ($nativeCmd) {{ & $nativeCmd.Source $args[1..$args.Length] }} else {{ Write-Error "Command not found: $($args[0])" }}
218      return
219    }}
220    & $LeanCtxBin -c @args
221    if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
222      if ($nativeCmd) {{ & $nativeCmd.Source $args[1..$args.Length] }} else {{ Write-Error "Command not found: $($args[0])" }}
223    }}
224  }}
225  function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
226  if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
227    function git {{ _lc git @args }}
228    function cargo {{ _lc cargo @args }}
229    function docker {{ _lc docker @args }}
230    function kubectl {{ _lc kubectl @args }}
231    function gh {{ _lc gh @args }}
232    function pip {{ _lc pip @args }}
233    function pip3 {{ _lc pip3 @args }}
234    function ruff {{ _lc ruff @args }}
235    function go {{ _lc go @args }}
236    function curl {{ _lc curl @args }}
237    function wget {{ _lc wget @args }}
238    foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
239      if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
240        $body = "_lc $c `@args"
241        New-Item -Path "function:$c" -Value ([scriptblock]::Create($body)) -Force | Out-Null
242      }}
243    }}
244  }}
245}}
246"#
247    )
248}
249
250pub fn init_powershell(binary: &str) {
251    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
252    let profile_path = if let Some(dir) = profile_dir {
253        let _ = std::fs::create_dir_all(&dir);
254        dir.join("Microsoft.PowerShell_profile.ps1")
255    } else {
256        tracing::error!("Could not resolve PowerShell profile directory");
257        return;
258    };
259
260    let hook_content = generate_hook_powershell(binary);
261
262    if write_hook_file("shell-hook.ps1", &hook_content).is_some() {
263        upsert_source_line(&profile_path, &source_line_powershell());
264        qprintln!("  Binary: {binary}");
265    }
266}
267
268pub fn remove_lean_ctx_block_ps(content: &str) -> String {
269    let mut result = String::new();
270    let mut in_block = false;
271    let mut brace_depth = 0i32;
272
273    for line in content.lines() {
274        if line.contains("lean-ctx shell hook") {
275            in_block = true;
276            continue;
277        }
278        if in_block {
279            brace_depth += line.matches('{').count() as i32;
280            brace_depth -= line.matches('}').count() as i32;
281            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
282                if line.trim() == "}" {
283                    in_block = false;
284                    brace_depth = 0;
285                }
286                continue;
287            }
288            continue;
289        }
290        result.push_str(line);
291        result.push('\n');
292    }
293    result
294}
295
296pub fn generate_hook_fish(binary: &str) -> String {
297    let config = crate::core::config::Config::load();
298    let activation = config.shell_activation_effective();
299    let baked_default = match activation {
300        crate::core::config::ShellActivation::Always => "always",
301        crate::core::config::ShellActivation::AgentsOnly => "agents-only",
302        crate::core::config::ShellActivation::Off => "off",
303    };
304    let alias_list = crate::rewrite_registry::shell_alias_list();
305    format!(
306        "# lean-ctx shell hook — smart shell mode (track-by-default)\n\
307        set -g _lean_ctx_cmds {alias_list}\n\
308        \n\
309        function _lc_is_agent\n\
310        \tset -q LEAN_CTX_AGENT; or set -q CODEX_CLI_SESSION; or set -q CLAUDECODE; or set -q GEMINI_SESSION\n\
311        end\n\
312        \n\
313        function _lc\n\
314        \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
315        \t\tcommand $argv\n\
316        \t\treturn\n\
317        \tend\n\
318        \tif not isatty stdout; and not _lc_is_agent\n\
319        \t\tcommand $argv\n\
320        \t\treturn\n\
321        \tend\n\
322        \t'{binary}' -t $argv\n\
323        \tset -l _lc_rc $status\n\
324        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
325        \t\tcommand $argv\n\
326        \telse\n\
327        \t\treturn $_lc_rc\n\
328        \tend\n\
329        end\n\
330        \n\
331        function _lc_compress\n\
332        \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
333        \t\tcommand $argv\n\
334        \t\treturn\n\
335        \tend\n\
336        \tif not isatty stdout; and not _lc_is_agent\n\
337        \t\tcommand $argv\n\
338        \t\treturn\n\
339        \tend\n\
340        \t'{binary}' -c $argv\n\
341        \tset -l _lc_rc $status\n\
342        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
343        \t\tcommand $argv\n\
344        \telse\n\
345        \t\treturn $_lc_rc\n\
346        \tend\n\
347        end\n\
348        \n\
349        function lean-ctx-on\n\
350        \tfor _lc_cmd in $_lean_ctx_cmds\n\
351        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
352        \tend\n\
353        \talias k '_lc kubectl'\n\
354        \tset -gx LEAN_CTX_ENABLED 1\n\
355        \tisatty stdout; and echo 'lean-ctx: ON (track mode — output unchanged, token savings recorded)'\n\
356        end\n\
357        \n\
358        function lean-ctx-off\n\
359        \tfor _lc_cmd in $_lean_ctx_cmds\n\
360        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
361        \tend\n\
362        \tfunctions --erase k 2>/dev/null; true\n\
363        \tset -gx LEAN_CTX_ENABLED 0\n\
364        \tisatty stdout; and echo 'lean-ctx: OFF'\n\
365        end\n\
366        \n\
367        function lean-ctx-mode\n\
368        \tswitch $argv[1]\n\
369        \t\tcase compress\n\
370        \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
371        \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
372        \t\t\t\tend\n\
373        \t\t\talias k '_lc_compress kubectl'\n\
374        \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
375        \t\t\tisatty stdout; and echo 'lean-ctx: COMPRESS mode (all output compressed)'\n\
376        \t\tcase track\n\
377        \t\t\tlean-ctx-on\n\
378        \t\tcase off\n\
379        \t\t\tlean-ctx-off\n\
380        \t\tcase '*'\n\
381        \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
382        \t\t\techo '  track    — Full output, stats recorded (default)'\n\
383        \t\t\techo '  compress — Compressed output for all commands'\n\
384        \t\t\techo '  off      — No aliases, raw shell'\n\
385        \tend\n\
386        end\n\
387        \n\
388        function lean-ctx-raw\n\
389        \tset -lx LEAN_CTX_RAW 1\n\
390        \tcommand $argv\n\
391        end\n\
392        \n\
393        function lean-ctx-status\n\
394        \tif set -q LEAN_CTX_DISABLED\n\
395        \t\tisatty stdout; and echo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
396        \telse if set -q LEAN_CTX_ENABLED\n\
397        \t\tisatty stdout; and echo 'lean-ctx: ON'\n\
398        \telse\n\
399        \t\tisatty stdout; and echo 'lean-ctx: OFF'\n\
400        \tend\n\
401        end\n\
402        \n\
403        function _lean_ctx_should_activate\n\
404        \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\
405        \t\treturn 1\n\
406        \tend\n\
407        \tset -l _lc_mode (set -q LEAN_CTX_SHELL_ACTIVATION; and echo $LEAN_CTX_SHELL_ACTIVATION; or echo '{baked_default}')\n\
408        \tswitch $_lc_mode\n\
409        \t\tcase off none manual\n\
410        \t\t\treturn 1\n\
411        \t\tcase 'agents-only' agents_only agentsonly\n\
412        \t\t\tif set -q LEAN_CTX_AGENT; or set -q CLAUDECODE; or set -q CODEX_CLI_SESSION; or set -q GEMINI_SESSION\n\
413        \t\t\t\treturn 0\n\
414        \t\t\tend\n\
415        \t\t\treturn 1\n\
416        \t\tcase '*'\n\
417        \t\t\treturn 0\n\
418        \tend\n\
419        end\n\
420        \n\
421        if _lean_ctx_should_activate\n\
422        \tif command -q lean-ctx\n\
423        \t\tlean-ctx-on\n\
424        \tend\n\
425        end\n"
426    )
427}
428
429pub fn init_fish(binary: &str) {
430    let config = dirs::home_dir()
431        .map(|h| h.join(".config/fish/config.fish"))
432        .unwrap_or_default();
433
434    let hook_content = generate_hook_fish(binary);
435
436    if write_hook_file("shell-hook.fish", &hook_content).is_some() {
437        upsert_source_line(&config, &source_line_fish());
438        qprintln!("  Binary: {binary}");
439    }
440}
441
442pub fn generate_hook_posix(binary: &str) -> String {
443    let config = crate::core::config::Config::load();
444    let activation = config.shell_activation_effective();
445    let baked_default = match activation {
446        crate::core::config::ShellActivation::Always => "always",
447        crate::core::config::ShellActivation::AgentsOnly => "agents-only",
448        crate::core::config::ShellActivation::Off => "off",
449    };
450    let alias_list = crate::rewrite_registry::shell_alias_list();
451    format!(
452        r#"# lean-ctx shell hook — smart shell mode (track-by-default)
453_lean_ctx_cmds=({alias_list})
454
455_lc_is_agent() {{
456    [ -n "${{LEAN_CTX_AGENT:-}}" ] || [ -n "${{CODEX_CLI_SESSION:-}}" ] || [ -n "${{CLAUDECODE:-}}" ] || [ -n "${{GEMINI_SESSION:-}}" ]
457}}
458
459_lc() {{
460    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
461        command "$@"
462        return
463    fi
464    if [ ! -t 1 ] && ! _lc_is_agent; then
465        command "$@"
466        return
467    fi
468    '{binary}' -t "$@"
469    local _lc_rc=$?
470    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
471        command "$@"
472    else
473        return "$_lc_rc"
474    fi
475}}
476
477_lc_compress() {{
478    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
479        command "$@"
480        return
481    fi
482    if [ ! -t 1 ] && ! _lc_is_agent; then
483        command "$@"
484        return
485    fi
486    '{binary}' -c "$@"
487    local _lc_rc=$?
488    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
489        command "$@"
490    else
491        return "$_lc_rc"
492    fi
493}}
494
495lean-ctx-on() {{
496    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
497        # shellcheck disable=SC2139
498        alias "$_lc_cmd"='_lc '"$_lc_cmd"
499    done
500    alias k='_lc kubectl'
501    export LEAN_CTX_ENABLED=1
502    [ -t 1 ] && echo "lean-ctx: ON (track mode — output unchanged, token savings recorded)"
503}}
504
505lean-ctx-off() {{
506    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
507        unalias "$_lc_cmd" 2>/dev/null || true
508    done
509    unalias k 2>/dev/null || true
510    export LEAN_CTX_ENABLED=0
511    [ -t 1 ] && echo "lean-ctx: OFF"
512}}
513
514lean-ctx-mode() {{
515    case "${{1:-}}" in
516        compress)
517            for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
518                # shellcheck disable=SC2139
519                alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
520            done
521            alias k='_lc_compress kubectl'
522            export LEAN_CTX_ENABLED=1
523            [ -t 1 ] && echo "lean-ctx: COMPRESS mode (all output compressed)"
524            ;;
525        track)
526            lean-ctx-on
527            ;;
528        off)
529            lean-ctx-off
530            ;;
531        *)
532            echo "Usage: lean-ctx-mode <track|compress|off>"
533            echo "  track    — Full output, stats recorded (default)"
534            echo "  compress — Compressed output for all commands"
535            echo "  off      — No aliases, raw shell"
536            ;;
537    esac
538}}
539
540lean-ctx-raw() {{
541    LEAN_CTX_RAW=1 command "$@"
542}}
543
544lean-ctx-status() {{
545    if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
546        [ -t 1 ] && echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
547    elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
548        [ -t 1 ] && echo "lean-ctx: ON"
549    else
550        [ -t 1 ] && echo "lean-ctx: OFF"
551    fi
552}}
553
554if [ -n "${{ZSH_VERSION:-}}" ]; then
555    _lean_ctx_comp() {{
556        shift words
557        (( CURRENT-- ))
558        _normal
559    }}
560    compdef _lean_ctx_comp _lc 2>/dev/null
561    compdef _lean_ctx_comp _lc_compress 2>/dev/null
562fi
563
564_lean_ctx_should_activate() {{
565    [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ] || return 1
566    case "${{LEAN_CTX_SHELL_ACTIVATION:-{baked_default}}}" in
567        off|none|manual) return 1 ;;
568        agents-only|agents_only|agentsonly)
569            [ -n "${{LEAN_CTX_AGENT:-}}" ] || [ -n "${{CLAUDECODE:-}}" ] || [ -n "${{CODEX_CLI_SESSION:-}}" ] || [ -n "${{GEMINI_SESSION:-}}" ] ;;
570        *) return 0 ;;
571    esac
572}}
573
574if _lean_ctx_should_activate; then
575    command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
576fi
577"#
578    )
579}
580
581pub fn init_posix(is_zsh: bool, binary: &str) {
582    let rc_file = if is_zsh {
583        dirs::home_dir()
584            .map(|h| h.join(".zshrc"))
585            .unwrap_or_default()
586    } else {
587        dirs::home_dir()
588            .map(|h| h.join(".bashrc"))
589            .unwrap_or_default()
590    };
591
592    let shell_ext = if is_zsh { "zsh" } else { "bash" };
593    let hook_content = generate_hook_posix(binary);
594
595    if let Some(hook_path) = write_hook_file(&format!("shell-hook.{shell_ext}"), &hook_content) {
596        upsert_source_line(&rc_file, &source_line_posix(shell_ext));
597
598        // Bash login shells don't read ~/.bashrc — make sure they pick it up so the hook
599        // (and the installer's PATH export) take effect in Terminal.app / IDE login shells.
600        if !is_zsh {
601            ensure_bash_login_sources_bashrc();
602        }
603
604        qprintln!("  Binary: {binary}");
605
606        write_env_sh_for_containers(&hook_content);
607        print_docker_env_hints(is_zsh);
608
609        let _ = hook_path;
610    }
611}
612
613/// Bash login shells (macOS Terminal.app, many IDE terminals, `bash -l`) read
614/// `~/.bash_profile` (or `~/.bash_login` / `~/.profile`) and never `~/.bashrc`. Because we
615/// install the hook — and the installer adds `~/.local/bin` to PATH — into `~/.bashrc`, a login
616/// shell would otherwise see neither. Ensure the login profile sources `~/.bashrc`, exactly as
617/// the Debian/Ubuntu default `.profile` does. Idempotent; zsh is unaffected (it always reads
618/// `~/.zshrc`), so this is only wired in for bash.
619fn ensure_bash_login_sources_bashrc() {
620    let Some(home) = dirs::home_dir() else {
621        return;
622    };
623
624    // Bash reads only the FIRST existing of these on login; target that one, else create
625    // ~/.bash_profile. (~/.bashrc is never a login file, so it's not a candidate.)
626    let target = [".bash_profile", ".bash_login", ".profile"]
627        .iter()
628        .map(|f| home.join(f))
629        .find(|p| p.exists())
630        .unwrap_or_else(|| home.join(".bash_profile"));
631
632    // Already sourcing ~/.bashrc (our snippet or the user's own)? Nothing to do.
633    if let Ok(existing) = std::fs::read_to_string(&target) {
634        let sources_bashrc = existing
635            .lines()
636            .any(|l| !l.trim_start().starts_with('#') && l.contains(".bashrc"));
637        if sources_bashrc {
638            return;
639        }
640    }
641
642    let snippet = "\n# lean-ctx: load ~/.bashrc in login shells (e.g. macOS Terminal) — begin\n\
643         if [ -f \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\"; fi\n\
644         # lean-ctx: load ~/.bashrc in login shells (e.g. macOS Terminal) — end\n";
645
646    backup_shell_config(&target);
647    match std::fs::OpenOptions::new()
648        .append(true)
649        .create(true)
650        .open(&target)
651    {
652        Ok(mut f) => {
653            use std::io::Write;
654            if f.write_all(snippet.as_bytes()).is_ok() {
655                qprintln!("  Login shell: {} now sources ~/.bashrc", target.display());
656            }
657        }
658        Err(e) => {
659            tracing::warn!("could not update {}: {e}", target.display());
660        }
661    }
662}
663
664pub fn write_env_sh_for_containers(aliases: &str) {
665    let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
666        Ok(d) => d.join("env.sh"),
667        Err(_) => return,
668    };
669    if let Some(parent) = env_sh.parent() {
670        let _ = std::fs::create_dir_all(parent);
671    }
672    let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
673    let mut content = String::from(
674        r#"# lean-ctx: passthrough stubs for non-interactive subshells (fixes #255).
675# These ensure _lc/_lc_compress exist so inherited aliases don't break.
676# The full hook definitions override these when the interactive shell loads.
677_lc()          { command "$@"; }
678_lc_compress() { command "$@"; }
679
680"#,
681    );
682    content.push_str(&sanitized_aliases);
683    content.push_str(
684        r#"
685
686# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
687# Guards: container-only + no recursion + no re-entry via BASH_ENV + 60s cooldown + PID-lock
688if [ -f /.dockerenv ] || grep -qsE '/docker/|/lxc/' /proc/1/cgroup 2>/dev/null; then
689  if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ -z "${_LEAN_CTX_HEAL:-}" ]; then
690    _LEAN_CTX_HEAL_TS="${HOME}/.lean-ctx/.heal_ts"
691    _LEAN_CTX_HEAL_COOLDOWN=60
692    _lean_ctx_heal_needed=1
693    if [ -f "$_LEAN_CTX_HEAL_TS" ]; then
694      _last_heal=$(cat "$_LEAN_CTX_HEAL_TS" 2>/dev/null || echo 0)
695      _now=$(date +%s 2>/dev/null || echo 0)
696      if [ $(( _now - _last_heal )) -lt $_LEAN_CTX_HEAL_COOLDOWN ]; then
697        _lean_ctx_heal_needed=0
698      fi
699    fi
700    _lean_ctx_lock_count=0
701    for _lf in "${HOME}/.lean-ctx/locks"/slot-*.lock; do
702      [ -f "$_lf" ] && _lean_ctx_lock_count=$(( _lean_ctx_lock_count + 1 ))
703    done
704    if [ "$_lean_ctx_heal_needed" = "1" ] && [ "$_lean_ctx_lock_count" -lt 4 ]; then
705      export _LEAN_CTX_HEAL=1
706      if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
707        if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
708          LEAN_CTX_ACTIVE=1 LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
709          date +%s > "$_LEAN_CTX_HEAL_TS" 2>/dev/null
710        fi
711      fi
712    fi
713  fi
714fi
715"#,
716    );
717    match std::fs::write(&env_sh, content) {
718        Ok(()) => {
719            // Keep JSON-mode stdout clean; non-quiet hints go to stderr.
720            if !super::quiet_enabled() {
721                eprintln!("  env.sh: {}", env_sh.display());
722            }
723        }
724        Err(e) => tracing::warn!("could not write {}: {e}", env_sh.display()),
725    }
726}
727
728fn print_docker_env_hints(is_zsh: bool) {
729    if is_zsh || !crate::shell::is_container() {
730        return;
731    }
732    let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
733        |_| "/root/.lean-ctx/env.sh".to_string(),
734        |d| d.join("env.sh").to_string_lossy().to_string(),
735    );
736
737    let has_bash_env = std::env::var("BASH_ENV").is_ok();
738    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
739
740    if has_bash_env && has_claude_env {
741        return;
742    }
743
744    eprintln!();
745    eprintln!("  \x1b[33m⚠  Docker detected — environment hints:\x1b[0m");
746
747    if !has_bash_env {
748        eprintln!("  For generic bash -c usage (non-interactive shells):");
749        eprintln!("    \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
750    }
751    if !has_claude_env {
752        eprintln!("  For Claude Code (sources before each command):");
753        eprintln!("    \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
754    }
755    eprintln!();
756}
757
758pub fn remove_lean_ctx_block(content: &str) -> String {
759    if content.contains("# lean-ctx shell hook — end") {
760        return remove_lean_ctx_block_by_marker(content);
761    }
762    remove_lean_ctx_block_legacy(content)
763}
764
765fn remove_lean_ctx_block_by_marker(content: &str) -> String {
766    let mut result = String::new();
767    let mut in_block = false;
768
769    for line in content.lines() {
770        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
771            in_block = true;
772            continue;
773        }
774        if in_block {
775            if line.trim() == "# lean-ctx shell hook — end" {
776                in_block = false;
777            }
778            continue;
779        }
780        result.push_str(line);
781        result.push('\n');
782    }
783    result
784}
785
786fn remove_lean_ctx_block_legacy(content: &str) -> String {
787    let mut result = String::new();
788    let mut in_block = false;
789
790    for line in content.lines() {
791        if line.contains("lean-ctx shell hook") {
792            in_block = true;
793            continue;
794        }
795        if in_block {
796            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
797                if line.trim() == "fi" || line.trim() == "end" {
798                    in_block = false;
799                }
800                continue;
801            }
802            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
803                in_block = false;
804                result.push_str(line);
805                result.push('\n');
806            }
807            continue;
808        }
809        result.push_str(line);
810        result.push('\n');
811    }
812    result
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    #[test]
820    fn test_remove_lean_ctx_block_posix() {
821        let input = r#"# existing config
822export PATH="$HOME/bin:$PATH"
823
824# lean-ctx shell hook — transparent CLI compression (95+ patterns)
825if [ -z "$LEAN_CTX_ACTIVE" ]; then
826alias git='lean-ctx -c git'
827alias npm='lean-ctx -c npm'
828fi
829
830# other stuff
831export EDITOR=vim
832"#;
833        let result = remove_lean_ctx_block(input);
834        assert!(!result.contains("lean-ctx"), "block should be removed");
835        assert!(result.contains("export PATH"), "other content preserved");
836        assert!(
837            result.contains("export EDITOR"),
838            "trailing content preserved"
839        );
840    }
841
842    #[test]
843    fn test_remove_lean_ctx_block_fish() {
844        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";
845        let result = remove_lean_ctx_block(input);
846        assert!(!result.contains("lean-ctx"), "block should be removed");
847        assert!(result.contains("set -x FOO"), "other content preserved");
848        assert!(result.contains("set -x BAZ"), "trailing content preserved");
849    }
850
851    #[test]
852    fn test_remove_lean_ctx_block_ps() {
853        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";
854        let result = remove_lean_ctx_block_ps(input);
855        assert!(
856            !result.contains("lean-ctx shell hook"),
857            "block should be removed"
858        );
859        assert!(result.contains("$env:FOO"), "other content preserved");
860        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
861    }
862
863    #[test]
864    fn test_remove_lean_ctx_block_ps_nested() {
865        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";
866        let result = remove_lean_ctx_block_ps(input);
867        assert!(
868            !result.contains("lean-ctx shell hook"),
869            "block should be removed"
870        );
871        assert!(!result.contains("_lc"), "function should be removed");
872        assert!(result.contains("$env:FOO"), "other content preserved");
873        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
874    }
875
876    #[test]
877    fn test_remove_block_no_lean_ctx() {
878        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
879        let result = remove_lean_ctx_block(input);
880        assert!(result.contains("export PATH"), "content unchanged");
881    }
882
883    #[test]
884    fn test_bash_hook_contains_pipe_guard_and_agent_bypass() {
885        let output = generate_hook_posix("/usr/local/bin/lean-ctx");
886        assert!(
887            output.contains("! -t 1"),
888            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
889        );
890        assert!(
891            output.contains("_lc_is_agent"),
892            "bash/zsh hook must have agent-aware bypass"
893        );
894        assert!(
895            output.contains("CODEX_CLI_SESSION"),
896            "agent check must include CODEX_CLI_SESSION"
897        );
898    }
899
900    #[test]
901    fn test_lc_uses_track_mode_by_default() {
902        let binary = "/usr/local/bin/lean-ctx";
903        let alias_list = crate::rewrite_registry::shell_alias_list();
904        let aliases = format!(
905            r#"_lc() {{
906    '{binary}' -t "$@"
907}}
908_lc_compress() {{
909    '{binary}' -c "$@"
910}}"#
911        );
912        assert!(
913            aliases.contains("-t \"$@\""),
914            "_lc must use -t (track mode) by default"
915        );
916        assert!(
917            aliases.contains("-c \"$@\""),
918            "_lc_compress must use -c (compress mode)"
919        );
920        let _ = alias_list;
921    }
922
923    #[test]
924    fn test_posix_shell_has_lean_ctx_mode() {
925        let alias_list = crate::rewrite_registry::shell_alias_list();
926        let aliases = r#"
927lean-ctx-mode() {{
928    case "${{1:-}}" in
929        compress) echo compress ;;
930        track) echo track ;;
931        off) echo off ;;
932    esac
933}}
934"#
935        .to_string();
936        assert!(
937            aliases.contains("lean-ctx-mode()"),
938            "lean-ctx-mode function must exist"
939        );
940        assert!(
941            aliases.contains("compress"),
942            "compress mode must be available"
943        );
944        assert!(aliases.contains("track"), "track mode must be available");
945        let _ = alias_list;
946    }
947
948    #[test]
949    fn test_fish_hook_contains_pipe_guard_and_agent_bypass() {
950        let output = generate_hook_fish("/usr/local/bin/lean-ctx");
951        assert!(
952            output.contains("isatty stdout"),
953            "fish hook must contain pipe guard (isatty stdout)"
954        );
955        assert!(
956            output.contains("_lc_is_agent"),
957            "fish hook must have agent-aware bypass"
958        );
959    }
960
961    #[test]
962    fn test_powershell_hook_contains_pipe_guard() {
963        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
964        assert!(
965            hook.contains("IsOutputRedirected"),
966            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
967        );
968    }
969
970    #[test]
971    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
972        let input = r#"# existing config
973export PATH="$HOME/bin:$PATH"
974
975# lean-ctx shell hook — transparent CLI compression (95+ patterns)
976_lean_ctx_cmds=(git npm pnpm)
977
978lean-ctx-on() {
979    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
980        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
981    done
982    export LEAN_CTX_ENABLED=1
983    [ -t 1 ] && echo "lean-ctx: ON"
984}
985
986lean-ctx-off() {
987    export LEAN_CTX_ENABLED=0
988    [ -t 1 ] && echo "lean-ctx: OFF"
989}
990
991if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
992    lean-ctx-on
993fi
994# lean-ctx shell hook — end
995
996# other stuff
997export EDITOR=vim
998"#;
999        let result = remove_lean_ctx_block(input);
1000        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1001        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1002        assert!(result.contains("export PATH"), "other content preserved");
1003        assert!(
1004            result.contains("export EDITOR"),
1005            "trailing content preserved"
1006        );
1007    }
1008
1009    #[test]
1010    fn env_sh_for_containers_includes_self_heal() {
1011        let _g = crate::core::data_dir::test_env_lock();
1012        let tmp = tempfile::tempdir().expect("tempdir");
1013        let data_dir = tmp.path().join("data");
1014        std::fs::create_dir_all(&data_dir).expect("mkdir data");
1015        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1016
1017        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1018        let env_sh = data_dir.join("env.sh");
1019        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1020        if !cfg!(windows) {
1021            if let Ok(mut bash) = std::process::Command::new("bash")
1022                .arg("-n")
1023                .arg(&env_sh)
1024                .spawn()
1025            {
1026                let ok = bash.wait().is_ok_and(|s| s.success());
1027                assert!(ok, "generated env.sh must be valid bash");
1028            }
1029        }
1030        assert!(
1031            content.contains(r#"_lc()          { command "$@"; }"#),
1032            "env.sh must contain _lc passthrough stub for non-interactive shells"
1033        );
1034        assert!(
1035            content.contains(r#"_lc_compress() { command "$@"; }"#),
1036            "env.sh must contain _lc_compress passthrough stub"
1037        );
1038        assert!(content.contains("lean-ctx docker self-heal"));
1039        assert!(content.contains("claude mcp list"));
1040        assert!(content.contains("lean-ctx init --agent claude"));
1041        assert!(
1042            content.contains("_LEAN_CTX_HEAL"),
1043            "env.sh must guard against recursive self-heal"
1044        );
1045        assert!(
1046            content.contains("LEAN_CTX_ACTIVE"),
1047            "env.sh must check LEAN_CTX_ACTIVE to prevent re-entry"
1048        );
1049        assert!(
1050            content.contains("/.dockerenv"),
1051            "env.sh self-heal must be gated to container environments"
1052        );
1053
1054        std::env::remove_var("LEAN_CTX_DATA_DIR");
1055    }
1056
1057    #[cfg(unix)]
1058    #[test]
1059    fn bash_login_profile_sources_bashrc_idempotently() {
1060        let _g = crate::core::data_dir::test_env_lock();
1061        let tmp = tempfile::tempdir().expect("tempdir");
1062        let home = tmp.path();
1063        let prev = std::env::var_os("HOME");
1064        std::env::set_var("HOME", home);
1065
1066        std::fs::write(home.join(".bashrc"), "# bashrc\n").expect("write .bashrc");
1067        // No login profile yet → the function must create ~/.bash_profile.
1068
1069        ensure_bash_login_sources_bashrc();
1070        let profile = home.join(".bash_profile");
1071        let first = std::fs::read_to_string(&profile).expect(".bash_profile created");
1072        assert!(
1073            first.contains(". \"$HOME/.bashrc\""),
1074            "login profile must source ~/.bashrc: {first}"
1075        );
1076        let markers = first.matches("load ~/.bashrc in login shells").count();
1077
1078        // Second run is a no-op: it already sources ~/.bashrc.
1079        ensure_bash_login_sources_bashrc();
1080        let second = std::fs::read_to_string(&profile).expect("read profile");
1081        assert_eq!(
1082            second.matches("load ~/.bashrc in login shells").count(),
1083            markers,
1084            "snippet must not be duplicated on re-run"
1085        );
1086
1087        match prev {
1088            Some(v) => std::env::set_var("HOME", v),
1089            None => std::env::remove_var("HOME"),
1090        }
1091    }
1092
1093    #[test]
1094    fn test_source_line_posix() {
1095        let line = source_line_posix("zsh");
1096        assert!(line.contains("shell-hook.zsh"));
1097        assert!(line.contains("[ -f"));
1098    }
1099
1100    #[test]
1101    fn test_source_line_fish() {
1102        let line = source_line_fish();
1103        assert!(line.contains("shell-hook.fish"));
1104        assert!(line.contains("source"));
1105    }
1106
1107    #[test]
1108    fn test_source_line_powershell() {
1109        let line = source_line_powershell();
1110        assert!(line.contains("shell-hook.ps1"));
1111        assert!(line.contains("Test-Path"));
1112    }
1113}