1pub mod cloud;
2pub mod dispatch;
3mod shell_init;
4
5pub use dispatch::run;
6pub use shell_init::*;
7
8use std::path::Path;
9
10use crate::core::compressor;
11use crate::core::config;
12use crate::core::deps as dep_extract;
13use crate::core::entropy;
14use crate::core::patterns::deps_cmd;
15use crate::core::protocol;
16use crate::core::signatures;
17use crate::core::stats;
18use crate::core::theme;
19use crate::core::tokens::count_tokens;
20use crate::hooks::to_bash_compatible_path;
21
22pub fn cmd_read(args: &[String]) {
23 if args.is_empty() {
24 eprintln!(
25 "Usage: lean-ctx read <file> [--mode full|map|signatures|aggressive|entropy] [--fresh]"
26 );
27 std::process::exit(1);
28 }
29
30 let path = &args[0];
31 let mode = args
32 .iter()
33 .position(|a| a == "--mode" || a == "-m")
34 .and_then(|i| args.get(i + 1))
35 .map(|s| s.as_str())
36 .unwrap_or("full");
37 let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
38
39 let short = protocol::shorten_path(path);
40
41 if !force_fresh && mode == "full" {
42 use crate::core::cli_cache::{self, CacheResult};
43 match cli_cache::check_and_read(path) {
44 CacheResult::Hit { entry, file_ref } => {
45 let msg = cli_cache::format_hit(&entry, &file_ref, &short);
46 println!("{msg}");
47 stats::record("cli_read", entry.original_tokens, count_tokens(&msg));
48 return;
49 }
50 CacheResult::Miss { content } if content.is_empty() => {
51 eprintln!("Error: could not read {path}");
52 std::process::exit(1);
53 }
54 CacheResult::Miss { content } => {
55 let line_count = content.lines().count();
56 println!("{short} [{line_count}L]");
57 println!("{content}");
58 stats::record("cli_read", count_tokens(&content), count_tokens(&content));
59 return;
60 }
61 }
62 }
63
64 let content = match crate::tools::ctx_read::read_file_lossy(path) {
65 Ok(c) => c,
66 Err(e) => {
67 eprintln!("Error: {e}");
68 std::process::exit(1);
69 }
70 };
71
72 let ext = Path::new(path)
73 .extension()
74 .and_then(|e| e.to_str())
75 .unwrap_or("");
76 let line_count = content.lines().count();
77 let original_tokens = count_tokens(&content);
78
79 let mode = if mode == "auto" {
80 let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
81 let predictor = crate::core::mode_predictor::ModePredictor::new();
82 predictor
83 .predict_best_mode(&sig)
84 .unwrap_or_else(|| "full".to_string())
85 } else {
86 mode.to_string()
87 };
88 let mode = mode.as_str();
89
90 match mode {
91 "map" => {
92 let sigs = signatures::extract_signatures(&content, ext);
93 let dep_info = dep_extract::extract_deps(&content, ext);
94
95 println!("{short} [{line_count}L]");
96 if !dep_info.imports.is_empty() {
97 println!(" deps: {}", dep_info.imports.join(", "));
98 }
99 if !dep_info.exports.is_empty() {
100 println!(" exports: {}", dep_info.exports.join(", "));
101 }
102 let key_sigs: Vec<_> = sigs
103 .iter()
104 .filter(|s| s.is_exported || s.indent == 0)
105 .collect();
106 if !key_sigs.is_empty() {
107 println!(" API:");
108 for sig in &key_sigs {
109 println!(" {}", sig.to_compact());
110 }
111 }
112 let sent = count_tokens(&short.to_string());
113 print_savings(original_tokens, sent);
114 }
115 "signatures" => {
116 let sigs = signatures::extract_signatures(&content, ext);
117 println!("{short} [{line_count}L]");
118 for sig in &sigs {
119 println!("{}", sig.to_compact());
120 }
121 let sent = count_tokens(&short.to_string());
122 print_savings(original_tokens, sent);
123 }
124 "aggressive" => {
125 let compressed = compressor::aggressive_compress(&content, Some(ext));
126 println!("{short} [{line_count}L]");
127 println!("{compressed}");
128 let sent = count_tokens(&compressed);
129 print_savings(original_tokens, sent);
130 }
131 "entropy" => {
132 let result = entropy::entropy_compress(&content);
133 let avg_h = entropy::analyze_entropy(&content).avg_entropy;
134 println!("{short} [{line_count}L] (H̄={avg_h:.1})");
135 for tech in &result.techniques {
136 println!("{tech}");
137 }
138 println!("{}", result.output);
139 let sent = count_tokens(&result.output);
140 print_savings(original_tokens, sent);
141 }
142 _ => {
143 println!("{short} [{line_count}L]");
144 println!("{content}");
145 }
146 }
147}
148
149pub fn cmd_diff(args: &[String]) {
150 if args.len() < 2 {
151 eprintln!("Usage: lean-ctx diff <file1> <file2>");
152 std::process::exit(1);
153 }
154
155 let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
156 Ok(c) => c,
157 Err(e) => {
158 eprintln!("Error reading {}: {e}", args[0]);
159 std::process::exit(1);
160 }
161 };
162
163 let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
164 Ok(c) => c,
165 Err(e) => {
166 eprintln!("Error reading {}: {e}", args[1]);
167 std::process::exit(1);
168 }
169 };
170
171 let diff = compressor::diff_content(&content1, &content2);
172 let original = count_tokens(&content1) + count_tokens(&content2);
173 let sent = count_tokens(&diff);
174
175 println!(
176 "diff {} {}",
177 protocol::shorten_path(&args[0]),
178 protocol::shorten_path(&args[1])
179 );
180 println!("{diff}");
181 print_savings(original, sent);
182}
183
184pub fn cmd_grep(args: &[String]) {
185 if args.is_empty() {
186 eprintln!("Usage: lean-ctx grep <pattern> [path]");
187 std::process::exit(1);
188 }
189
190 let pattern = &args[0];
191 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
192
193 let re = match regex::Regex::new(pattern) {
194 Ok(r) => r,
195 Err(e) => {
196 eprintln!("Invalid regex pattern: {e}");
197 std::process::exit(1);
198 }
199 };
200
201 let mut found = false;
202 for entry in ignore::WalkBuilder::new(path)
203 .hidden(true)
204 .git_ignore(true)
205 .git_global(true)
206 .git_exclude(true)
207 .max_depth(Some(10))
208 .build()
209 .flatten()
210 {
211 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
212 continue;
213 }
214 let file_path = entry.path();
215 if let Ok(content) = std::fs::read_to_string(file_path) {
216 for (i, line) in content.lines().enumerate() {
217 if re.is_match(line) {
218 println!("{}:{}:{}", file_path.display(), i + 1, line);
219 found = true;
220 }
221 }
222 }
223 }
224
225 if !found {
226 std::process::exit(1);
227 }
228}
229
230pub fn cmd_find(args: &[String]) {
231 if args.is_empty() {
232 eprintln!("Usage: lean-ctx find <pattern> [path]");
233 std::process::exit(1);
234 }
235
236 let raw_pattern = &args[0];
237 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
238
239 let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
240 let glob_matcher = if is_glob {
241 glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
242 } else {
243 None
244 };
245 let substring = raw_pattern.to_lowercase();
246
247 let mut found = false;
248 for entry in ignore::WalkBuilder::new(path)
249 .hidden(true)
250 .git_ignore(true)
251 .git_global(true)
252 .git_exclude(true)
253 .max_depth(Some(10))
254 .build()
255 .flatten()
256 {
257 let name = entry.file_name().to_string_lossy().to_lowercase();
258 let matches = if let Some(ref g) = glob_matcher {
259 g.matches(&name)
260 } else {
261 name.contains(&substring)
262 };
263 if matches {
264 println!("{}", entry.path().display());
265 found = true;
266 }
267 }
268
269 if !found {
270 std::process::exit(1);
271 }
272}
273
274pub fn cmd_ls(args: &[String]) {
275 let path = args.first().map(|s| s.as_str()).unwrap_or(".");
276 let command = if cfg!(windows) {
277 format!("dir {}", path.replace('/', "\\"))
278 } else {
279 format!("ls -la {path}")
280 };
281 let code = crate::shell::exec(&command);
282 std::process::exit(code);
283}
284
285pub fn cmd_deps(args: &[String]) {
286 let path = args.first().map(|s| s.as_str()).unwrap_or(".");
287
288 match deps_cmd::detect_and_compress(path) {
289 Some(result) => println!("{result}"),
290 None => {
291 eprintln!("No dependency file found in {path}");
292 std::process::exit(1);
293 }
294 }
295}
296
297pub fn cmd_discover(_args: &[String]) {
298 let history = load_shell_history();
299 if history.is_empty() {
300 println!("No shell history found.");
301 return;
302 }
303
304 let result = crate::tools::ctx_discover::analyze_history(&history, 20);
305 println!("{}", crate::tools::ctx_discover::format_cli_output(&result));
306}
307
308pub fn cmd_session() {
309 let history = load_shell_history();
310 let gain = stats::load_stats();
311
312 let compressible_commands = [
313 "git ",
314 "npm ",
315 "yarn ",
316 "pnpm ",
317 "cargo ",
318 "docker ",
319 "kubectl ",
320 "gh ",
321 "pip ",
322 "pip3 ",
323 "eslint",
324 "prettier",
325 "ruff ",
326 "go ",
327 "golangci-lint",
328 "curl ",
329 "wget ",
330 "grep ",
331 "rg ",
332 "find ",
333 "ls ",
334 ];
335
336 let mut total = 0u32;
337 let mut via_hook = 0u32;
338
339 for line in &history {
340 let cmd = line.trim().to_lowercase();
341 if cmd.starts_with("lean-ctx") {
342 via_hook += 1;
343 total += 1;
344 } else {
345 for p in &compressible_commands {
346 if cmd.starts_with(p) {
347 total += 1;
348 break;
349 }
350 }
351 }
352 }
353
354 let pct = if total > 0 {
355 (via_hook as f64 / total as f64 * 100.0).round() as u32
356 } else {
357 0
358 };
359
360 println!("lean-ctx session statistics\n");
361 println!(
362 "Adoption: {}% ({}/{} compressible commands)",
363 pct, via_hook, total
364 );
365 println!("Saved: {} tokens total", gain.total_saved);
366 println!("Calls: {} compressed", gain.total_calls);
367
368 if total > via_hook {
369 let missed = total - via_hook;
370 let est = missed * 150;
371 println!(
372 "Missed: {} commands (~{} tokens saveable)",
373 missed, est
374 );
375 }
376
377 println!("\nRun 'lean-ctx discover' for details on missed commands.");
378}
379
380pub fn cmd_wrapped(args: &[String]) {
381 let period = if args.iter().any(|a| a == "--month") {
382 "month"
383 } else if args.iter().any(|a| a == "--all") {
384 "all"
385 } else {
386 "week"
387 };
388
389 let report = crate::core::wrapped::WrappedReport::generate(period);
390 println!("{}", report.format_ascii());
391}
392
393pub fn cmd_sessions(args: &[String]) {
394 use crate::core::session::SessionState;
395
396 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
397
398 match action {
399 "list" | "ls" => {
400 let sessions = SessionState::list_sessions();
401 if sessions.is_empty() {
402 println!("No sessions found.");
403 return;
404 }
405 println!("Sessions ({}):\n", sessions.len());
406 for s in sessions.iter().take(20) {
407 let task = s.task.as_deref().unwrap_or("(no task)");
408 let task_short: String = task.chars().take(50).collect();
409 let date = s.updated_at.format("%Y-%m-%d %H:%M");
410 println!(
411 " {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
412 s.id,
413 s.version,
414 s.tool_calls,
415 format_tokens_cli(s.tokens_saved),
416 date,
417 task_short
418 );
419 }
420 if sessions.len() > 20 {
421 println!(" ... +{} more", sessions.len() - 20);
422 }
423 }
424 "show" => {
425 let id = args.get(1);
426 let session = if let Some(id) = id {
427 SessionState::load_by_id(id)
428 } else {
429 SessionState::load_latest()
430 };
431 match session {
432 Some(s) => println!("{}", s.format_compact()),
433 None => println!("Session not found."),
434 }
435 }
436 "cleanup" => {
437 let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
438 let removed = SessionState::cleanup_old_sessions(days);
439 println!("Cleaned up {removed} session(s) older than {days} days.");
440 }
441 _ => {
442 eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
443 std::process::exit(1);
444 }
445 }
446}
447
448pub fn cmd_benchmark(args: &[String]) {
449 use crate::core::benchmark;
450
451 let action = args.first().map(|s| s.as_str()).unwrap_or("run");
452
453 match action {
454 "run" => {
455 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
456 let is_json = args.iter().any(|a| a == "--json");
457
458 let result = benchmark::run_project_benchmark(path);
459 if is_json {
460 println!("{}", benchmark::format_json(&result));
461 } else {
462 println!("{}", benchmark::format_terminal(&result));
463 }
464 }
465 "report" => {
466 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
467 let result = benchmark::run_project_benchmark(path);
468 println!("{}", benchmark::format_markdown(&result));
469 }
470 _ => {
471 if std::path::Path::new(action).exists() {
472 let result = benchmark::run_project_benchmark(action);
473 println!("{}", benchmark::format_terminal(&result));
474 } else {
475 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
476 eprintln!(" lean-ctx benchmark report [path]");
477 std::process::exit(1);
478 }
479 }
480 }
481}
482
483fn format_tokens_cli(tokens: u64) -> String {
484 if tokens >= 1_000_000 {
485 format!("{:.1}M", tokens as f64 / 1_000_000.0)
486 } else if tokens >= 1_000 {
487 format!("{:.1}K", tokens as f64 / 1_000.0)
488 } else {
489 format!("{tokens}")
490 }
491}
492
493pub fn cmd_stats(args: &[String]) {
494 match args.first().map(|s| s.as_str()) {
495 Some("reset-cep") => {
496 crate::core::stats::reset_cep();
497 println!("CEP stats reset. Shell hook data preserved.");
498 }
499 Some("json") => {
500 let store = crate::core::stats::load();
501 println!(
502 "{}",
503 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
504 );
505 }
506 _ => {
507 let store = crate::core::stats::load();
508 let input_saved = store
509 .total_input_tokens
510 .saturating_sub(store.total_output_tokens);
511 let pct = if store.total_input_tokens > 0 {
512 input_saved as f64 / store.total_input_tokens as f64 * 100.0
513 } else {
514 0.0
515 };
516 println!("Commands: {}", store.total_commands);
517 println!("Input: {} tokens", store.total_input_tokens);
518 println!("Output: {} tokens", store.total_output_tokens);
519 println!("Saved: {} tokens ({:.1}%)", input_saved, pct);
520 println!();
521 println!("CEP sessions: {}", store.cep.sessions);
522 println!(
523 "CEP tokens: {} → {}",
524 store.cep.total_tokens_original, store.cep.total_tokens_compressed
525 );
526 println!();
527 println!("Subcommands: stats reset-cep | stats json");
528 }
529 }
530}
531
532pub fn cmd_cache(args: &[String]) {
533 use crate::core::cli_cache;
534 match args.first().map(|s| s.as_str()) {
535 Some("clear") => {
536 let count = cli_cache::clear();
537 println!("Cleared {count} cached entries.");
538 }
539 Some("reset") => {
540 let project_flag = args.get(1).map(|s| s.as_str()) == Some("--project");
541 if project_flag {
542 let root =
543 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
544 match root {
545 Some(root) => {
546 let count = cli_cache::clear_project(&root);
547 println!("Reset {count} cache entries for project: {root}");
548 }
549 None => {
550 eprintln!("No active project root found. Start a session first.");
551 std::process::exit(1);
552 }
553 }
554 } else {
555 let count = cli_cache::clear();
556 println!("Reset all {count} cache entries.");
557 }
558 }
559 Some("stats") => {
560 let (hits, reads, entries) = cli_cache::stats();
561 let rate = if reads > 0 {
562 (hits as f64 / reads as f64 * 100.0).round() as u32
563 } else {
564 0
565 };
566 println!("CLI Cache Stats:");
567 println!(" Entries: {entries}");
568 println!(" Reads: {reads}");
569 println!(" Hits: {hits}");
570 println!(" Hit Rate: {rate}%");
571 }
572 Some("invalidate") => {
573 if args.len() < 2 {
574 eprintln!("Usage: lean-ctx cache invalidate <path>");
575 std::process::exit(1);
576 }
577 cli_cache::invalidate(&args[1]);
578 println!("Invalidated cache for {}", args[1]);
579 }
580 _ => {
581 let (hits, reads, entries) = cli_cache::stats();
582 let rate = if reads > 0 {
583 (hits as f64 / reads as f64 * 100.0).round() as u32
584 } else {
585 0
586 };
587 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
588 println!();
589 println!("Subcommands:");
590 println!(" cache stats Show detailed stats");
591 println!(" cache clear Clear all cached entries");
592 println!(" cache reset Reset all cache (or --project for current project only)");
593 println!(" cache invalidate Remove specific file from cache");
594 }
595 }
596}
597
598pub fn cmd_config(args: &[String]) {
599 let cfg = config::Config::load();
600
601 if args.is_empty() {
602 println!("{}", cfg.show());
603 return;
604 }
605
606 match args[0].as_str() {
607 "init" | "create" => {
608 let default = config::Config::default();
609 match default.save() {
610 Ok(()) => {
611 let path = config::Config::path()
612 .map(|p| p.to_string_lossy().to_string())
613 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
614 println!("Created default config at {path}");
615 }
616 Err(e) => eprintln!("Error: {e}"),
617 }
618 }
619 "set" => {
620 if args.len() < 3 {
621 eprintln!("Usage: lean-ctx config set <key> <value>");
622 std::process::exit(1);
623 }
624 let mut cfg = cfg;
625 let key = &args[1];
626 let val = &args[2];
627 match key.as_str() {
628 "ultra_compact" => cfg.ultra_compact = val == "true",
629 "tee_on_error" | "tee_mode" => {
630 cfg.tee_mode = match val.as_str() {
631 "true" | "failures" => config::TeeMode::Failures,
632 "always" => config::TeeMode::Always,
633 "false" | "never" => config::TeeMode::Never,
634 _ => {
635 eprintln!("Valid tee_mode values: always, failures, never");
636 std::process::exit(1);
637 }
638 };
639 }
640 "checkpoint_interval" => {
641 cfg.checkpoint_interval = val.parse().unwrap_or(15);
642 }
643 "theme" => {
644 if theme::from_preset(val).is_some() || val == "custom" {
645 cfg.theme = val.to_string();
646 } else {
647 eprintln!(
648 "Unknown theme '{val}'. Available: {}",
649 theme::PRESET_NAMES.join(", ")
650 );
651 std::process::exit(1);
652 }
653 }
654 "slow_command_threshold_ms" => {
655 cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
656 }
657 "passthrough_urls" => {
658 cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
659 }
660 "rules_scope" => match val.as_str() {
661 "global" | "project" | "both" => {
662 cfg.rules_scope = Some(val.to_string());
663 }
664 _ => {
665 eprintln!("Valid rules_scope values: global, project, both");
666 std::process::exit(1);
667 }
668 },
669 _ => {
670 eprintln!("Unknown config key: {key}");
671 std::process::exit(1);
672 }
673 }
674 match cfg.save() {
675 Ok(()) => println!("Updated {key} = {val}"),
676 Err(e) => eprintln!("Error saving config: {e}"),
677 }
678 }
679 _ => {
680 eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
681 std::process::exit(1);
682 }
683 }
684}
685
686pub fn cmd_cheatsheet() {
687 let ver = env!("CARGO_PKG_VERSION");
688 let ver_pad = format!("v{ver}");
689 let header = format!(
690 "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
691\x1b[1;36m║\x1b[0m \x1b[1;37mlean-ctx Workflow Cheat Sheet\x1b[0m \x1b[2m{ver_pad:>6}\x1b[0m \x1b[1;36m║\x1b[0m
692\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m");
693 println!(
694 "{header}
695
696\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
697 ctx_session load \x1b[2m# restore previous session\x1b[0m
698 ctx_overview task=\"...\" \x1b[2m# task-aware file map\x1b[0m
699 ctx_graph action=build \x1b[2m# index project (first time)\x1b[0m
700 ctx_knowledge action=recall \x1b[2m# check stored project facts\x1b[0m
701
702\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
703 ctx_read mode=full \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
704 ctx_read mode=map \x1b[2m# context-only files (~93% saved)\x1b[0m
705 ctx_read mode=diff \x1b[2m# after editing (~98% saved)\x1b[0m
706 ctx_read mode=sigs \x1b[2m# API surface of large files (~95%)\x1b[0m
707 ctx_multi_read \x1b[2m# read multiple files at once\x1b[0m
708 ctx_search \x1b[2m# search with compressed results (~70%)\x1b[0m
709 ctx_shell \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
710
711\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
712 ctx_session finding \"...\" \x1b[2m# record what you discovered\x1b[0m
713 ctx_session decision \"...\" \x1b[2m# record architectural choices\x1b[0m
714 ctx_knowledge action=remember \x1b[2m# store permanent project facts\x1b[0m
715 ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
716 ctx_metrics \x1b[2m# see session statistics\x1b[0m
717
718\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
719 ctx_agent action=register \x1b[2m# announce yourself\x1b[0m
720 ctx_agent action=list \x1b[2m# see other active agents\x1b[0m
721 ctx_agent action=post \x1b[2m# share findings\x1b[0m
722 ctx_agent action=read \x1b[2m# check messages\x1b[0m
723
724\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
725 Will edit? → \x1b[1mfull\x1b[0m (re-reads: 13 tokens) → after edit: \x1b[1mdiff\x1b[0m
726 API only? → \x1b[1msignatures\x1b[0m
727 Deps/exports? → \x1b[1mmap\x1b[0m
728 Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
729 Browsing? → \x1b[1maggressive\x1b[0m (syntax stripped)
730
731\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
732 lean-ctx gain \x1b[2m# visual savings dashboard\x1b[0m
733 lean-ctx gain --live \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
734 lean-ctx dashboard \x1b[2m# web dashboard with charts\x1b[0m
735 lean-ctx wrapped \x1b[2m# weekly savings report\x1b[0m
736 lean-ctx discover \x1b[2m# find uncompressed commands\x1b[0m
737 lean-ctx doctor \x1b[2m# diagnose installation\x1b[0m
738 lean-ctx update \x1b[2m# self-update to latest\x1b[0m
739
740\x1b[2m Full guide: https://leanctx.com/docs/workflow\x1b[0m"
741 );
742}
743
744pub fn cmd_slow_log(args: &[String]) {
745 use crate::core::slow_log;
746
747 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
748 match action {
749 "list" | "ls" | "" => println!("{}", slow_log::list()),
750 "clear" | "purge" => println!("{}", slow_log::clear()),
751 _ => {
752 eprintln!("Usage: lean-ctx slow-log [list|clear]");
753 std::process::exit(1);
754 }
755 }
756}
757
758pub fn cmd_tee(args: &[String]) {
759 let tee_dir = match dirs::home_dir() {
760 Some(h) => h.join(".lean-ctx").join("tee"),
761 None => {
762 eprintln!("Cannot determine home directory");
763 std::process::exit(1);
764 }
765 };
766
767 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
768 match action {
769 "list" | "ls" => {
770 if !tee_dir.exists() {
771 println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
772 return;
773 }
774 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
775 .unwrap_or_else(|e| {
776 eprintln!("Error: {e}");
777 std::process::exit(1);
778 })
779 .filter_map(|e| e.ok())
780 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
781 .collect();
782 entries.sort_by_key(|e| e.file_name());
783
784 if entries.is_empty() {
785 println!("No tee logs found.");
786 return;
787 }
788
789 println!("Tee logs ({}):\n", entries.len());
790 for entry in &entries {
791 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
792 let name = entry.file_name();
793 let size_str = if size > 1024 {
794 format!("{}K", size / 1024)
795 } else {
796 format!("{}B", size)
797 };
798 println!(" {:<60} {}", name.to_string_lossy(), size_str);
799 }
800 println!("\nUse 'lean-ctx tee clear' to delete all logs.");
801 }
802 "clear" | "purge" => {
803 if !tee_dir.exists() {
804 println!("No tee logs to clear.");
805 return;
806 }
807 let mut count = 0u32;
808 if let Ok(entries) = std::fs::read_dir(&tee_dir) {
809 for entry in entries.flatten() {
810 if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
811 && std::fs::remove_file(entry.path()).is_ok()
812 {
813 count += 1;
814 }
815 }
816 }
817 println!("Cleared {count} tee log(s) from {}", tee_dir.display());
818 }
819 "show" => {
820 let filename = args.get(1);
821 if filename.is_none() {
822 eprintln!("Usage: lean-ctx tee show <filename>");
823 std::process::exit(1);
824 }
825 let path = tee_dir.join(filename.unwrap());
826 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
827 Ok(content) => print!("{content}"),
828 Err(e) => {
829 eprintln!("Error reading {}: {e}", path.display());
830 std::process::exit(1);
831 }
832 }
833 }
834 "last" => {
835 if !tee_dir.exists() {
836 println!("No tee logs found.");
837 return;
838 }
839 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
840 .ok()
841 .into_iter()
842 .flat_map(|d| d.filter_map(|e| e.ok()))
843 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
844 .collect();
845 entries.sort_by_key(|e| {
846 e.metadata()
847 .and_then(|m| m.modified())
848 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
849 });
850 match entries.last() {
851 Some(entry) => {
852 let path = entry.path();
853 println!(
854 "--- {} ---\n",
855 path.file_name().unwrap_or_default().to_string_lossy()
856 );
857 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
858 Ok(content) => print!("{content}"),
859 Err(e) => eprintln!("Error: {e}"),
860 }
861 }
862 None => println!("No tee logs found."),
863 }
864 }
865 _ => {
866 eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
867 std::process::exit(1);
868 }
869 }
870}
871
872pub fn cmd_filter(args: &[String]) {
873 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
874 match action {
875 "list" | "ls" => match crate::core::filters::FilterEngine::load() {
876 Some(engine) => {
877 let rules = engine.list_rules();
878 println!("Loaded {} filter rule(s):\n", rules.len());
879 for rule in &rules {
880 println!("{rule}");
881 }
882 }
883 None => {
884 println!("No custom filters found.");
885 println!("Create one: lean-ctx filter init");
886 }
887 },
888 "validate" => {
889 let path = args.get(1);
890 if path.is_none() {
891 eprintln!("Usage: lean-ctx filter validate <file.toml>");
892 std::process::exit(1);
893 }
894 match crate::core::filters::validate_filter_file(path.unwrap()) {
895 Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
896 Err(e) => {
897 eprintln!("Validation failed: {e}");
898 std::process::exit(1);
899 }
900 }
901 }
902 "init" => match crate::core::filters::create_example_filter() {
903 Ok(path) => {
904 println!("Created example filter: {path}");
905 println!("Edit it to add your custom compression rules.");
906 }
907 Err(e) => {
908 eprintln!("{e}");
909 std::process::exit(1);
910 }
911 },
912 _ => {
913 eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
914 std::process::exit(1);
915 }
916 }
917}
918
919fn quiet_enabled() -> bool {
920 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
921}
922
923macro_rules! qprintln {
924 ($($t:tt)*) => {
925 if !quiet_enabled() {
926 println!($($t)*);
927 }
928 };
929}
930
931pub fn cmd_init(args: &[String]) {
932 let global = args.iter().any(|a| a == "--global" || a == "-g");
933 let dry_run = args.iter().any(|a| a == "--dry-run");
934
935 let agents: Vec<&str> = args
936 .windows(2)
937 .filter(|w| w[0] == "--agent")
938 .map(|w| w[1].as_str())
939 .collect();
940
941 if !agents.is_empty() {
942 for agent_name in &agents {
943 crate::hooks::install_agent_hook(agent_name, global);
944 if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
945 eprintln!("MCP config for '{agent_name}' not updated: {e}");
946 }
947 }
948 if !global {
949 crate::hooks::install_project_rules();
950 }
951 qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
952 return;
953 }
954
955 let eval_shell = args
956 .iter()
957 .find(|a| matches!(a.as_str(), "bash" | "zsh" | "fish" | "powershell" | "pwsh"));
958 if let Some(shell) = eval_shell {
959 if !global {
960 shell_init::print_hook_stdout(shell);
961 return;
962 }
963 }
964
965 let shell_name = std::env::var("SHELL").unwrap_or_default();
966 let is_zsh = shell_name.contains("zsh");
967 let is_fish = shell_name.contains("fish");
968 let is_powershell = cfg!(windows) && shell_name.is_empty();
969
970 let binary = crate::core::portable_binary::resolve_portable_binary();
971
972 if dry_run {
973 let rc = if is_powershell {
974 "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
975 } else if is_fish {
976 "~/.config/fish/config.fish".to_string()
977 } else if is_zsh {
978 "~/.zshrc".to_string()
979 } else {
980 "~/.bashrc".to_string()
981 };
982 qprintln!("\nlean-ctx init --dry-run\n");
983 qprintln!(" Would modify: {rc}");
984 qprintln!(" Would backup: {rc}.lean-ctx.bak");
985 qprintln!(" Would alias: git npm pnpm yarn cargo docker docker-compose kubectl");
986 qprintln!(" gh pip pip3 ruff go golangci-lint eslint prettier tsc");
987 qprintln!(" curl wget php composer (24 commands + k)");
988 qprintln!(" Would create: ~/.lean-ctx/");
989 qprintln!(" Binary: {binary}");
990 qprintln!("\n Safety: aliases auto-fallback to original command if lean-ctx is removed.");
991 qprintln!("\n Run without --dry-run to apply.");
992 return;
993 }
994
995 if is_powershell {
996 init_powershell(&binary);
997 } else {
998 let bash_binary = to_bash_compatible_path(&binary);
999 if is_fish {
1000 init_fish(&bash_binary);
1001 } else {
1002 init_posix(is_zsh, &bash_binary);
1003 }
1004 }
1005
1006 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
1007 if let Some(dir) = lean_dir {
1008 if !dir.exists() {
1009 let _ = std::fs::create_dir_all(&dir);
1010 qprintln!("Created {}", dir.display());
1011 }
1012 }
1013
1014 let rc = if is_powershell {
1015 "$PROFILE"
1016 } else if is_fish {
1017 "config.fish"
1018 } else if is_zsh {
1019 ".zshrc"
1020 } else {
1021 ".bashrc"
1022 };
1023
1024 qprintln!("\nlean-ctx init complete (24 aliases installed)");
1025 qprintln!();
1026 qprintln!(" Disable temporarily: lean-ctx-off");
1027 qprintln!(" Re-enable: lean-ctx-on");
1028 qprintln!(" Check status: lean-ctx-status");
1029 qprintln!(" Full uninstall: lean-ctx uninstall");
1030 qprintln!(" Diagnose issues: lean-ctx doctor");
1031 qprintln!(" Preview changes: lean-ctx init --global --dry-run");
1032 qprintln!();
1033 if is_powershell {
1034 qprintln!(" Restart PowerShell or run: . {rc}");
1035 } else {
1036 qprintln!(" Restart your shell or run: source ~/{rc}");
1037 }
1038 qprintln!();
1039 qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1040 qprintln!(" Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1041 qprintln!(" crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1042 qprintln!(" pi, qwen, roo, sublime, trae, verdent, windsurf");
1043}
1044
1045pub fn cmd_init_quiet(args: &[String]) {
1046 std::env::set_var("LEAN_CTX_QUIET", "1");
1047 cmd_init(args);
1048 std::env::remove_var("LEAN_CTX_QUIET");
1049}
1050
1051pub fn load_shell_history_pub() -> Vec<String> {
1052 load_shell_history()
1053}
1054
1055fn load_shell_history() -> Vec<String> {
1056 let shell = std::env::var("SHELL").unwrap_or_default();
1057 let home = match dirs::home_dir() {
1058 Some(h) => h,
1059 None => return Vec::new(),
1060 };
1061
1062 let history_file = if shell.contains("zsh") {
1063 home.join(".zsh_history")
1064 } else if shell.contains("fish") {
1065 home.join(".local/share/fish/fish_history")
1066 } else if cfg!(windows) && shell.is_empty() {
1067 home.join("AppData")
1068 .join("Roaming")
1069 .join("Microsoft")
1070 .join("Windows")
1071 .join("PowerShell")
1072 .join("PSReadLine")
1073 .join("ConsoleHost_history.txt")
1074 } else {
1075 home.join(".bash_history")
1076 };
1077
1078 match std::fs::read_to_string(&history_file) {
1079 Ok(content) => content
1080 .lines()
1081 .filter_map(|l| {
1082 let trimmed = l.trim();
1083 if trimmed.starts_with(':') {
1084 trimmed.split(';').nth(1).map(|s| s.to_string())
1085 } else {
1086 Some(trimmed.to_string())
1087 }
1088 })
1089 .filter(|l| !l.is_empty())
1090 .collect(),
1091 Err(_) => Vec::new(),
1092 }
1093}
1094
1095fn print_savings(original: usize, sent: usize) {
1096 let saved = original.saturating_sub(sent);
1097 if original > 0 && saved > 0 {
1098 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1099 println!("[{saved} tok saved ({pct}%)]");
1100 }
1101}
1102
1103pub fn cmd_theme(args: &[String]) {
1104 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1105 let r = theme::rst();
1106 let b = theme::bold();
1107 let d = theme::dim();
1108
1109 match sub {
1110 "list" => {
1111 let cfg = config::Config::load();
1112 let active = cfg.theme.as_str();
1113 println!();
1114 println!(" {b}Available themes:{r}");
1115 println!(" {ln}", ln = "─".repeat(40));
1116 for name in theme::PRESET_NAMES {
1117 let marker = if *name == active { " ◀ active" } else { "" };
1118 let t = theme::from_preset(name).unwrap();
1119 let preview = format!(
1120 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1121 p = t.primary.fg(),
1122 s = t.secondary.fg(),
1123 a = t.accent.fg(),
1124 sc = t.success.fg(),
1125 w = t.warning.fg(),
1126 );
1127 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1128 }
1129 if let Some(path) = theme::theme_file_path() {
1130 if path.exists() {
1131 let custom = theme::load_theme("_custom_");
1132 let preview = format!(
1133 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1134 p = custom.primary.fg(),
1135 s = custom.secondary.fg(),
1136 a = custom.accent.fg(),
1137 sc = custom.success.fg(),
1138 w = custom.warning.fg(),
1139 );
1140 let marker = if active == "custom" {
1141 " ◀ active"
1142 } else {
1143 ""
1144 };
1145 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1146 }
1147 }
1148 println!();
1149 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1150 println!();
1151 }
1152 "set" => {
1153 if args.len() < 2 {
1154 eprintln!("Usage: lean-ctx theme set <name>");
1155 std::process::exit(1);
1156 }
1157 let name = &args[1];
1158 if theme::from_preset(name).is_none() && name != "custom" {
1159 eprintln!(
1160 "Unknown theme '{name}'. Available: {}",
1161 theme::PRESET_NAMES.join(", ")
1162 );
1163 std::process::exit(1);
1164 }
1165 let mut cfg = config::Config::load();
1166 cfg.theme = name.to_string();
1167 match cfg.save() {
1168 Ok(()) => {
1169 let t = theme::load_theme(name);
1170 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1171 let preview = t.gradient_bar(0.75, 30);
1172 println!(" {preview}");
1173 }
1174 Err(e) => eprintln!("Error: {e}"),
1175 }
1176 }
1177 "export" => {
1178 let cfg = config::Config::load();
1179 let t = theme::load_theme(&cfg.theme);
1180 println!("{}", t.to_toml());
1181 }
1182 "import" => {
1183 if args.len() < 2 {
1184 eprintln!("Usage: lean-ctx theme import <path>");
1185 std::process::exit(1);
1186 }
1187 let path = std::path::Path::new(&args[1]);
1188 if !path.exists() {
1189 eprintln!("File not found: {}", args[1]);
1190 std::process::exit(1);
1191 }
1192 match std::fs::read_to_string(path) {
1193 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1194 Ok(imported) => match theme::save_theme(&imported) {
1195 Ok(()) => {
1196 let mut cfg = config::Config::load();
1197 cfg.theme = "custom".to_string();
1198 let _ = cfg.save();
1199 println!(
1200 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1201 sc = imported.success.fg(),
1202 name = imported.name,
1203 );
1204 println!(" Config updated: theme = custom");
1205 }
1206 Err(e) => eprintln!("Error saving theme: {e}"),
1207 },
1208 Err(e) => eprintln!("Invalid theme file: {e}"),
1209 },
1210 Err(e) => eprintln!("Error reading file: {e}"),
1211 }
1212 }
1213 "preview" => {
1214 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1215 let t = match theme::from_preset(name) {
1216 Some(t) => t,
1217 None => {
1218 eprintln!("Unknown theme: {name}");
1219 std::process::exit(1);
1220 }
1221 };
1222 println!();
1223 println!(
1224 " {icon} {title} {d}Theme Preview: {name}{r}",
1225 icon = t.header_icon(),
1226 title = t.brand_title(),
1227 );
1228 println!(" {ln}", ln = t.border_line(50));
1229 println!();
1230 println!(
1231 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1232 sc = t.success.fg(),
1233 sec = t.secondary.fg(),
1234 wrn = t.warning.fg(),
1235 acc = t.accent.fg(),
1236 );
1237 println!(" {d} tokens saved compression commands USD saved{r}");
1238 println!();
1239 println!(
1240 " {b}{txt}Gradient Bar{r} {bar}",
1241 txt = t.text.fg(),
1242 bar = t.gradient_bar(0.85, 30),
1243 );
1244 println!(
1245 " {b}{txt}Sparkline{r} {spark}",
1246 txt = t.text.fg(),
1247 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1248 );
1249 println!();
1250 println!(" {top}", top = t.box_top(50));
1251 println!(
1252 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1253 side = t.box_side(),
1254 side_r = t.box_side(),
1255 txt = t.text.fg(),
1256 );
1257 println!(" {bot}", bot = t.box_bottom(50));
1258 println!();
1259 }
1260 _ => {
1261 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1262 std::process::exit(1);
1263 }
1264 }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269 use super::*;
1270 use tempfile;
1271
1272 #[test]
1273 fn test_remove_lean_ctx_block_posix() {
1274 let input = r#"# existing config
1275export PATH="$HOME/bin:$PATH"
1276
1277# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1278if [ -z "$LEAN_CTX_ACTIVE" ]; then
1279alias git='lean-ctx -c git'
1280alias npm='lean-ctx -c npm'
1281fi
1282
1283# other stuff
1284export EDITOR=vim
1285"#;
1286 let result = remove_lean_ctx_block(input);
1287 assert!(!result.contains("lean-ctx"), "block should be removed");
1288 assert!(result.contains("export PATH"), "other content preserved");
1289 assert!(
1290 result.contains("export EDITOR"),
1291 "trailing content preserved"
1292 );
1293 }
1294
1295 #[test]
1296 fn test_remove_lean_ctx_block_fish() {
1297 let input = "# other fish config\nset -x FOO bar\n\n# lean-ctx shell hook — transparent CLI compression (90+ 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";
1298 let result = remove_lean_ctx_block(input);
1299 assert!(!result.contains("lean-ctx"), "block should be removed");
1300 assert!(result.contains("set -x FOO"), "other content preserved");
1301 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1302 }
1303
1304 #[test]
1305 fn test_remove_lean_ctx_block_ps() {
1306 let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (90+ 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";
1307 let result = remove_lean_ctx_block_ps(input);
1308 assert!(
1309 !result.contains("lean-ctx shell hook"),
1310 "block should be removed"
1311 );
1312 assert!(result.contains("$env:FOO"), "other content preserved");
1313 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1314 }
1315
1316 #[test]
1317 fn test_remove_lean_ctx_block_ps_nested() {
1318 let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (90+ 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";
1319 let result = remove_lean_ctx_block_ps(input);
1320 assert!(
1321 !result.contains("lean-ctx shell hook"),
1322 "block should be removed"
1323 );
1324 assert!(!result.contains("_lc"), "function should be removed");
1325 assert!(result.contains("$env:FOO"), "other content preserved");
1326 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1327 }
1328
1329 #[test]
1330 fn test_remove_block_no_lean_ctx() {
1331 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1332 let result = remove_lean_ctx_block(input);
1333 assert!(result.contains("export PATH"), "content unchanged");
1334 }
1335
1336 #[test]
1337 fn test_bash_hook_contains_pipe_guard() {
1338 let binary = "/usr/local/bin/lean-ctx";
1339 let hook = format!(
1340 r#"_lc() {{
1341 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1342 command "$@"
1343 return
1344 fi
1345 '{binary}' -t "$@"
1346}}"#
1347 );
1348 assert!(
1349 hook.contains("! -t 1"),
1350 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1351 );
1352 assert!(
1353 hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1354 "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1355 );
1356 }
1357
1358 #[test]
1359 fn test_lc_uses_track_mode_by_default() {
1360 let binary = "/usr/local/bin/lean-ctx";
1361 let alias_list = crate::rewrite_registry::shell_alias_list();
1362 let aliases = format!(
1363 r#"_lc() {{
1364 '{binary}' -t "$@"
1365}}
1366_lc_compress() {{
1367 '{binary}' -c "$@"
1368}}"#
1369 );
1370 assert!(
1371 aliases.contains("-t \"$@\""),
1372 "_lc must use -t (track mode) by default"
1373 );
1374 assert!(
1375 aliases.contains("-c \"$@\""),
1376 "_lc_compress must use -c (compress mode)"
1377 );
1378 let _ = alias_list;
1379 }
1380
1381 #[test]
1382 fn test_posix_shell_has_lean_ctx_mode() {
1383 let alias_list = crate::rewrite_registry::shell_alias_list();
1384 let aliases = r#"
1385lean-ctx-mode() {{
1386 case "${{1:-}}" in
1387 compress) echo compress ;;
1388 track) echo track ;;
1389 off) echo off ;;
1390 esac
1391}}
1392"#
1393 .to_string();
1394 assert!(
1395 aliases.contains("lean-ctx-mode()"),
1396 "lean-ctx-mode function must exist"
1397 );
1398 assert!(
1399 aliases.contains("compress"),
1400 "compress mode must be available"
1401 );
1402 assert!(aliases.contains("track"), "track mode must be available");
1403 let _ = alias_list;
1404 }
1405
1406 #[test]
1407 fn test_fish_hook_contains_pipe_guard() {
1408 let hook = "function _lc\n\tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\t\tcommand $argv\n\t\treturn\n\tend\nend";
1409 assert!(
1410 hook.contains("isatty stdout"),
1411 "fish hook must contain pipe guard (isatty stdout)"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_powershell_hook_contains_pipe_guard() {
1417 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1418 assert!(
1419 hook.contains("IsOutputRedirected"),
1420 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1426 let input = r#"# existing config
1427export PATH="$HOME/bin:$PATH"
1428
1429# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1430_lean_ctx_cmds=(git npm pnpm)
1431
1432lean-ctx-on() {
1433 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1434 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1435 done
1436 export LEAN_CTX_ENABLED=1
1437 [ -t 1 ] && echo "lean-ctx: ON"
1438}
1439
1440lean-ctx-off() {
1441 unset LEAN_CTX_ENABLED
1442 [ -t 1 ] && echo "lean-ctx: OFF"
1443}
1444
1445if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1446 lean-ctx-on
1447fi
1448# lean-ctx shell hook — end
1449
1450# other stuff
1451export EDITOR=vim
1452"#;
1453 let result = remove_lean_ctx_block(input);
1454 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1455 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1456 assert!(result.contains("export PATH"), "other content preserved");
1457 assert!(
1458 result.contains("export EDITOR"),
1459 "trailing content preserved"
1460 );
1461 }
1462
1463 #[test]
1464 fn env_sh_for_containers_includes_self_heal() {
1465 let _g = crate::core::data_dir::test_env_lock();
1466 let tmp = tempfile::tempdir().expect("tempdir");
1467 let data_dir = tmp.path().join("data");
1468 std::fs::create_dir_all(&data_dir).expect("mkdir data");
1469 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1470
1471 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1472 let env_sh = data_dir.join("env.sh");
1473 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1474 assert!(content.contains("lean-ctx docker self-heal"));
1475 assert!(content.contains("claude mcp list"));
1476 assert!(content.contains("lean-ctx init --agent claude"));
1477
1478 std::env::remove_var("LEAN_CTX_DATA_DIR");
1479 }
1480}