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 shell_name = std::env::var("SHELL").unwrap_or_default();
956 let is_zsh = shell_name.contains("zsh");
957 let is_fish = shell_name.contains("fish");
958 let is_powershell = cfg!(windows) && shell_name.is_empty();
959
960 let binary = std::env::current_exe()
961 .map(|p| p.to_string_lossy().to_string())
962 .unwrap_or_else(|_| "lean-ctx".to_string());
963
964 if dry_run {
965 let rc = if is_powershell {
966 "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
967 } else if is_fish {
968 "~/.config/fish/config.fish".to_string()
969 } else if is_zsh {
970 "~/.zshrc".to_string()
971 } else {
972 "~/.bashrc".to_string()
973 };
974 qprintln!("\nlean-ctx init --dry-run\n");
975 qprintln!(" Would modify: {rc}");
976 qprintln!(" Would backup: {rc}.lean-ctx.bak");
977 qprintln!(" Would alias: git npm pnpm yarn cargo docker docker-compose kubectl");
978 qprintln!(" gh pip pip3 ruff go golangci-lint eslint prettier tsc");
979 qprintln!(" curl wget php composer (24 commands + k)");
980 qprintln!(" Would create: ~/.lean-ctx/");
981 qprintln!(" Binary: {binary}");
982 qprintln!("\n Safety: aliases auto-fallback to original command if lean-ctx is removed.");
983 qprintln!("\n Run without --dry-run to apply.");
984 return;
985 }
986
987 if is_powershell {
988 init_powershell(&binary);
989 } else {
990 let bash_binary = to_bash_compatible_path(&binary);
991 if is_fish {
992 init_fish(&bash_binary);
993 } else {
994 init_posix(is_zsh, &bash_binary);
995 }
996 }
997
998 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
999 if let Some(dir) = lean_dir {
1000 if !dir.exists() {
1001 let _ = std::fs::create_dir_all(&dir);
1002 qprintln!("Created {}", dir.display());
1003 }
1004 }
1005
1006 let rc = if is_powershell {
1007 "$PROFILE"
1008 } else if is_fish {
1009 "config.fish"
1010 } else if is_zsh {
1011 ".zshrc"
1012 } else {
1013 ".bashrc"
1014 };
1015
1016 qprintln!("\nlean-ctx init complete (24 aliases installed)");
1017 qprintln!();
1018 qprintln!(" Disable temporarily: lean-ctx-off");
1019 qprintln!(" Re-enable: lean-ctx-on");
1020 qprintln!(" Check status: lean-ctx-status");
1021 qprintln!(" Full uninstall: lean-ctx uninstall");
1022 qprintln!(" Diagnose issues: lean-ctx doctor");
1023 qprintln!(" Preview changes: lean-ctx init --global --dry-run");
1024 qprintln!();
1025 if is_powershell {
1026 qprintln!(" Restart PowerShell or run: . {rc}");
1027 } else {
1028 qprintln!(" Restart your shell or run: source ~/{rc}");
1029 }
1030 qprintln!();
1031 qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1032 qprintln!(" Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1033 qprintln!(" crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1034 qprintln!(" pi, qwen, roo, sublime, trae, verdent, windsurf");
1035}
1036
1037pub fn cmd_init_quiet(args: &[String]) {
1038 std::env::set_var("LEAN_CTX_QUIET", "1");
1039 cmd_init(args);
1040 std::env::remove_var("LEAN_CTX_QUIET");
1041}
1042
1043pub fn load_shell_history_pub() -> Vec<String> {
1044 load_shell_history()
1045}
1046
1047fn load_shell_history() -> Vec<String> {
1048 let shell = std::env::var("SHELL").unwrap_or_default();
1049 let home = match dirs::home_dir() {
1050 Some(h) => h,
1051 None => return Vec::new(),
1052 };
1053
1054 let history_file = if shell.contains("zsh") {
1055 home.join(".zsh_history")
1056 } else if shell.contains("fish") {
1057 home.join(".local/share/fish/fish_history")
1058 } else if cfg!(windows) && shell.is_empty() {
1059 home.join("AppData")
1060 .join("Roaming")
1061 .join("Microsoft")
1062 .join("Windows")
1063 .join("PowerShell")
1064 .join("PSReadLine")
1065 .join("ConsoleHost_history.txt")
1066 } else {
1067 home.join(".bash_history")
1068 };
1069
1070 match std::fs::read_to_string(&history_file) {
1071 Ok(content) => content
1072 .lines()
1073 .filter_map(|l| {
1074 let trimmed = l.trim();
1075 if trimmed.starts_with(':') {
1076 trimmed.split(';').nth(1).map(|s| s.to_string())
1077 } else {
1078 Some(trimmed.to_string())
1079 }
1080 })
1081 .filter(|l| !l.is_empty())
1082 .collect(),
1083 Err(_) => Vec::new(),
1084 }
1085}
1086
1087fn print_savings(original: usize, sent: usize) {
1088 let saved = original.saturating_sub(sent);
1089 if original > 0 && saved > 0 {
1090 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1091 println!("[{saved} tok saved ({pct}%)]");
1092 }
1093}
1094
1095pub fn cmd_theme(args: &[String]) {
1096 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1097 let r = theme::rst();
1098 let b = theme::bold();
1099 let d = theme::dim();
1100
1101 match sub {
1102 "list" => {
1103 let cfg = config::Config::load();
1104 let active = cfg.theme.as_str();
1105 println!();
1106 println!(" {b}Available themes:{r}");
1107 println!(" {ln}", ln = "─".repeat(40));
1108 for name in theme::PRESET_NAMES {
1109 let marker = if *name == active { " ◀ active" } else { "" };
1110 let t = theme::from_preset(name).unwrap();
1111 let preview = format!(
1112 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1113 p = t.primary.fg(),
1114 s = t.secondary.fg(),
1115 a = t.accent.fg(),
1116 sc = t.success.fg(),
1117 w = t.warning.fg(),
1118 );
1119 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1120 }
1121 if let Some(path) = theme::theme_file_path() {
1122 if path.exists() {
1123 let custom = theme::load_theme("_custom_");
1124 let preview = format!(
1125 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1126 p = custom.primary.fg(),
1127 s = custom.secondary.fg(),
1128 a = custom.accent.fg(),
1129 sc = custom.success.fg(),
1130 w = custom.warning.fg(),
1131 );
1132 let marker = if active == "custom" {
1133 " ◀ active"
1134 } else {
1135 ""
1136 };
1137 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1138 }
1139 }
1140 println!();
1141 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1142 println!();
1143 }
1144 "set" => {
1145 if args.len() < 2 {
1146 eprintln!("Usage: lean-ctx theme set <name>");
1147 std::process::exit(1);
1148 }
1149 let name = &args[1];
1150 if theme::from_preset(name).is_none() && name != "custom" {
1151 eprintln!(
1152 "Unknown theme '{name}'. Available: {}",
1153 theme::PRESET_NAMES.join(", ")
1154 );
1155 std::process::exit(1);
1156 }
1157 let mut cfg = config::Config::load();
1158 cfg.theme = name.to_string();
1159 match cfg.save() {
1160 Ok(()) => {
1161 let t = theme::load_theme(name);
1162 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1163 let preview = t.gradient_bar(0.75, 30);
1164 println!(" {preview}");
1165 }
1166 Err(e) => eprintln!("Error: {e}"),
1167 }
1168 }
1169 "export" => {
1170 let cfg = config::Config::load();
1171 let t = theme::load_theme(&cfg.theme);
1172 println!("{}", t.to_toml());
1173 }
1174 "import" => {
1175 if args.len() < 2 {
1176 eprintln!("Usage: lean-ctx theme import <path>");
1177 std::process::exit(1);
1178 }
1179 let path = std::path::Path::new(&args[1]);
1180 if !path.exists() {
1181 eprintln!("File not found: {}", args[1]);
1182 std::process::exit(1);
1183 }
1184 match std::fs::read_to_string(path) {
1185 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1186 Ok(imported) => match theme::save_theme(&imported) {
1187 Ok(()) => {
1188 let mut cfg = config::Config::load();
1189 cfg.theme = "custom".to_string();
1190 let _ = cfg.save();
1191 println!(
1192 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1193 sc = imported.success.fg(),
1194 name = imported.name,
1195 );
1196 println!(" Config updated: theme = custom");
1197 }
1198 Err(e) => eprintln!("Error saving theme: {e}"),
1199 },
1200 Err(e) => eprintln!("Invalid theme file: {e}"),
1201 },
1202 Err(e) => eprintln!("Error reading file: {e}"),
1203 }
1204 }
1205 "preview" => {
1206 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1207 let t = match theme::from_preset(name) {
1208 Some(t) => t,
1209 None => {
1210 eprintln!("Unknown theme: {name}");
1211 std::process::exit(1);
1212 }
1213 };
1214 println!();
1215 println!(
1216 " {icon} {title} {d}Theme Preview: {name}{r}",
1217 icon = t.header_icon(),
1218 title = t.brand_title(),
1219 );
1220 println!(" {ln}", ln = t.border_line(50));
1221 println!();
1222 println!(
1223 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1224 sc = t.success.fg(),
1225 sec = t.secondary.fg(),
1226 wrn = t.warning.fg(),
1227 acc = t.accent.fg(),
1228 );
1229 println!(" {d} tokens saved compression commands USD saved{r}");
1230 println!();
1231 println!(
1232 " {b}{txt}Gradient Bar{r} {bar}",
1233 txt = t.text.fg(),
1234 bar = t.gradient_bar(0.85, 30),
1235 );
1236 println!(
1237 " {b}{txt}Sparkline{r} {spark}",
1238 txt = t.text.fg(),
1239 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1240 );
1241 println!();
1242 println!(" {top}", top = t.box_top(50));
1243 println!(
1244 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1245 side = t.box_side(),
1246 side_r = t.box_side(),
1247 txt = t.text.fg(),
1248 );
1249 println!(" {bot}", bot = t.box_bottom(50));
1250 println!();
1251 }
1252 _ => {
1253 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1254 std::process::exit(1);
1255 }
1256 }
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 use super::*;
1262 use tempfile;
1263
1264 #[test]
1265 fn test_remove_lean_ctx_block_posix() {
1266 let input = r#"# existing config
1267export PATH="$HOME/bin:$PATH"
1268
1269# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1270if [ -z "$LEAN_CTX_ACTIVE" ]; then
1271alias git='lean-ctx -c git'
1272alias npm='lean-ctx -c npm'
1273fi
1274
1275# other stuff
1276export EDITOR=vim
1277"#;
1278 let result = remove_lean_ctx_block(input);
1279 assert!(!result.contains("lean-ctx"), "block should be removed");
1280 assert!(result.contains("export PATH"), "other content preserved");
1281 assert!(
1282 result.contains("export EDITOR"),
1283 "trailing content preserved"
1284 );
1285 }
1286
1287 #[test]
1288 fn test_remove_lean_ctx_block_fish() {
1289 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";
1290 let result = remove_lean_ctx_block(input);
1291 assert!(!result.contains("lean-ctx"), "block should be removed");
1292 assert!(result.contains("set -x FOO"), "other content preserved");
1293 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1294 }
1295
1296 #[test]
1297 fn test_remove_lean_ctx_block_ps() {
1298 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";
1299 let result = remove_lean_ctx_block_ps(input);
1300 assert!(
1301 !result.contains("lean-ctx shell hook"),
1302 "block should be removed"
1303 );
1304 assert!(result.contains("$env:FOO"), "other content preserved");
1305 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1306 }
1307
1308 #[test]
1309 fn test_remove_lean_ctx_block_ps_nested() {
1310 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";
1311 let result = remove_lean_ctx_block_ps(input);
1312 assert!(
1313 !result.contains("lean-ctx shell hook"),
1314 "block should be removed"
1315 );
1316 assert!(!result.contains("_lc"), "function should be removed");
1317 assert!(result.contains("$env:FOO"), "other content preserved");
1318 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1319 }
1320
1321 #[test]
1322 fn test_remove_block_no_lean_ctx() {
1323 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1324 let result = remove_lean_ctx_block(input);
1325 assert!(result.contains("export PATH"), "content unchanged");
1326 }
1327
1328 #[test]
1329 fn test_bash_hook_contains_pipe_guard() {
1330 let binary = "/usr/local/bin/lean-ctx";
1331 let hook = format!(
1332 r#"_lc() {{
1333 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1334 command "$@"
1335 return
1336 fi
1337 '{binary}' -t "$@"
1338}}"#
1339 );
1340 assert!(
1341 hook.contains("! -t 1"),
1342 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1343 );
1344 assert!(
1345 hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1346 "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_lc_uses_track_mode_by_default() {
1352 let binary = "/usr/local/bin/lean-ctx";
1353 let alias_list = crate::rewrite_registry::shell_alias_list();
1354 let aliases = format!(
1355 r#"_lc() {{
1356 '{binary}' -t "$@"
1357}}
1358_lc_compress() {{
1359 '{binary}' -c "$@"
1360}}"#
1361 );
1362 assert!(
1363 aliases.contains("-t \"$@\""),
1364 "_lc must use -t (track mode) by default"
1365 );
1366 assert!(
1367 aliases.contains("-c \"$@\""),
1368 "_lc_compress must use -c (compress mode)"
1369 );
1370 let _ = alias_list;
1371 }
1372
1373 #[test]
1374 fn test_posix_shell_has_lean_ctx_mode() {
1375 let alias_list = crate::rewrite_registry::shell_alias_list();
1376 let aliases = r#"
1377lean-ctx-mode() {{
1378 case "${{1:-}}" in
1379 compress) echo compress ;;
1380 track) echo track ;;
1381 off) echo off ;;
1382 esac
1383}}
1384"#
1385 .to_string();
1386 assert!(
1387 aliases.contains("lean-ctx-mode()"),
1388 "lean-ctx-mode function must exist"
1389 );
1390 assert!(
1391 aliases.contains("compress"),
1392 "compress mode must be available"
1393 );
1394 assert!(aliases.contains("track"), "track mode must be available");
1395 let _ = alias_list;
1396 }
1397
1398 #[test]
1399 fn test_fish_hook_contains_pipe_guard() {
1400 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";
1401 assert!(
1402 hook.contains("isatty stdout"),
1403 "fish hook must contain pipe guard (isatty stdout)"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_powershell_hook_contains_pipe_guard() {
1409 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1410 assert!(
1411 hook.contains("IsOutputRedirected"),
1412 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1418 let input = r#"# existing config
1419export PATH="$HOME/bin:$PATH"
1420
1421# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1422_lean_ctx_cmds=(git npm pnpm)
1423
1424lean-ctx-on() {
1425 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1426 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1427 done
1428 export LEAN_CTX_ENABLED=1
1429 [ -t 1 ] && echo "lean-ctx: ON"
1430}
1431
1432lean-ctx-off() {
1433 unset LEAN_CTX_ENABLED
1434 [ -t 1 ] && echo "lean-ctx: OFF"
1435}
1436
1437if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1438 lean-ctx-on
1439fi
1440# lean-ctx shell hook — end
1441
1442# other stuff
1443export EDITOR=vim
1444"#;
1445 let result = remove_lean_ctx_block(input);
1446 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1447 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1448 assert!(result.contains("export PATH"), "other content preserved");
1449 assert!(
1450 result.contains("export EDITOR"),
1451 "trailing content preserved"
1452 );
1453 }
1454
1455 #[test]
1456 fn env_sh_for_containers_includes_self_heal() {
1457 let _g = crate::core::data_dir::test_env_lock();
1458 let tmp = tempfile::tempdir().expect("tempdir");
1459 let data_dir = tmp.path().join("data");
1460 std::fs::create_dir_all(&data_dir).expect("mkdir data");
1461 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1462
1463 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1464 let env_sh = data_dir.join("env.sh");
1465 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1466 assert!(content.contains("lean-ctx docker self-heal"));
1467 assert!(content.contains("claude mcp list"));
1468 assert!(content.contains("lean-ctx init --agent claude"));
1469
1470 std::env::remove_var("LEAN_CTX_DATA_DIR");
1471 }
1472}