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 }
145 }
146 return;
147 }
148
149 match std::fs::OpenOptions::new()
150 .append(true)
151 .create(true)
152 .open(rc_path)
153 {
154 Ok(mut f) => {
155 use std::io::Write;
156 let _ = f.write_all(source_line.as_bytes());
157 qprintln!("Added lean-ctx hook to {}", rc_path.display());
158 }
159 Err(e) => tracing::error!("Error writing {}: {e}", rc_path.display()),
160 }
161}
162
163pub fn generate_hook_powershell(binary: &str) -> String {
164 let config = crate::core::config::Config::load();
165 let activation = config.shell_activation_effective();
166 let baked_default = match activation {
167 crate::core::config::ShellActivation::Always => "always",
168 crate::core::config::ShellActivation::AgentsOnly => "agents-only",
169 crate::core::config::ShellActivation::Off => "off",
170 };
171 let binary_escaped = binary.replace('\\', "\\\\");
172 format!(
173 r#"# lean-ctx shell hook — transparent CLI compression (95+ patterns)
174$_leanCtxActivation = if ($env:LEAN_CTX_SHELL_ACTIVATION) {{ $env:LEAN_CTX_SHELL_ACTIVATION }} else {{ "{baked_default}" }}
175$_leanCtxShouldActivate = $false
176if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED -and -not $env:LEAN_CTX_NO_HOOK) {{
177 switch ($_leanCtxActivation) {{
178 {{ $_ -in 'off','none','manual' }} {{ $_leanCtxShouldActivate = $false }}
179 {{ $_ -in 'agents-only','agents_only','agentsonly' }} {{
180 $_leanCtxShouldActivate = $env:LEAN_CTX_AGENT -or $env:CLAUDECODE -or $env:CODEX_CLI_SESSION -or $env:GEMINI_SESSION
181 }}
182 default {{ $_leanCtxShouldActivate = $true }}
183 }}
184}}
185if ($_leanCtxShouldActivate) {{
186 $LeanCtxBin = "{binary_escaped}"
187 function _lc {{
188 if ($env:LEAN_CTX_DISABLED -or $env:LEAN_CTX_NO_HOOK -or [Console]::IsOutputRedirected) {{ & @args; return }}
189 & $LeanCtxBin -c @args
190 if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
191 & @args
192 }}
193 }}
194 function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
195 if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
196 function git {{ _lc git @args }}
197 function cargo {{ _lc cargo @args }}
198 function docker {{ _lc docker @args }}
199 function kubectl {{ _lc kubectl @args }}
200 function gh {{ _lc gh @args }}
201 function pip {{ _lc pip @args }}
202 function pip3 {{ _lc pip3 @args }}
203 function ruff {{ _lc ruff @args }}
204 function go {{ _lc go @args }}
205 function curl {{ _lc curl @args }}
206 function wget {{ _lc wget @args }}
207 foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
208 if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
209 $body = "_lc $c `@args"
210 New-Item -Path "function:$c" -Value ([scriptblock]::Create($body)) -Force | Out-Null
211 }}
212 }}
213 }}
214}}
215"#
216 )
217}
218
219pub fn init_powershell(binary: &str) {
220 let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
221 let profile_path = if let Some(dir) = profile_dir {
222 let _ = std::fs::create_dir_all(&dir);
223 dir.join("Microsoft.PowerShell_profile.ps1")
224 } else {
225 tracing::error!("Could not resolve PowerShell profile directory");
226 return;
227 };
228
229 let hook_content = generate_hook_powershell(binary);
230
231 if write_hook_file("shell-hook.ps1", &hook_content).is_some() {
232 upsert_source_line(&profile_path, &source_line_powershell());
233 qprintln!(" Binary: {binary}");
234 }
235}
236
237pub fn remove_lean_ctx_block_ps(content: &str) -> String {
238 let mut result = String::new();
239 let mut in_block = false;
240 let mut brace_depth = 0i32;
241
242 for line in content.lines() {
243 if line.contains("lean-ctx shell hook") {
244 in_block = true;
245 continue;
246 }
247 if in_block {
248 brace_depth += line.matches('{').count() as i32;
249 brace_depth -= line.matches('}').count() as i32;
250 if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
251 if line.trim() == "}" {
252 in_block = false;
253 brace_depth = 0;
254 }
255 continue;
256 }
257 continue;
258 }
259 result.push_str(line);
260 result.push('\n');
261 }
262 result
263}
264
265pub fn generate_hook_fish(binary: &str) -> String {
266 let config = crate::core::config::Config::load();
267 let activation = config.shell_activation_effective();
268 let baked_default = match activation {
269 crate::core::config::ShellActivation::Always => "always",
270 crate::core::config::ShellActivation::AgentsOnly => "agents-only",
271 crate::core::config::ShellActivation::Off => "off",
272 };
273 let alias_list = crate::rewrite_registry::shell_alias_list();
274 format!(
275 "# lean-ctx shell hook — smart shell mode (track-by-default)\n\
276 set -g _lean_ctx_cmds {alias_list}\n\
277 \n\
278 function _lc_is_agent\n\
279 \tset -q LEAN_CTX_AGENT; or set -q CODEX_CLI_SESSION; or set -q CLAUDECODE; or set -q GEMINI_SESSION\n\
280 end\n\
281 \n\
282 function _lc\n\
283 \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
284 \t\tcommand $argv\n\
285 \t\treturn\n\
286 \tend\n\
287 \tif not isatty stdout; and not _lc_is_agent\n\
288 \t\tcommand $argv\n\
289 \t\treturn\n\
290 \tend\n\
291 \t'{binary}' -t $argv\n\
292 \tset -l _lc_rc $status\n\
293 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
294 \t\tcommand $argv\n\
295 \telse\n\
296 \t\treturn $_lc_rc\n\
297 \tend\n\
298 end\n\
299 \n\
300 function _lc_compress\n\
301 \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
302 \t\tcommand $argv\n\
303 \t\treturn\n\
304 \tend\n\
305 \tif not isatty stdout; and not _lc_is_agent\n\
306 \t\tcommand $argv\n\
307 \t\treturn\n\
308 \tend\n\
309 \t'{binary}' -c $argv\n\
310 \tset -l _lc_rc $status\n\
311 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
312 \t\tcommand $argv\n\
313 \telse\n\
314 \t\treturn $_lc_rc\n\
315 \tend\n\
316 end\n\
317 \n\
318 function lean-ctx-on\n\
319 \tfor _lc_cmd in $_lean_ctx_cmds\n\
320 \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
321 \tend\n\
322 \talias k '_lc kubectl'\n\
323 \tset -gx LEAN_CTX_ENABLED 1\n\
324 \tisatty stdout; and echo 'lean-ctx: ON (track mode — output unchanged, token savings recorded)'\n\
325 end\n\
326 \n\
327 function lean-ctx-off\n\
328 \tfor _lc_cmd in $_lean_ctx_cmds\n\
329 \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
330 \tend\n\
331 \tfunctions --erase k 2>/dev/null; true\n\
332 \tset -e LEAN_CTX_ENABLED\n\
333 \tisatty stdout; and echo 'lean-ctx: OFF'\n\
334 end\n\
335 \n\
336 function lean-ctx-mode\n\
337 \tswitch $argv[1]\n\
338 \t\tcase compress\n\
339 \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
340 \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
341 \t\t\t\tend\n\
342 \t\t\talias k '_lc_compress kubectl'\n\
343 \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
344 \t\t\tisatty stdout; and echo 'lean-ctx: COMPRESS mode (all output compressed)'\n\
345 \t\tcase track\n\
346 \t\t\tlean-ctx-on\n\
347 \t\tcase off\n\
348 \t\t\tlean-ctx-off\n\
349 \t\tcase '*'\n\
350 \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
351 \t\t\techo ' track — Full output, stats recorded (default)'\n\
352 \t\t\techo ' compress — Compressed output for all commands'\n\
353 \t\t\techo ' off — No aliases, raw shell'\n\
354 \tend\n\
355 end\n\
356 \n\
357 function lean-ctx-raw\n\
358 \tset -lx LEAN_CTX_RAW 1\n\
359 \tcommand $argv\n\
360 end\n\
361 \n\
362 function lean-ctx-status\n\
363 \tif set -q LEAN_CTX_DISABLED\n\
364 \t\tisatty stdout; and echo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
365 \telse if set -q LEAN_CTX_ENABLED\n\
366 \t\tisatty stdout; and echo 'lean-ctx: ON'\n\
367 \telse\n\
368 \t\tisatty stdout; and echo 'lean-ctx: OFF'\n\
369 \tend\n\
370 end\n\
371 \n\
372 function _lean_ctx_should_activate\n\
373 \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\
374 \t\treturn 1\n\
375 \tend\n\
376 \tset -l _lc_mode (set -q LEAN_CTX_SHELL_ACTIVATION; and echo $LEAN_CTX_SHELL_ACTIVATION; or echo '{baked_default}')\n\
377 \tswitch $_lc_mode\n\
378 \t\tcase off none manual\n\
379 \t\t\treturn 1\n\
380 \t\tcase 'agents-only' agents_only agentsonly\n\
381 \t\t\tif set -q LEAN_CTX_AGENT; or set -q CLAUDECODE; or set -q CODEX_CLI_SESSION; or set -q GEMINI_SESSION\n\
382 \t\t\t\treturn 0\n\
383 \t\t\tend\n\
384 \t\t\treturn 1\n\
385 \t\tcase '*'\n\
386 \t\t\treturn 0\n\
387 \tend\n\
388 end\n\
389 \n\
390 if _lean_ctx_should_activate\n\
391 \tif command -q lean-ctx\n\
392 \t\tlean-ctx-on\n\
393 \tend\n\
394 end\n"
395 )
396}
397
398pub fn init_fish(binary: &str) {
399 let config = dirs::home_dir()
400 .map(|h| h.join(".config/fish/config.fish"))
401 .unwrap_or_default();
402
403 let hook_content = generate_hook_fish(binary);
404
405 if write_hook_file("shell-hook.fish", &hook_content).is_some() {
406 upsert_source_line(&config, &source_line_fish());
407 qprintln!(" Binary: {binary}");
408 }
409}
410
411pub fn generate_hook_posix(binary: &str) -> String {
412 let config = crate::core::config::Config::load();
413 let activation = config.shell_activation_effective();
414 let baked_default = match activation {
415 crate::core::config::ShellActivation::Always => "always",
416 crate::core::config::ShellActivation::AgentsOnly => "agents-only",
417 crate::core::config::ShellActivation::Off => "off",
418 };
419 let alias_list = crate::rewrite_registry::shell_alias_list();
420 format!(
421 r#"# lean-ctx shell hook — smart shell mode (track-by-default)
422_lean_ctx_cmds=({alias_list})
423
424_lc_is_agent() {{
425 [ -n "${{LEAN_CTX_AGENT:-}}" ] || [ -n "${{CODEX_CLI_SESSION:-}}" ] || [ -n "${{CLAUDECODE:-}}" ] || [ -n "${{GEMINI_SESSION:-}}" ]
426}}
427
428_lc() {{
429 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
430 command "$@"
431 return
432 fi
433 if [ ! -t 1 ] && ! _lc_is_agent; then
434 command "$@"
435 return
436 fi
437 '{binary}' -t "$@"
438 local _lc_rc=$?
439 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
440 command "$@"
441 else
442 return "$_lc_rc"
443 fi
444}}
445
446_lc_compress() {{
447 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
448 command "$@"
449 return
450 fi
451 if [ ! -t 1 ] && ! _lc_is_agent; then
452 command "$@"
453 return
454 fi
455 '{binary}' -c "$@"
456 local _lc_rc=$?
457 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
458 command "$@"
459 else
460 return "$_lc_rc"
461 fi
462}}
463
464lean-ctx-on() {{
465 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
466 # shellcheck disable=SC2139
467 alias "$_lc_cmd"='_lc '"$_lc_cmd"
468 done
469 alias k='_lc kubectl'
470 export LEAN_CTX_ENABLED=1
471 [ -t 1 ] && echo "lean-ctx: ON (track mode — output unchanged, token savings recorded)"
472}}
473
474lean-ctx-off() {{
475 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
476 unalias "$_lc_cmd" 2>/dev/null || true
477 done
478 unalias k 2>/dev/null || true
479 unset LEAN_CTX_ENABLED
480 [ -t 1 ] && echo "lean-ctx: OFF"
481}}
482
483lean-ctx-mode() {{
484 case "${{1:-}}" in
485 compress)
486 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
487 # shellcheck disable=SC2139
488 alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
489 done
490 alias k='_lc_compress kubectl'
491 export LEAN_CTX_ENABLED=1
492 [ -t 1 ] && echo "lean-ctx: COMPRESS mode (all output compressed)"
493 ;;
494 track)
495 lean-ctx-on
496 ;;
497 off)
498 lean-ctx-off
499 ;;
500 *)
501 echo "Usage: lean-ctx-mode <track|compress|off>"
502 echo " track — Full output, stats recorded (default)"
503 echo " compress — Compressed output for all commands"
504 echo " off — No aliases, raw shell"
505 ;;
506 esac
507}}
508
509lean-ctx-raw() {{
510 LEAN_CTX_RAW=1 command "$@"
511}}
512
513lean-ctx-status() {{
514 if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
515 [ -t 1 ] && echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
516 elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
517 [ -t 1 ] && echo "lean-ctx: ON"
518 else
519 [ -t 1 ] && echo "lean-ctx: OFF"
520 fi
521}}
522
523if [ -n "${{ZSH_VERSION:-}}" ]; then
524 _lean_ctx_comp() {{
525 shift words
526 (( CURRENT-- ))
527 _normal
528 }}
529 compdef _lean_ctx_comp _lc 2>/dev/null
530 compdef _lean_ctx_comp _lc_compress 2>/dev/null
531fi
532
533_lean_ctx_should_activate() {{
534 [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ] || return 1
535 case "${{LEAN_CTX_SHELL_ACTIVATION:-{baked_default}}}" in
536 off|none|manual) return 1 ;;
537 agents-only|agents_only|agentsonly)
538 [ -n "${{LEAN_CTX_AGENT:-}}" ] || [ -n "${{CLAUDECODE:-}}" ] || [ -n "${{CODEX_CLI_SESSION:-}}" ] || [ -n "${{GEMINI_SESSION:-}}" ] ;;
539 *) return 0 ;;
540 esac
541}}
542
543if _lean_ctx_should_activate; then
544 command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
545fi
546"#
547 )
548}
549
550pub fn init_posix(is_zsh: bool, binary: &str) {
551 let rc_file = if is_zsh {
552 dirs::home_dir()
553 .map(|h| h.join(".zshrc"))
554 .unwrap_or_default()
555 } else {
556 dirs::home_dir()
557 .map(|h| h.join(".bashrc"))
558 .unwrap_or_default()
559 };
560
561 let shell_ext = if is_zsh { "zsh" } else { "bash" };
562 let hook_content = generate_hook_posix(binary);
563
564 if let Some(hook_path) = write_hook_file(&format!("shell-hook.{shell_ext}"), &hook_content) {
565 upsert_source_line(&rc_file, &source_line_posix(shell_ext));
566 qprintln!(" Binary: {binary}");
567
568 write_env_sh_for_containers(&hook_content);
569 print_docker_env_hints(is_zsh);
570
571 let _ = hook_path;
572 }
573}
574
575pub fn write_env_sh_for_containers(aliases: &str) {
576 let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
577 Ok(d) => d.join("env.sh"),
578 Err(_) => return,
579 };
580 if let Some(parent) = env_sh.parent() {
581 let _ = std::fs::create_dir_all(parent);
582 }
583 let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
584 let mut content = String::from(
585 r#"# lean-ctx: passthrough stubs for non-interactive subshells (fixes #255).
586# These ensure _lc/_lc_compress exist so inherited aliases don't break.
587# The full hook definitions override these when the interactive shell loads.
588_lc() { command "$@"; }
589_lc_compress() { command "$@"; }
590
591"#,
592 );
593 content.push_str(&sanitized_aliases);
594 content.push_str(
595 r#"
596
597# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
598# Guards: container-only + no recursion + no re-entry via BASH_ENV + 60s cooldown + PID-lock
599if [ -f /.dockerenv ] || grep -qsE '/docker/|/lxc/' /proc/1/cgroup 2>/dev/null; then
600 if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ -z "${_LEAN_CTX_HEAL:-}" ]; then
601 _LEAN_CTX_HEAL_TS="${HOME}/.lean-ctx/.heal_ts"
602 _LEAN_CTX_HEAL_COOLDOWN=60
603 _lean_ctx_heal_needed=1
604 if [ -f "$_LEAN_CTX_HEAL_TS" ]; then
605 _last_heal=$(cat "$_LEAN_CTX_HEAL_TS" 2>/dev/null || echo 0)
606 _now=$(date +%s 2>/dev/null || echo 0)
607 if [ $(( _now - _last_heal )) -lt $_LEAN_CTX_HEAL_COOLDOWN ]; then
608 _lean_ctx_heal_needed=0
609 fi
610 fi
611 _lean_ctx_lock_count=0
612 for _lf in "${HOME}/.lean-ctx/locks"/slot-*.lock; do
613 [ -f "$_lf" ] && _lean_ctx_lock_count=$(( _lean_ctx_lock_count + 1 ))
614 done
615 if [ "$_lean_ctx_heal_needed" = "1" ] && [ "$_lean_ctx_lock_count" -lt 4 ]; then
616 export _LEAN_CTX_HEAL=1
617 if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
618 if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
619 LEAN_CTX_ACTIVE=1 LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
620 date +%s > "$_LEAN_CTX_HEAL_TS" 2>/dev/null
621 fi
622 fi
623 fi
624 fi
625fi
626"#,
627 );
628 match std::fs::write(&env_sh, content) {
629 Ok(()) => {
630 if !super::quiet_enabled() {
632 eprintln!(" env.sh: {}", env_sh.display());
633 }
634 }
635 Err(e) => tracing::warn!("could not write {}: {e}", env_sh.display()),
636 }
637}
638
639fn print_docker_env_hints(is_zsh: bool) {
640 if is_zsh || !crate::shell::is_container() {
641 return;
642 }
643 let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
644 |_| "/root/.lean-ctx/env.sh".to_string(),
645 |d| d.join("env.sh").to_string_lossy().to_string(),
646 );
647
648 let has_bash_env = std::env::var("BASH_ENV").is_ok();
649 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
650
651 if has_bash_env && has_claude_env {
652 return;
653 }
654
655 eprintln!();
656 eprintln!(" \x1b[33m⚠ Docker detected — environment hints:\x1b[0m");
657
658 if !has_bash_env {
659 eprintln!(" For generic bash -c usage (non-interactive shells):");
660 eprintln!(" \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
661 }
662 if !has_claude_env {
663 eprintln!(" For Claude Code (sources before each command):");
664 eprintln!(" \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
665 }
666 eprintln!();
667}
668
669pub fn remove_lean_ctx_block(content: &str) -> String {
670 if content.contains("# lean-ctx shell hook — end") {
671 return remove_lean_ctx_block_by_marker(content);
672 }
673 remove_lean_ctx_block_legacy(content)
674}
675
676fn remove_lean_ctx_block_by_marker(content: &str) -> String {
677 let mut result = String::new();
678 let mut in_block = false;
679
680 for line in content.lines() {
681 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
682 in_block = true;
683 continue;
684 }
685 if in_block {
686 if line.trim() == "# lean-ctx shell hook — end" {
687 in_block = false;
688 }
689 continue;
690 }
691 result.push_str(line);
692 result.push('\n');
693 }
694 result
695}
696
697fn remove_lean_ctx_block_legacy(content: &str) -> String {
698 let mut result = String::new();
699 let mut in_block = false;
700
701 for line in content.lines() {
702 if line.contains("lean-ctx shell hook") {
703 in_block = true;
704 continue;
705 }
706 if in_block {
707 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
708 if line.trim() == "fi" || line.trim() == "end" {
709 in_block = false;
710 }
711 continue;
712 }
713 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
714 in_block = false;
715 result.push_str(line);
716 result.push('\n');
717 }
718 continue;
719 }
720 result.push_str(line);
721 result.push('\n');
722 }
723 result
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
731 fn test_remove_lean_ctx_block_posix() {
732 let input = r#"# existing config
733export PATH="$HOME/bin:$PATH"
734
735# lean-ctx shell hook — transparent CLI compression (95+ patterns)
736if [ -z "$LEAN_CTX_ACTIVE" ]; then
737alias git='lean-ctx -c git'
738alias npm='lean-ctx -c npm'
739fi
740
741# other stuff
742export EDITOR=vim
743"#;
744 let result = remove_lean_ctx_block(input);
745 assert!(!result.contains("lean-ctx"), "block should be removed");
746 assert!(result.contains("export PATH"), "other content preserved");
747 assert!(
748 result.contains("export EDITOR"),
749 "trailing content preserved"
750 );
751 }
752
753 #[test]
754 fn test_remove_lean_ctx_block_fish() {
755 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";
756 let result = remove_lean_ctx_block(input);
757 assert!(!result.contains("lean-ctx"), "block should be removed");
758 assert!(result.contains("set -x FOO"), "other content preserved");
759 assert!(result.contains("set -x BAZ"), "trailing content preserved");
760 }
761
762 #[test]
763 fn test_remove_lean_ctx_block_ps() {
764 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";
765 let result = remove_lean_ctx_block_ps(input);
766 assert!(
767 !result.contains("lean-ctx shell hook"),
768 "block should be removed"
769 );
770 assert!(result.contains("$env:FOO"), "other content preserved");
771 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
772 }
773
774 #[test]
775 fn test_remove_lean_ctx_block_ps_nested() {
776 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";
777 let result = remove_lean_ctx_block_ps(input);
778 assert!(
779 !result.contains("lean-ctx shell hook"),
780 "block should be removed"
781 );
782 assert!(!result.contains("_lc"), "function should be removed");
783 assert!(result.contains("$env:FOO"), "other content preserved");
784 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
785 }
786
787 #[test]
788 fn test_remove_block_no_lean_ctx() {
789 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
790 let result = remove_lean_ctx_block(input);
791 assert!(result.contains("export PATH"), "content unchanged");
792 }
793
794 #[test]
795 fn test_bash_hook_contains_pipe_guard_and_agent_bypass() {
796 let output = generate_hook_posix("/usr/local/bin/lean-ctx");
797 assert!(
798 output.contains("! -t 1"),
799 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
800 );
801 assert!(
802 output.contains("_lc_is_agent"),
803 "bash/zsh hook must have agent-aware bypass"
804 );
805 assert!(
806 output.contains("CODEX_CLI_SESSION"),
807 "agent check must include CODEX_CLI_SESSION"
808 );
809 }
810
811 #[test]
812 fn test_lc_uses_track_mode_by_default() {
813 let binary = "/usr/local/bin/lean-ctx";
814 let alias_list = crate::rewrite_registry::shell_alias_list();
815 let aliases = format!(
816 r#"_lc() {{
817 '{binary}' -t "$@"
818}}
819_lc_compress() {{
820 '{binary}' -c "$@"
821}}"#
822 );
823 assert!(
824 aliases.contains("-t \"$@\""),
825 "_lc must use -t (track mode) by default"
826 );
827 assert!(
828 aliases.contains("-c \"$@\""),
829 "_lc_compress must use -c (compress mode)"
830 );
831 let _ = alias_list;
832 }
833
834 #[test]
835 fn test_posix_shell_has_lean_ctx_mode() {
836 let alias_list = crate::rewrite_registry::shell_alias_list();
837 let aliases = r#"
838lean-ctx-mode() {{
839 case "${{1:-}}" in
840 compress) echo compress ;;
841 track) echo track ;;
842 off) echo off ;;
843 esac
844}}
845"#
846 .to_string();
847 assert!(
848 aliases.contains("lean-ctx-mode()"),
849 "lean-ctx-mode function must exist"
850 );
851 assert!(
852 aliases.contains("compress"),
853 "compress mode must be available"
854 );
855 assert!(aliases.contains("track"), "track mode must be available");
856 let _ = alias_list;
857 }
858
859 #[test]
860 fn test_fish_hook_contains_pipe_guard_and_agent_bypass() {
861 let output = generate_hook_fish("/usr/local/bin/lean-ctx");
862 assert!(
863 output.contains("isatty stdout"),
864 "fish hook must contain pipe guard (isatty stdout)"
865 );
866 assert!(
867 output.contains("_lc_is_agent"),
868 "fish hook must have agent-aware bypass"
869 );
870 }
871
872 #[test]
873 fn test_powershell_hook_contains_pipe_guard() {
874 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
875 assert!(
876 hook.contains("IsOutputRedirected"),
877 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
878 );
879 }
880
881 #[test]
882 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
883 let input = r#"# existing config
884export PATH="$HOME/bin:$PATH"
885
886# lean-ctx shell hook — transparent CLI compression (95+ patterns)
887_lean_ctx_cmds=(git npm pnpm)
888
889lean-ctx-on() {
890 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
891 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
892 done
893 export LEAN_CTX_ENABLED=1
894 [ -t 1 ] && echo "lean-ctx: ON"
895}
896
897lean-ctx-off() {
898 unset LEAN_CTX_ENABLED
899 [ -t 1 ] && echo "lean-ctx: OFF"
900}
901
902if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
903 lean-ctx-on
904fi
905# lean-ctx shell hook — end
906
907# other stuff
908export EDITOR=vim
909"#;
910 let result = remove_lean_ctx_block(input);
911 assert!(!result.contains("lean-ctx-on"), "block should be removed");
912 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
913 assert!(result.contains("export PATH"), "other content preserved");
914 assert!(
915 result.contains("export EDITOR"),
916 "trailing content preserved"
917 );
918 }
919
920 #[test]
921 fn env_sh_for_containers_includes_self_heal() {
922 let _g = crate::core::data_dir::test_env_lock();
923 let tmp = tempfile::tempdir().expect("tempdir");
924 let data_dir = tmp.path().join("data");
925 std::fs::create_dir_all(&data_dir).expect("mkdir data");
926 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
927
928 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
929 let env_sh = data_dir.join("env.sh");
930 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
931 if !cfg!(windows) {
932 if let Ok(mut bash) = std::process::Command::new("bash")
933 .arg("-n")
934 .arg(&env_sh)
935 .spawn()
936 {
937 let ok = bash.wait().is_ok_and(|s| s.success());
938 assert!(ok, "generated env.sh must be valid bash");
939 }
940 }
941 assert!(
942 content.contains(r#"_lc() { command "$@"; }"#),
943 "env.sh must contain _lc passthrough stub for non-interactive shells"
944 );
945 assert!(
946 content.contains(r#"_lc_compress() { command "$@"; }"#),
947 "env.sh must contain _lc_compress passthrough stub"
948 );
949 assert!(content.contains("lean-ctx docker self-heal"));
950 assert!(content.contains("claude mcp list"));
951 assert!(content.contains("lean-ctx init --agent claude"));
952 assert!(
953 content.contains("_LEAN_CTX_HEAL"),
954 "env.sh must guard against recursive self-heal"
955 );
956 assert!(
957 content.contains("LEAN_CTX_ACTIVE"),
958 "env.sh must check LEAN_CTX_ACTIVE to prevent re-entry"
959 );
960 assert!(
961 content.contains("/.dockerenv"),
962 "env.sh self-heal must be gated to container environments"
963 );
964
965 std::env::remove_var("LEAN_CTX_DATA_DIR");
966 }
967
968 #[test]
969 fn test_source_line_posix() {
970 let line = source_line_posix("zsh");
971 assert!(line.contains("shell-hook.zsh"));
972 assert!(line.contains("[ -f"));
973 }
974
975 #[test]
976 fn test_source_line_fish() {
977 let line = source_line_fish();
978 assert!(line.contains("shell-hook.fish"));
979 assert!(line.contains("source"));
980 }
981
982 #[test]
983 fn test_source_line_powershell() {
984 let line = source_line_powershell();
985 assert!(line.contains("shell-hook.ps1"));
986 assert!(line.contains("Test-Path"));
987 }
988}