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