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