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" => generate_hook_posix(&binary),
15        "zsh" => generate_hook_posix(&binary),
16        "fish" => generate_hook_fish(&binary),
17        "powershell" | "pwsh" => generate_hook_powershell(&binary),
18        _ => {
19            eprintln!("nebu-ctx: unsupported shell '{shell}'");
20            eprintln!("Supported: bash, zsh, fish, powershell");
21            std::process::exit(1);
22        }
23    };
24    print!("{code}");
25}
26
27fn backup_shell_config(path: &std::path::Path) {
28    if !path.exists() {
29        return;
30    }
31    let bak = path.with_extension("nebu-ctx.bak");
32    if std::fs::copy(path, &bak).is_ok() {
33        qprintln!(
34            "  Backup: {}",
35            bak.file_name()
36                .map(|n| format!("~/{}", n.to_string_lossy()))
37                .unwrap_or_else(|| bak.display().to_string())
38        );
39    }
40}
41
42fn nebu_ctx_dir() -> Option<std::path::PathBuf> {
43    dirs::home_dir().map(|h| h.join(".nebu-ctx"))
44}
45
46fn write_hook_file(filename: &str, content: &str) -> Option<std::path::PathBuf> {
47    let dir = nebu_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(()) => Some(path),
52        Err(e) => {
53            eprintln!("Error writing {}: {e}", path.display());
54            None
55        }
56    }
57}
58
59fn source_line_posix(shell_ext: &str) -> String {
60    format!(
61        r#"# nebu-ctx shell hook
62[ -f "$HOME/.nebu-ctx/shell-hook.{shell_ext}" ] && . "$HOME/.nebu-ctx/shell-hook.{shell_ext}"
63"#
64    )
65}
66
67fn source_line_fish() -> String {
68    r#"# nebu-ctx shell hook
69fish_add_path "$HOME/.cargo/bin"
70if test -f "$HOME/.nebu-ctx/shell-hook.fish"
71    source "$HOME/.nebu-ctx/shell-hook.fish"
72end
73"#
74    .to_string()
75}
76
77fn source_line_powershell() -> String {
78    r#"# nebu-ctx shell hook
79$nebuCtxHook = Join-Path $HOME ".nebu-ctx" "shell-hook.ps1"
80if (Test-Path $nebuCtxHook) { . $nebuCtxHook }
81"#
82    .to_string()
83}
84
85fn upsert_source_line(rc_path: &std::path::Path, source_line: &str) {
86    backup_shell_config(rc_path);
87
88    if let Ok(existing) = std::fs::read_to_string(rc_path) {
89        if existing.contains(".nebu-ctx/shell-hook.") {
90            return;
91        }
92
93        let cleaned = if existing.contains("nebu-ctx shell hook") {
94            remove_nebu_ctx_block(&existing)
95        } else {
96            existing
97        };
98
99        match std::fs::write(rc_path, format!("{cleaned}{source_line}")) {
100            Ok(()) => {
101                qprintln!("Updated nebu-ctx hook in {}", rc_path.display());
102            }
103            Err(e) => {
104                eprintln!("Error updating {}: {e}", rc_path.display());
105            }
106        }
107        return;
108    }
109
110    match std::fs::OpenOptions::new()
111        .append(true)
112        .create(true)
113        .open(rc_path)
114    {
115        Ok(mut f) => {
116            use std::io::Write;
117            let _ = f.write_all(source_line.as_bytes());
118            qprintln!("Added nebu-ctx hook to {}", rc_path.display());
119        }
120        Err(e) => eprintln!("Error writing {}: {e}", rc_path.display()),
121    }
122}
123
124pub fn generate_hook_powershell(binary: &str) -> String {
125    let binary_escaped = binary.replace('\\', "\\\\");
126    format!(
127        r#"# nebu-ctx shell hook — transparent CLI compression (90+ patterns)
128if (-not $env:NEBU_CTX_ACTIVE -and -not $env:NEBU_CTX_DISABLED) {{
129  $NebuCtxBin = "{binary_escaped}"
130  function _lc {{
131    if ($env:NEBU_CTX_DISABLED -or [Console]::IsOutputRedirected) {{ & @args; return }}
132    & $NebuCtxBin -c @args
133    if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
134      & @args
135    }}
136  }}
137    function nebu-ctx-raw {{ $env:NEBU_CTX_RAW = '1'; & @args; Remove-Item Env:NEBU_CTX_RAW -ErrorAction SilentlyContinue }}
138    function git {{ _lc git @args }}
139    function cargo {{ _lc cargo @args }}
140    function docker {{ _lc docker @args }}
141    function kubectl {{ _lc kubectl @args }}
142    function gh {{ _lc gh @args }}
143    function pip {{ _lc pip @args }}
144    function pip3 {{ _lc pip3 @args }}
145    function ruff {{ _lc ruff @args }}
146    function go {{ _lc go @args }}
147    function curl {{ _lc curl @args }}
148    function wget {{ _lc wget @args }}
149    foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
150        if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
151            New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc $c @args")) -Force | Out-Null
152        }}
153    }}
154}}
155"#
156    )
157}
158
159pub fn init_powershell(binary: &str) {
160    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
161    let profile_path = match profile_dir {
162        Some(dir) => {
163            let _ = std::fs::create_dir_all(&dir);
164            dir.join("Microsoft.PowerShell_profile.ps1")
165        }
166        None => {
167            eprintln!("Could not resolve PowerShell profile directory");
168            return;
169        }
170    };
171
172    let hook_content = generate_hook_powershell(binary);
173
174    if write_hook_file("shell-hook.ps1", &hook_content).is_some() {
175        upsert_source_line(&profile_path, &source_line_powershell());
176        qprintln!("  Binary: {binary}");
177    }
178}
179
180pub fn remove_nebu_ctx_block_ps(content: &str) -> String {
181    let mut result = String::new();
182    let mut in_block = false;
183    let mut brace_depth = 0i32;
184
185    for line in content.lines() {
186        if line.contains("nebu-ctx shell hook") {
187            in_block = true;
188            continue;
189        }
190        if in_block {
191            brace_depth += line.matches('{').count() as i32;
192            brace_depth -= line.matches('}').count() as i32;
193            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
194                if line.trim() == "}" {
195                    in_block = false;
196                    brace_depth = 0;
197                }
198                continue;
199            }
200            continue;
201        }
202        result.push_str(line);
203        result.push('\n');
204    }
205    result
206}
207
208pub fn generate_hook_fish(binary: &str) -> String {
209    let alias_list = crate::rewrite_registry::shell_alias_list();
210    format!(
211        "# nebu-ctx shell hook — smart shell mode (track-by-default)\n\
212        set -g _nebu_ctx_cmds {alias_list}\n\
213        \n\
214        function _lc\n\
215        \tif set -q NEBU_CTX_DISABLED; or not isatty stdout\n\
216        \t\tcommand $argv\n\
217        \t\treturn\n\
218        \tend\n\
219        \t'{binary}' -t $argv\n\
220        \tset -l _lc_rc $status\n\
221        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
222        \t\tcommand $argv\n\
223        \telse\n\
224        \t\treturn $_lc_rc\n\
225        \tend\n\
226        end\n\
227        \n\
228        function _lc_compress\n\
229        \tif set -q NEBU_CTX_DISABLED; or not isatty stdout\n\
230        \t\tcommand $argv\n\
231        \t\treturn\n\
232        \tend\n\
233        \t'{binary}' -c $argv\n\
234        \tset -l _lc_rc $status\n\
235        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
236        \t\tcommand $argv\n\
237        \telse\n\
238        \t\treturn $_lc_rc\n\
239        \tend\n\
240        end\n\
241        \n\
242        function nebu-ctx-on\n\
243        \tfor _lc_cmd in $_nebu_ctx_cmds\n\
244        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
245        \tend\n\
246        \talias k '_lc kubectl'\n\
247        \tset -gx NEBU_CTX_ENABLED 1\n\
248        \tisatty stdout; and '{binary}' on-brief\n\
249        end\n\
250        \n\
251        function nebu-ctx-off\n\
252        \tfor _lc_cmd in $_nebu_ctx_cmds\n\
253        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
254        \tend\n\
255        \tfunctions --erase k 2>/dev/null; true\n\
256        \tset -e NEBU_CTX_ENABLED\n\
257        \tisatty stdout; and echo 'nebu-ctx: OFF'\n\
258        end\n\
259        \n\
260        function nebu-ctx-mode\n\
261        \tswitch $argv[1]\n\
262        \t\tcase compress\n\
263        \t\t\tfor _lc_cmd in $_nebu_ctx_cmds\n\
264        \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
265        \t\t\t\tend\n\
266        \t\t\talias k '_lc_compress kubectl'\n\
267        \t\t\tset -gx NEBU_CTX_ENABLED 1\n\
268        \t\t\tisatty stdout; and echo 'nebu-ctx: COMPRESS mode (all output compressed)'\n\
269        \t\tcase track\n\
270        \t\t\tnebu-ctx-on\n\
271        \t\tcase off\n\
272        \t\t\tnebu-ctx-off\n\
273        \t\tcase '*'\n\
274        \t\t\techo 'Usage: nebu-ctx-mode <track|compress|off>'\n\
275        \t\t\techo '  track    — Full output, stats recorded (default)'\n\
276        \t\t\techo '  compress — Compressed output for all commands'\n\
277        \t\t\techo '  off      — No aliases, raw shell'\n\
278        \tend\n\
279        end\n\
280        \n\
281        function nebu-ctx-raw\n\
282        \tset -lx NEBU_CTX_RAW 1\n\
283        \tcommand $argv\n\
284        end\n\
285        \n\
286        function nebu-ctx-status\n\
287        \tif set -q NEBU_CTX_DISABLED\n\
288        \t\tisatty stdout; and echo 'nebu-ctx: DISABLED (NEBU_CTX_DISABLED is set)'\n\
289        \telse if set -q NEBU_CTX_ENABLED\n\
290        \t\tisatty stdout; and echo 'nebu-ctx: ON'\n\
291        \telse\n\
292        \t\tisatty stdout; and echo 'nebu-ctx: OFF'\n\
293        \tend\n\
294        end\n\
295        \n\
296        if not set -q NEBU_CTX_ACTIVE; and not set -q NEBU_CTX_DISABLED; and test (set -q NEBU_CTX_ENABLED; and echo $NEBU_CTX_ENABLED; or echo 1) != '0'\n\
297    	nebu-ctx-on\n\
298        end\n"
299    )
300}
301
302pub fn init_fish(binary: &str) {
303    let config = dirs::home_dir()
304        .map(|h| h.join(".config/fish/config.fish"))
305        .unwrap_or_default();
306
307    let hook_content = generate_hook_fish(binary);
308
309    if write_hook_file("shell-hook.fish", &hook_content).is_some() {
310        upsert_source_line(&config, &source_line_fish());
311        qprintln!("  Binary: {binary}");
312    }
313}
314
315pub fn generate_hook_posix(binary: &str) -> String {
316    let alias_list = crate::rewrite_registry::shell_alias_list();
317    format!(
318        r#"# nebu-ctx shell hook — smart shell mode (track-by-default)
319_nebu_ctx_cmds=({alias_list})
320
321_lc() {{
322    if [ -n "${{NEBU_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
323        command "$@"
324        return
325    fi
326    '{binary}' -t "$@"
327    local _lc_rc=$?
328    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
329        command "$@"
330    else
331        return "$_lc_rc"
332    fi
333}}
334
335_lc_compress() {{
336    if [ -n "${{NEBU_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
337        command "$@"
338        return
339    fi
340    '{binary}' -c "$@"
341    local _lc_rc=$?
342    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
343        command "$@"
344    else
345        return "$_lc_rc"
346    fi
347}}
348
349nebu-ctx-on() {{
350    for _lc_cmd in "${{_nebu_ctx_cmds[@]}}"; do
351        # shellcheck disable=SC2139
352        alias "$_lc_cmd"='_lc '"$_lc_cmd"
353    done
354    alias k='_lc kubectl'
355    export NEBU_CTX_ENABLED=1
356    [ -t 1 ] && '{binary}' on-brief
357}}
358
359nebu-ctx-off() {{
360    for _lc_cmd in "${{_nebu_ctx_cmds[@]}}"; do
361        unalias "$_lc_cmd" 2>/dev/null || true
362    done
363    unalias k 2>/dev/null || true
364    unset NEBU_CTX_ENABLED
365    [ -t 1 ] && echo "nebu-ctx: OFF"
366}}
367
368nebu-ctx-mode() {{
369    case "${{1:-}}" in
370        compress)
371            for _lc_cmd in "${{_nebu_ctx_cmds[@]}}"; do
372                # shellcheck disable=SC2139
373                alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
374            done
375            alias k='_lc_compress kubectl'
376            export NEBU_CTX_ENABLED=1
377            [ -t 1 ] && echo "nebu-ctx: COMPRESS mode (all output compressed)"
378            ;;
379        track)
380            nebu-ctx-on
381            ;;
382        off)
383            nebu-ctx-off
384            ;;
385        *)
386            echo "Usage: nebu-ctx-mode <track|compress|off>"
387            echo "  track    — Full output, stats recorded (default)"
388            echo "  compress — Compressed output for all commands"
389            echo "  off      — No aliases, raw shell"
390            ;;
391    esac
392}}
393
394nebu-ctx-raw() {{
395    NEBU_CTX_RAW=1 command "$@"
396}}
397
398nebu-ctx-status() {{
399    if [ -n "${{NEBU_CTX_DISABLED:-}}" ]; then
400        [ -t 1 ] && echo "nebu-ctx: DISABLED (NEBU_CTX_DISABLED is set)"
401    elif [ -n "${{NEBU_CTX_ENABLED:-}}" ]; then
402        [ -t 1 ] && echo "nebu-ctx: ON"
403    else
404        [ -t 1 ] && echo "nebu-ctx: OFF"
405    fi
406}}
407
408if [ -z "${{NEBU_CTX_ACTIVE:-}}" ] && [ -z "${{NEBU_CTX_DISABLED:-}}" ] && [ "${{NEBU_CTX_ENABLED:-1}}" != "0" ]; then
409    nebu-ctx-on
410fi
411"#
412    )
413}
414
415pub fn init_posix(is_zsh: bool, binary: &str) {
416    let rc_file = if is_zsh {
417        dirs::home_dir()
418            .map(|h| h.join(".zshrc"))
419            .unwrap_or_default()
420    } else {
421        dirs::home_dir()
422            .map(|h| h.join(".bashrc"))
423            .unwrap_or_default()
424    };
425
426    let shell_ext = if is_zsh { "zsh" } else { "bash" };
427    let hook_content = generate_hook_posix(binary);
428
429    if let Some(hook_path) = write_hook_file(&format!("shell-hook.{shell_ext}"), &hook_content) {
430        upsert_source_line(&rc_file, &source_line_posix(shell_ext));
431        qprintln!("  Binary: {binary}");
432
433        write_env_sh_for_containers(&hook_content);
434        print_docker_env_hints(is_zsh);
435
436        let _ = hook_path;
437    }
438}
439
440pub fn write_env_sh_for_containers(aliases: &str) {
441    let env_sh = match crate::core::data_dir::nebu_ctx_data_dir() {
442        Ok(d) => d.join("env.sh"),
443        Err(_) => return,
444    };
445    if let Some(parent) = env_sh.parent() {
446        let _ = std::fs::create_dir_all(parent);
447    }
448    let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
449    let mut content = sanitized_aliases;
450    content.push_str(
451        r#"
452
453# nebu-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
454if command -v claude >/dev/null 2>&1 && command -v nebu-ctx >/dev/null 2>&1; then
455    if ! claude mcp list 2>/dev/null | grep -Eq "nebu-ctx|nebu-ctx"; then
456        NEBU_CTX_QUIET=1 nebu-ctx init --agent claude >/dev/null 2>&1
457  fi
458fi
459"#,
460    );
461    match std::fs::write(&env_sh, content) {
462        Ok(()) => {
463            if !matches!(std::env::var("NEBU_CTX_QUIET"), Ok(value) if value.trim() == "1") {
464                println!("  env.sh: {}", env_sh.display());
465            }
466        }
467        Err(e) => eprintln!("  Warning: could not write {}: {e}", env_sh.display()),
468    }
469}
470
471fn print_docker_env_hints(is_zsh: bool) {
472    if is_zsh || !crate::shell::is_container() {
473        return;
474    }
475    let env_sh = crate::core::data_dir::nebu_ctx_data_dir()
476        .map(|d| d.join("env.sh").to_string_lossy().to_string())
477        .unwrap_or_else(|_| "/root/.nebu-ctx/env.sh".to_string());
478
479    let has_bash_env = std::env::var("BASH_ENV").is_ok();
480    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
481
482    if has_bash_env && has_claude_env {
483        return;
484    }
485
486    eprintln!();
487    eprintln!("  \x1b[33m⚠  Docker detected — environment hints:\x1b[0m");
488
489    if !has_bash_env {
490        eprintln!("  For generic bash -c usage (non-interactive shells):");
491        eprintln!("    \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
492    }
493    if !has_claude_env {
494        eprintln!("  For Claude Code (sources before each command):");
495        eprintln!("    \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
496    }
497    eprintln!();
498}
499
500pub fn remove_nebu_ctx_block(content: &str) -> String {
501    if content.contains("# nebu-ctx shell hook — end") {
502        return remove_nebu_ctx_block_by_marker(content);
503    }
504    remove_nebu_ctx_block_legacy(content)
505}
506
507fn remove_nebu_ctx_block_by_marker(content: &str) -> String {
508    let mut result = String::new();
509    let mut in_block = false;
510
511    for line in content.lines() {
512        if !in_block && line.contains("nebu-ctx shell hook") && !line.contains("end") {
513            in_block = true;
514            continue;
515        }
516        if in_block {
517            if line.trim() == "# nebu-ctx shell hook — end" {
518                in_block = false;
519            }
520            continue;
521        }
522        result.push_str(line);
523        result.push('\n');
524    }
525    result
526}
527
528fn remove_nebu_ctx_block_legacy(content: &str) -> String {
529    let mut result = String::new();
530    let mut in_block = false;
531
532    for line in content.lines() {
533        if line.contains("nebu-ctx shell hook") {
534            in_block = true;
535            continue;
536        }
537        if in_block {
538            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
539                if line.trim() == "fi" || line.trim() == "end" {
540                    in_block = false;
541                }
542                continue;
543            }
544            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
545                in_block = false;
546                result.push_str(line);
547                result.push('\n');
548            }
549            continue;
550        }
551        result.push_str(line);
552        result.push('\n');
553    }
554    result
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_remove_nebu_ctx_block_posix() {
563        let input = r#"# existing config
564export PATH="$HOME/bin:$PATH"
565
566# nebu-ctx shell hook — transparent CLI compression (90+ patterns)
567if [ -z "$NEBU_CTX_ACTIVE" ]; then
568alias git='nebu-ctx -c git'
569alias npm='nebu-ctx -c npm'
570fi
571
572# other stuff
573export EDITOR=vim
574"#;
575        let result = remove_nebu_ctx_block(input);
576        assert!(!result.contains("nebu-ctx"), "block should be removed");
577        assert!(result.contains("export PATH"), "other content preserved");
578        assert!(
579            result.contains("export EDITOR"),
580            "trailing content preserved"
581        );
582    }
583
584    #[test]
585    fn test_remove_nebu_ctx_block_fish() {
586        let input = "# other fish config\nset -x FOO bar\n\n# nebu-ctx shell hook — transparent CLI compression (90+ patterns)\nif not set -q NEBU_CTX_ACTIVE\n\talias git 'nebu-ctx -c git'\n\talias npm 'nebu-ctx -c npm'\nend\n\n# more config\nset -x BAZ qux\n";
587        let result = remove_nebu_ctx_block(input);
588        assert!(!result.contains("nebu-ctx"), "block should be removed");
589        assert!(result.contains("set -x FOO"), "other content preserved");
590        assert!(result.contains("set -x BAZ"), "trailing content preserved");
591    }
592
593    #[test]
594    fn test_remove_nebu_ctx_block_ps() {
595        let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# nebu-ctx shell hook — transparent CLI compression (90+ patterns)\nif (-not $env:NEBU_CTX_ACTIVE) {\n  $NebuCtxBin = \"C:\\\\bin\\\\nebu-ctx.exe\"\n  function git { & $NebuCtxBin -c \"git $($args -join ' ')\" }\n}\n\n# other stuff\n$env:EDITOR = 'vim'\n";
596        let result = remove_nebu_ctx_block_ps(input);
597        assert!(
598            !result.contains("nebu-ctx shell hook"),
599            "block should be removed"
600        );
601        assert!(result.contains("$env:FOO"), "other content preserved");
602        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
603    }
604
605    #[test]
606    fn test_remove_nebu_ctx_block_ps_nested() {
607        let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# nebu-ctx shell hook — transparent CLI compression (90+ patterns)\nif (-not $env:NEBU_CTX_ACTIVE) {\n  $NebuCtxBin = \"nebu-ctx\"\n  function _lc {\n    & $NebuCtxBin -c \"$($args -join ' ')\"\n  }\n  if (Get-Command nebu-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";
608        let result = remove_nebu_ctx_block_ps(input);
609        assert!(
610            !result.contains("nebu-ctx shell hook"),
611            "block should be removed"
612        );
613        assert!(!result.contains("_lc"), "function should be removed");
614        assert!(result.contains("$env:FOO"), "other content preserved");
615        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
616    }
617
618    #[test]
619    fn test_remove_block_no_nebu_ctx() {
620        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
621        let result = remove_nebu_ctx_block(input);
622        assert!(result.contains("export PATH"), "content unchanged");
623    }
624
625    #[test]
626    fn test_bash_hook_contains_pipe_guard() {
627        let binary = "/usr/local/bin/nebu-ctx";
628        let hook = format!(
629            r#"_lc() {{
630    if [ -n "${{NEBU_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
631        command "$@"
632        return
633    fi
634    '{binary}' -t "$@"
635}}"#
636        );
637        assert!(
638            hook.contains("! -t 1"),
639            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
640        );
641        assert!(
642            hook.contains("NEBU_CTX_DISABLED") && hook.contains("! -t 1"),
643            "pipe guard must be in the same conditional as NEBU_CTX_DISABLED"
644        );
645    }
646
647    #[test]
648    fn test_lc_uses_track_mode_by_default() {
649        let binary = "/usr/local/bin/nebu-ctx";
650        let alias_list = crate::rewrite_registry::shell_alias_list();
651        let aliases = format!(
652            r#"_lc() {{
653    '{binary}' -t "$@"
654}}
655_lc_compress() {{
656    '{binary}' -c "$@"
657}}"#
658        );
659        assert!(
660            aliases.contains("-t \"$@\""),
661            "_lc must use -t (track mode) by default"
662        );
663        assert!(
664            aliases.contains("-c \"$@\""),
665            "_lc_compress must use -c (compress mode)"
666        );
667        let _ = alias_list;
668    }
669
670    #[test]
671    fn test_posix_shell_has_nebu_ctx_mode() {
672        let alias_list = crate::rewrite_registry::shell_alias_list();
673        let aliases = r#"
674nebu-ctx-mode() {{
675    case "${{1:-}}" in
676        compress) echo compress ;;
677        track) echo track ;;
678        off) echo off ;;
679    esac
680}}
681"#
682        .to_string();
683        assert!(
684            aliases.contains("nebu-ctx-mode()"),
685            "nebu-ctx-mode function must exist"
686        );
687        assert!(
688            aliases.contains("compress"),
689            "compress mode must be available"
690        );
691        assert!(aliases.contains("track"), "track mode must be available");
692        let _ = alias_list;
693    }
694
695    #[test]
696    fn test_fish_hook_contains_pipe_guard() {
697        let hook = "function _lc\n\tif set -q NEBU_CTX_DISABLED; or not isatty stdout\n\t\tcommand $argv\n\t\treturn\n\tend\nend";
698        assert!(
699            hook.contains("isatty stdout"),
700            "fish hook must contain pipe guard (isatty stdout)"
701        );
702    }
703
704    #[test]
705    fn test_powershell_hook_contains_pipe_guard() {
706        let hook = "function _lc { if ($env:NEBU_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
707        assert!(
708            hook.contains("IsOutputRedirected"),
709            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
710        );
711    }
712
713    #[test]
714    fn test_remove_nebu_ctx_block_new_format_with_end_marker() {
715        let input = r#"# existing config
716export PATH="$HOME/bin:$PATH"
717
718# nebu-ctx shell hook — transparent CLI compression (90+ patterns)
719_nebu_ctx_cmds=(git npm pnpm)
720
721nebu-ctx-on() {
722    for _lc_cmd in "${_nebu_ctx_cmds[@]}"; do
723        alias "$_lc_cmd"='nebu-ctx -c '"$_lc_cmd"
724    done
725    export NEBU_CTX_ENABLED=1
726    [ -t 1 ] && echo "nebu-ctx: ON"
727}
728
729nebu-ctx-off() {
730    unset NEBU_CTX_ENABLED
731    [ -t 1 ] && echo "nebu-ctx: OFF"
732}
733
734if [ -z "${NEBU_CTX_ACTIVE:-}" ] && [ "${NEBU_CTX_ENABLED:-1}" != "0" ]; then
735    nebu-ctx-on
736fi
737# nebu-ctx shell hook — end
738
739# other stuff
740export EDITOR=vim
741"#;
742        let result = remove_nebu_ctx_block(input);
743        assert!(!result.contains("nebu-ctx-on"), "block should be removed");
744        assert!(!result.contains("nebu-ctx shell hook"), "marker removed");
745        assert!(result.contains("export PATH"), "other content preserved");
746        assert!(
747            result.contains("export EDITOR"),
748            "trailing content preserved"
749        );
750    }
751
752    #[test]
753    fn env_sh_for_containers_includes_self_heal() {
754        let _g = crate::core::data_dir::test_env_lock();
755        let tmp = tempfile::tempdir().expect("tempdir");
756        let data_dir = tmp.path().join("data");
757        std::fs::create_dir_all(&data_dir).expect("mkdir data");
758        std::env::set_var("NEBU_CTX_DATA_DIR", &data_dir);
759
760        write_env_sh_for_containers("alias git='nebu-ctx -c git'\n");
761        let env_sh = data_dir.join("env.sh");
762        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
763        assert!(content.contains("nebu-ctx docker self-heal"));
764        assert!(content.contains("claude mcp list"));
765        assert!(content.contains("nebu-ctx init --agent claude"));
766
767        std::env::remove_var("NEBU_CTX_DATA_DIR");
768    }
769
770    #[test]
771    fn test_source_line_posix() {
772        let line = source_line_posix("zsh");
773        assert!(line.contains("shell-hook.zsh"));
774        assert!(line.contains("[ -f"));
775    }
776
777    #[test]
778    fn test_source_line_fish() {
779        let line = source_line_fish();
780        assert!(line.contains("shell-hook.fish"));
781        assert!(line.contains("source"));
782    }
783
784    #[test]
785    fn test_source_line_powershell() {
786        let line = source_line_powershell();
787        assert!(line.contains("shell-hook.ps1"));
788        assert!(line.contains("Test-Path"));
789    }
790}