lean_ctx/cli/
shell_init.rs1macro_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}