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_path = if let Some(home) = dirs::home_dir() {
254 let path = crate::shell::platform::powershell_profile_path(&home);
255 if let Some(dir) = path.parent() {
256 let _ = std::fs::create_dir_all(dir);
257 }
258 path
259 } else {
260 tracing::error!("Could not resolve PowerShell profile directory");
261 return;
262 };
263
264 let hook_content = generate_hook_powershell(binary);
265
266 if write_hook_file("shell-hook.ps1", &hook_content).is_some() {
267 upsert_source_line(&profile_path, &source_line_powershell());
268 qprintln!(" Binary: {binary}");
269 }
270}
271
272pub fn remove_lean_ctx_block_ps(content: &str) -> String {
273 let mut result = String::new();
274 let mut in_block = false;
275 let mut brace_depth = 0i32;
276
277 for line in content.lines() {
278 if line.contains("lean-ctx shell hook") {
279 in_block = true;
280 continue;
281 }
282 if in_block {
283 brace_depth += line.matches('{').count() as i32;
284 brace_depth -= line.matches('}').count() as i32;
285 if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
286 if line.trim() == "}" {
287 in_block = false;
288 brace_depth = 0;
289 }
290 continue;
291 }
292 continue;
293 }
294 result.push_str(line);
295 result.push('\n');
296 }
297 result
298}
299
300pub fn generate_hook_fish(binary: &str) -> String {
301 let config = crate::core::config::Config::load();
302 let activation = config.shell_activation_effective();
303 let baked_default = match activation {
304 crate::core::config::ShellActivation::Always => "always",
305 crate::core::config::ShellActivation::AgentsOnly => "agents-only",
306 crate::core::config::ShellActivation::Off => "off",
307 };
308 let alias_list = crate::rewrite_registry::shell_alias_list();
309 format!(
310 "# lean-ctx shell hook — smart shell mode (track-by-default)\n\
311 set -g _lean_ctx_cmds {alias_list}\n\
312 \n\
313 function _lc_is_agent\n\
314 \tset -q LEAN_CTX_AGENT; or set -q CODEX_CLI_SESSION; or set -q CLAUDECODE; or set -q GEMINI_SESSION\n\
315 end\n\
316 \n\
317 function _lc\n\
318 \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
319 \t\tcommand $argv\n\
320 \t\treturn\n\
321 \tend\n\
322 \tif not isatty stdout; and not _lc_is_agent\n\
323 \t\tcommand $argv\n\
324 \t\treturn\n\
325 \tend\n\
326 \t'{binary}' -t $argv\n\
327 \tset -l _lc_rc $status\n\
328 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
329 \t\tcommand $argv\n\
330 \telse\n\
331 \t\treturn $_lc_rc\n\
332 \tend\n\
333 end\n\
334 \n\
335 function _lc_compress\n\
336 \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
337 \t\tcommand $argv\n\
338 \t\treturn\n\
339 \tend\n\
340 \tif not isatty stdout; and not _lc_is_agent\n\
341 \t\tcommand $argv\n\
342 \t\treturn\n\
343 \tend\n\
344 \t'{binary}' -c $argv\n\
345 \tset -l _lc_rc $status\n\
346 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
347 \t\tcommand $argv\n\
348 \telse\n\
349 \t\treturn $_lc_rc\n\
350 \tend\n\
351 end\n\
352 \n\
353 function lean-ctx-on\n\
354 \tfor _lc_cmd in $_lean_ctx_cmds\n\
355 \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
356 \tend\n\
357 \talias k '_lc kubectl'\n\
358 \tset -gx LEAN_CTX_ENABLED 1\n\
359 \tisatty stdout; and echo 'lean-ctx: ON (track mode — output unchanged, token savings recorded)'\n\
360 end\n\
361 \n\
362 function lean-ctx-off\n\
363 \tfor _lc_cmd in $_lean_ctx_cmds\n\
364 \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
365 \tend\n\
366 \tfunctions --erase k 2>/dev/null; true\n\
367 \tset -gx LEAN_CTX_ENABLED 0\n\
368 \tisatty stdout; and echo 'lean-ctx: OFF'\n\
369 end\n\
370 \n\
371 function lean-ctx-mode\n\
372 \tswitch $argv[1]\n\
373 \t\tcase compress\n\
374 \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
375 \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
376 \t\t\t\tend\n\
377 \t\t\talias k '_lc_compress kubectl'\n\
378 \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
379 \t\t\tisatty stdout; and echo 'lean-ctx: COMPRESS mode (all output compressed)'\n\
380 \t\tcase track\n\
381 \t\t\tlean-ctx-on\n\
382 \t\tcase off\n\
383 \t\t\tlean-ctx-off\n\
384 \t\tcase '*'\n\
385 \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
386 \t\t\techo ' track — Full output, stats recorded (default)'\n\
387 \t\t\techo ' compress — Compressed output for all commands'\n\
388 \t\t\techo ' off — No aliases, raw shell'\n\
389 \tend\n\
390 end\n\
391 \n\
392 function lean-ctx-raw\n\
393 \tset -lx LEAN_CTX_RAW 1\n\
394 \tcommand $argv\n\
395 end\n\
396 \n\
397 function lean-ctx-status\n\
398 \tif set -q LEAN_CTX_DISABLED\n\
399 \t\tisatty stdout; and echo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
400 \telse if set -q LEAN_CTX_ENABLED\n\
401 \t\tisatty stdout; and echo 'lean-ctx: ON'\n\
402 \telse\n\
403 \t\tisatty stdout; and echo 'lean-ctx: OFF'\n\
404 \tend\n\
405 end\n\
406 \n\
407 function _lean_ctx_should_activate\n\
408 \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\
409 \t\treturn 1\n\
410 \tend\n\
411 \tset -l _lc_mode (set -q LEAN_CTX_SHELL_ACTIVATION; and echo $LEAN_CTX_SHELL_ACTIVATION; or echo '{baked_default}')\n\
412 \tswitch $_lc_mode\n\
413 \t\tcase off none manual\n\
414 \t\t\treturn 1\n\
415 \t\tcase 'agents-only' agents_only agentsonly\n\
416 \t\t\tif set -q LEAN_CTX_AGENT; or set -q CLAUDECODE; or set -q CODEX_CLI_SESSION; or set -q GEMINI_SESSION\n\
417 \t\t\t\treturn 0\n\
418 \t\t\tend\n\
419 \t\t\treturn 1\n\
420 \t\tcase '*'\n\
421 \t\t\treturn 0\n\
422 \tend\n\
423 end\n\
424 \n\
425 if _lean_ctx_should_activate\n\
426 \tif command -q lean-ctx\n\
427 \t\tlean-ctx-on\n\
428 \tend\n\
429 end\n"
430 )
431}
432
433pub fn init_fish(binary: &str) {
434 let config = dirs::home_dir()
435 .map(|h| h.join(".config/fish/config.fish"))
436 .unwrap_or_default();
437
438 let hook_content = generate_hook_fish(binary);
439
440 if write_hook_file("shell-hook.fish", &hook_content).is_some() {
441 upsert_source_line(&config, &source_line_fish());
442 qprintln!(" Binary: {binary}");
443 }
444}
445
446pub fn generate_hook_posix(binary: &str) -> String {
447 let config = crate::core::config::Config::load();
448 let activation = config.shell_activation_effective();
449 let baked_default = match activation {
450 crate::core::config::ShellActivation::Always => "always",
451 crate::core::config::ShellActivation::AgentsOnly => "agents-only",
452 crate::core::config::ShellActivation::Off => "off",
453 };
454 let alias_list = crate::rewrite_registry::shell_alias_list();
455 format!(
456 r#"# lean-ctx shell hook — smart shell mode (track-by-default)
457_lean_ctx_cmds=({alias_list})
458
459_lc_is_agent() {{
460 [ -n "${{LEAN_CTX_AGENT:-}}" ] || [ -n "${{CODEX_CLI_SESSION:-}}" ] || [ -n "${{CLAUDECODE:-}}" ] || [ -n "${{GEMINI_SESSION:-}}" ]
461}}
462
463_lc() {{
464 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
465 command "$@"
466 return
467 fi
468 if [ ! -t 1 ] && ! _lc_is_agent; then
469 command "$@"
470 return
471 fi
472 '{binary}' -t "$@"
473 local _lc_rc=$?
474 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
475 command "$@"
476 else
477 return "$_lc_rc"
478 fi
479}}
480
481_lc_compress() {{
482 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
483 command "$@"
484 return
485 fi
486 if [ ! -t 1 ] && ! _lc_is_agent; then
487 command "$@"
488 return
489 fi
490 '{binary}' -c "$@"
491 local _lc_rc=$?
492 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
493 command "$@"
494 else
495 return "$_lc_rc"
496 fi
497}}
498
499lean-ctx-on() {{
500 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
501 # shellcheck disable=SC2139
502 alias "$_lc_cmd"='_lc '"$_lc_cmd"
503 done
504 alias k='_lc kubectl'
505 export LEAN_CTX_ENABLED=1
506 [ -t 1 ] && echo "lean-ctx: ON (track mode — output unchanged, token savings recorded)"
507}}
508
509lean-ctx-off() {{
510 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
511 unalias "$_lc_cmd" 2>/dev/null || true
512 done
513 unalias k 2>/dev/null || true
514 export LEAN_CTX_ENABLED=0
515 [ -t 1 ] && echo "lean-ctx: OFF"
516}}
517
518lean-ctx-mode() {{
519 case "${{1:-}}" in
520 compress)
521 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
522 # shellcheck disable=SC2139
523 alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
524 done
525 alias k='_lc_compress kubectl'
526 export LEAN_CTX_ENABLED=1
527 [ -t 1 ] && echo "lean-ctx: COMPRESS mode (all output compressed)"
528 ;;
529 track)
530 lean-ctx-on
531 ;;
532 off)
533 lean-ctx-off
534 ;;
535 *)
536 echo "Usage: lean-ctx-mode <track|compress|off>"
537 echo " track — Full output, stats recorded (default)"
538 echo " compress — Compressed output for all commands"
539 echo " off — No aliases, raw shell"
540 ;;
541 esac
542}}
543
544lean-ctx-raw() {{
545 LEAN_CTX_RAW=1 command "$@"
546}}
547
548lean-ctx-status() {{
549 if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
550 [ -t 1 ] && echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
551 elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
552 [ -t 1 ] && echo "lean-ctx: ON"
553 else
554 [ -t 1 ] && echo "lean-ctx: OFF"
555 fi
556}}
557
558if [ -n "${{ZSH_VERSION:-}}" ]; then
559 _lean_ctx_comp() {{
560 shift words
561 (( CURRENT-- ))
562 _normal
563 }}
564 compdef _lean_ctx_comp _lc 2>/dev/null
565 compdef _lean_ctx_comp _lc_compress 2>/dev/null
566fi
567
568_lean_ctx_should_activate() {{
569 [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ] || return 1
570 case "${{LEAN_CTX_SHELL_ACTIVATION:-{baked_default}}}" in
571 off|none|manual) return 1 ;;
572 agents-only|agents_only|agentsonly)
573 [ -n "${{LEAN_CTX_AGENT:-}}" ] || [ -n "${{CLAUDECODE:-}}" ] || [ -n "${{CODEX_CLI_SESSION:-}}" ] || [ -n "${{GEMINI_SESSION:-}}" ] ;;
574 *) return 0 ;;
575 esac
576}}
577
578if _lean_ctx_should_activate; then
579 command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
580fi
581"#
582 )
583}
584
585pub fn init_posix(is_zsh: bool, binary: &str) {
586 let rc_file = if is_zsh {
587 dirs::home_dir()
588 .map(|h| h.join(".zshrc"))
589 .unwrap_or_default()
590 } else {
591 dirs::home_dir()
592 .map(|h| h.join(".bashrc"))
593 .unwrap_or_default()
594 };
595
596 let shell_ext = if is_zsh { "zsh" } else { "bash" };
597 let hook_content = generate_hook_posix(binary);
598
599 if let Some(hook_path) = write_hook_file(&format!("shell-hook.{shell_ext}"), &hook_content) {
600 upsert_source_line(&rc_file, &source_line_posix(shell_ext));
601
602 if !is_zsh {
605 ensure_bash_login_sources_bashrc();
606 }
607
608 qprintln!(" Binary: {binary}");
609
610 write_env_sh_for_containers(&hook_content);
611 print_docker_env_hints(is_zsh);
612
613 let _ = hook_path;
614 }
615}
616
617fn ensure_bash_login_sources_bashrc() {
624 let Some(home) = dirs::home_dir() else {
625 return;
626 };
627
628 let target = [".bash_profile", ".bash_login", ".profile"]
631 .iter()
632 .map(|f| home.join(f))
633 .find(|p| p.exists())
634 .unwrap_or_else(|| home.join(".bash_profile"));
635
636 if let Ok(existing) = std::fs::read_to_string(&target) {
638 let sources_bashrc = existing
639 .lines()
640 .any(|l| !l.trim_start().starts_with('#') && l.contains(".bashrc"));
641 if sources_bashrc {
642 return;
643 }
644 }
645
646 let snippet = "\n# lean-ctx: load ~/.bashrc in login shells (e.g. macOS Terminal) — begin\n\
647 if [ -f \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\"; fi\n\
648 # lean-ctx: load ~/.bashrc in login shells (e.g. macOS Terminal) — end\n";
649
650 backup_shell_config(&target);
651 match std::fs::OpenOptions::new()
652 .append(true)
653 .create(true)
654 .open(&target)
655 {
656 Ok(mut f) => {
657 use std::io::Write;
658 if f.write_all(snippet.as_bytes()).is_ok() {
659 qprintln!(" Login shell: {} now sources ~/.bashrc", target.display());
660 }
661 }
662 Err(e) => {
663 tracing::warn!("could not update {}: {e}", target.display());
664 }
665 }
666}
667
668pub fn write_env_sh_for_containers(aliases: &str) {
669 let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
670 Ok(d) => d.join("env.sh"),
671 Err(_) => return,
672 };
673 if let Some(parent) = env_sh.parent() {
674 let _ = std::fs::create_dir_all(parent);
675 }
676 let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
677 let mut content = String::from(
678 r#"# lean-ctx: passthrough stubs for non-interactive subshells (fixes #255).
679# These ensure _lc/_lc_compress exist so inherited aliases don't break.
680# The full hook definitions override these when the interactive shell loads.
681_lc() { command "$@"; }
682_lc_compress() { command "$@"; }
683
684"#,
685 );
686 content.push_str(&sanitized_aliases);
687 content.push_str(
688 r#"
689
690# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
691# Guards: container-only + no recursion + no re-entry via BASH_ENV + 60s cooldown + PID-lock
692if [ -f /.dockerenv ] || grep -qsE '/docker/|/lxc/' /proc/1/cgroup 2>/dev/null; then
693 if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ -z "${_LEAN_CTX_HEAL:-}" ]; then
694 _LEAN_CTX_HEAL_TS="${HOME}/.lean-ctx/.heal_ts"
695 _LEAN_CTX_HEAL_COOLDOWN=60
696 _lean_ctx_heal_needed=1
697 if [ -f "$_LEAN_CTX_HEAL_TS" ]; then
698 _last_heal=$(cat "$_LEAN_CTX_HEAL_TS" 2>/dev/null || echo 0)
699 _now=$(date +%s 2>/dev/null || echo 0)
700 if [ $(( _now - _last_heal )) -lt $_LEAN_CTX_HEAL_COOLDOWN ]; then
701 _lean_ctx_heal_needed=0
702 fi
703 fi
704 _lean_ctx_lock_count=0
705 for _lf in "${HOME}/.lean-ctx/locks"/slot-*.lock; do
706 [ -f "$_lf" ] && _lean_ctx_lock_count=$(( _lean_ctx_lock_count + 1 ))
707 done
708 if [ "$_lean_ctx_heal_needed" = "1" ] && [ "$_lean_ctx_lock_count" -lt 4 ]; then
709 export _LEAN_CTX_HEAL=1
710 if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
711 if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
712 LEAN_CTX_ACTIVE=1 LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
713 date +%s > "$_LEAN_CTX_HEAL_TS" 2>/dev/null
714 fi
715 fi
716 fi
717 fi
718fi
719"#,
720 );
721 match std::fs::write(&env_sh, content) {
722 Ok(()) => {
723 if !super::quiet_enabled() {
725 eprintln!(" env.sh: {}", env_sh.display());
726 }
727 }
728 Err(e) => tracing::warn!("could not write {}: {e}", env_sh.display()),
729 }
730}
731
732fn print_docker_env_hints(is_zsh: bool) {
733 if is_zsh || !crate::shell::is_container() {
734 return;
735 }
736 let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
737 |_| "/root/.lean-ctx/env.sh".to_string(),
738 |d| d.join("env.sh").to_string_lossy().to_string(),
739 );
740
741 let has_bash_env = std::env::var("BASH_ENV").is_ok();
742 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
743
744 if has_bash_env && has_claude_env {
745 return;
746 }
747
748 eprintln!();
749 eprintln!(" \x1b[33m⚠ Docker detected — environment hints:\x1b[0m");
750
751 if !has_bash_env {
752 eprintln!(" For generic bash -c usage (non-interactive shells):");
753 eprintln!(" \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
754 }
755 if !has_claude_env {
756 eprintln!(" For Claude Code (sources before each command):");
757 eprintln!(" \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
758 }
759 eprintln!();
760}
761
762pub fn remove_lean_ctx_block(content: &str) -> String {
763 if content.contains("# lean-ctx shell hook — end") {
764 return remove_lean_ctx_block_by_marker(content);
765 }
766 remove_lean_ctx_block_legacy(content)
767}
768
769fn remove_lean_ctx_block_by_marker(content: &str) -> String {
770 let mut result = String::new();
771 let mut in_block = false;
772
773 for line in content.lines() {
774 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
775 in_block = true;
776 continue;
777 }
778 if in_block {
779 if line.trim() == "# lean-ctx shell hook — end" {
780 in_block = false;
781 }
782 continue;
783 }
784 result.push_str(line);
785 result.push('\n');
786 }
787 result
788}
789
790fn remove_lean_ctx_block_legacy(content: &str) -> String {
791 let mut result = String::new();
792 let mut in_block = false;
793
794 for line in content.lines() {
795 if line.contains("lean-ctx shell hook") {
796 in_block = true;
797 continue;
798 }
799 if in_block {
800 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
801 if line.trim() == "fi" || line.trim() == "end" {
802 in_block = false;
803 }
804 continue;
805 }
806 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
807 in_block = false;
808 result.push_str(line);
809 result.push('\n');
810 }
811 continue;
812 }
813 result.push_str(line);
814 result.push('\n');
815 }
816 result
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn test_remove_lean_ctx_block_posix() {
825 let input = r#"# existing config
826export PATH="$HOME/bin:$PATH"
827
828# lean-ctx shell hook — transparent CLI compression (95+ patterns)
829if [ -z "$LEAN_CTX_ACTIVE" ]; then
830alias git='lean-ctx -c git'
831alias npm='lean-ctx -c npm'
832fi
833
834# other stuff
835export EDITOR=vim
836"#;
837 let result = remove_lean_ctx_block(input);
838 assert!(!result.contains("lean-ctx"), "block should be removed");
839 assert!(result.contains("export PATH"), "other content preserved");
840 assert!(
841 result.contains("export EDITOR"),
842 "trailing content preserved"
843 );
844 }
845
846 #[test]
847 fn test_remove_lean_ctx_block_fish() {
848 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";
849 let result = remove_lean_ctx_block(input);
850 assert!(!result.contains("lean-ctx"), "block should be removed");
851 assert!(result.contains("set -x FOO"), "other content preserved");
852 assert!(result.contains("set -x BAZ"), "trailing content preserved");
853 }
854
855 #[test]
856 fn test_remove_lean_ctx_block_ps() {
857 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";
858 let result = remove_lean_ctx_block_ps(input);
859 assert!(
860 !result.contains("lean-ctx shell hook"),
861 "block should be removed"
862 );
863 assert!(result.contains("$env:FOO"), "other content preserved");
864 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
865 }
866
867 #[test]
868 fn test_remove_lean_ctx_block_ps_nested() {
869 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";
870 let result = remove_lean_ctx_block_ps(input);
871 assert!(
872 !result.contains("lean-ctx shell hook"),
873 "block should be removed"
874 );
875 assert!(!result.contains("_lc"), "function should be removed");
876 assert!(result.contains("$env:FOO"), "other content preserved");
877 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
878 }
879
880 #[test]
881 fn test_remove_block_no_lean_ctx() {
882 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
883 let result = remove_lean_ctx_block(input);
884 assert!(result.contains("export PATH"), "content unchanged");
885 }
886
887 #[test]
888 fn test_bash_hook_contains_pipe_guard_and_agent_bypass() {
889 let output = generate_hook_posix("/usr/local/bin/lean-ctx");
890 assert!(
891 output.contains("! -t 1"),
892 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
893 );
894 assert!(
895 output.contains("_lc_is_agent"),
896 "bash/zsh hook must have agent-aware bypass"
897 );
898 assert!(
899 output.contains("CODEX_CLI_SESSION"),
900 "agent check must include CODEX_CLI_SESSION"
901 );
902 }
903
904 #[test]
905 fn test_lc_uses_track_mode_by_default() {
906 let binary = "/usr/local/bin/lean-ctx";
907 let alias_list = crate::rewrite_registry::shell_alias_list();
908 let aliases = format!(
909 r#"_lc() {{
910 '{binary}' -t "$@"
911}}
912_lc_compress() {{
913 '{binary}' -c "$@"
914}}"#
915 );
916 assert!(
917 aliases.contains("-t \"$@\""),
918 "_lc must use -t (track mode) by default"
919 );
920 assert!(
921 aliases.contains("-c \"$@\""),
922 "_lc_compress must use -c (compress mode)"
923 );
924 let _ = alias_list;
925 }
926
927 #[test]
928 fn test_posix_shell_has_lean_ctx_mode() {
929 let alias_list = crate::rewrite_registry::shell_alias_list();
930 let aliases = r#"
931lean-ctx-mode() {{
932 case "${{1:-}}" in
933 compress) echo compress ;;
934 track) echo track ;;
935 off) echo off ;;
936 esac
937}}
938"#
939 .to_string();
940 assert!(
941 aliases.contains("lean-ctx-mode()"),
942 "lean-ctx-mode function must exist"
943 );
944 assert!(
945 aliases.contains("compress"),
946 "compress mode must be available"
947 );
948 assert!(aliases.contains("track"), "track mode must be available");
949 let _ = alias_list;
950 }
951
952 #[test]
953 fn test_fish_hook_contains_pipe_guard_and_agent_bypass() {
954 let output = generate_hook_fish("/usr/local/bin/lean-ctx");
955 assert!(
956 output.contains("isatty stdout"),
957 "fish hook must contain pipe guard (isatty stdout)"
958 );
959 assert!(
960 output.contains("_lc_is_agent"),
961 "fish hook must have agent-aware bypass"
962 );
963 }
964
965 #[test]
966 fn test_powershell_hook_contains_pipe_guard() {
967 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
968 assert!(
969 hook.contains("IsOutputRedirected"),
970 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
971 );
972 }
973
974 #[test]
975 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
976 let input = r#"# existing config
977export PATH="$HOME/bin:$PATH"
978
979# lean-ctx shell hook — transparent CLI compression (95+ patterns)
980_lean_ctx_cmds=(git npm pnpm)
981
982lean-ctx-on() {
983 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
984 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
985 done
986 export LEAN_CTX_ENABLED=1
987 [ -t 1 ] && echo "lean-ctx: ON"
988}
989
990lean-ctx-off() {
991 export LEAN_CTX_ENABLED=0
992 [ -t 1 ] && echo "lean-ctx: OFF"
993}
994
995if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
996 lean-ctx-on
997fi
998# lean-ctx shell hook — end
999
1000# other stuff
1001export EDITOR=vim
1002"#;
1003 let result = remove_lean_ctx_block(input);
1004 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1005 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1006 assert!(result.contains("export PATH"), "other content preserved");
1007 assert!(
1008 result.contains("export EDITOR"),
1009 "trailing content preserved"
1010 );
1011 }
1012
1013 #[test]
1014 fn env_sh_for_containers_includes_self_heal() {
1015 let _g = crate::core::data_dir::test_env_lock();
1016 let tmp = tempfile::tempdir().expect("tempdir");
1017 let data_dir = tmp.path().join("data");
1018 std::fs::create_dir_all(&data_dir).expect("mkdir data");
1019 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1020
1021 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1022 let env_sh = data_dir.join("env.sh");
1023 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1024 if !cfg!(windows) {
1025 if let Ok(mut bash) = std::process::Command::new("bash")
1026 .arg("-n")
1027 .arg(&env_sh)
1028 .spawn()
1029 {
1030 let ok = bash.wait().is_ok_and(|s| s.success());
1031 assert!(ok, "generated env.sh must be valid bash");
1032 }
1033 }
1034 assert!(
1035 content.contains(r#"_lc() { command "$@"; }"#),
1036 "env.sh must contain _lc passthrough stub for non-interactive shells"
1037 );
1038 assert!(
1039 content.contains(r#"_lc_compress() { command "$@"; }"#),
1040 "env.sh must contain _lc_compress passthrough stub"
1041 );
1042 assert!(content.contains("lean-ctx docker self-heal"));
1043 assert!(content.contains("claude mcp list"));
1044 assert!(content.contains("lean-ctx init --agent claude"));
1045 assert!(
1046 content.contains("_LEAN_CTX_HEAL"),
1047 "env.sh must guard against recursive self-heal"
1048 );
1049 assert!(
1050 content.contains("LEAN_CTX_ACTIVE"),
1051 "env.sh must check LEAN_CTX_ACTIVE to prevent re-entry"
1052 );
1053 assert!(
1054 content.contains("/.dockerenv"),
1055 "env.sh self-heal must be gated to container environments"
1056 );
1057
1058 std::env::remove_var("LEAN_CTX_DATA_DIR");
1059 }
1060
1061 #[cfg(unix)]
1062 #[test]
1063 fn bash_login_profile_sources_bashrc_idempotently() {
1064 let _g = crate::core::data_dir::test_env_lock();
1065 let tmp = tempfile::tempdir().expect("tempdir");
1066 let home = tmp.path();
1067 let prev = std::env::var_os("HOME");
1068 std::env::set_var("HOME", home);
1069
1070 std::fs::write(home.join(".bashrc"), "# bashrc\n").expect("write .bashrc");
1071 ensure_bash_login_sources_bashrc();
1074 let profile = home.join(".bash_profile");
1075 let first = std::fs::read_to_string(&profile).expect(".bash_profile created");
1076 assert!(
1077 first.contains(". \"$HOME/.bashrc\""),
1078 "login profile must source ~/.bashrc: {first}"
1079 );
1080 let markers = first.matches("load ~/.bashrc in login shells").count();
1081
1082 ensure_bash_login_sources_bashrc();
1084 let second = std::fs::read_to_string(&profile).expect("read profile");
1085 assert_eq!(
1086 second.matches("load ~/.bashrc in login shells").count(),
1087 markers,
1088 "snippet must not be duplicated on re-run"
1089 );
1090
1091 match prev {
1092 Some(v) => std::env::set_var("HOME", v),
1093 None => std::env::remove_var("HOME"),
1094 }
1095 }
1096
1097 #[test]
1098 fn test_source_line_posix() {
1099 let line = source_line_posix("zsh");
1100 assert!(line.contains("shell-hook.zsh"));
1101 assert!(line.contains("[ -f"));
1102 }
1103
1104 #[test]
1105 fn test_source_line_fish() {
1106 let line = source_line_fish();
1107 assert!(line.contains("shell-hook.fish"));
1108 assert!(line.contains("source"));
1109 }
1110
1111 #[test]
1112 fn test_source_line_powershell() {
1113 let line = source_line_powershell();
1114 assert!(line.contains("shell-hook.ps1"));
1115 assert!(line.contains("Test-Path"));
1116 }
1117}