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