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