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 {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_ghost(args: &[String]) {
309 let json = args.iter().any(|a| a == "--json");
310
311 let history = load_shell_history();
312 let discover = crate::tools::ctx_discover::analyze_history(&history, 20);
313
314 let session = crate::core::session::SessionState::load_latest();
315 let store = crate::core::stats::load();
316
317 let unoptimized_tokens = discover.potential_tokens;
318 let _unoptimized_usd = discover.potential_usd;
319
320 let redundant_reads = store.cep.total_cache_hits as usize;
321 let redundant_tokens = redundant_reads * 200;
322
323 let wasted_original = store
324 .cep
325 .total_tokens_original
326 .saturating_sub(store.cep.total_tokens_compressed) as usize;
327 let truncated_tokens = wasted_original / 3;
328
329 let total_ghost = unoptimized_tokens + redundant_tokens + truncated_tokens;
330 let total_usd =
331 total_ghost as f64 * crate::core::stats::DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
332 let monthly_usd = total_usd * 30.0;
333
334 if json {
335 let obj = serde_json::json!({
336 "ghost_tokens": total_ghost,
337 "breakdown": {
338 "unoptimized_shells": unoptimized_tokens,
339 "redundant_reads": redundant_tokens,
340 "truncated_contexts": truncated_tokens,
341 },
342 "estimated_usd": total_usd,
343 "monthly_usd": monthly_usd,
344 "session_active": session.is_some(),
345 "history_commands": discover.total_commands,
346 "already_optimized": discover.already_optimized,
347 });
348 println!("{}", serde_json::to_string_pretty(&obj).unwrap_or_default());
349 return;
350 }
351
352 let bold = "\x1b[1m";
353 let green = "\x1b[32m";
354 let yellow = "\x1b[33m";
355 let red = "\x1b[31m";
356 let dim = "\x1b[2m";
357 let rst = "\x1b[0m";
358 let white = "\x1b[97m";
359
360 println!();
361 println!(" {bold}{white}lean-ctx ghost report{rst}");
362 println!(" {dim}{}{rst}", "=".repeat(40));
363 println!();
364
365 if total_ghost == 0 {
366 println!(" {green}No ghost tokens detected!{rst}");
367 println!(
368 " {dim}All {} commands optimized.{rst}",
369 discover.total_commands
370 );
371 println!();
372 return;
373 }
374
375 let severity = if total_ghost > 10000 {
376 red
377 } else if total_ghost > 3000 {
378 yellow
379 } else {
380 green
381 };
382
383 println!(
384 " {bold}Ghost Tokens found:{rst} {severity}{total_ghost:>8}{rst} tokens {dim}(~${total_usd:.2}){rst}"
385 );
386 println!();
387
388 if unoptimized_tokens > 0 {
389 let missed_count: u32 = discover.missed_commands.iter().map(|m| m.count).sum();
390 println!(
391 " {dim} Unoptimized shells:{rst} {white}{unoptimized_tokens:>8}{rst} {dim}({missed_count} cmds without lean-ctx){rst}"
392 );
393 }
394 if redundant_tokens > 0 {
395 println!(
396 " {dim} Redundant reads:{rst} {white}{redundant_tokens:>8}{rst} {dim}({redundant_reads} cache hits = wasted re-reads){rst}"
397 );
398 }
399 if truncated_tokens > 0 {
400 println!(
401 " {dim} Oversized contexts:{rst} {white}{truncated_tokens:>8}{rst} {dim}(uncompressed portion of tool results){rst}"
402 );
403 }
404
405 println!();
406 println!(" {bold}Monthly savings potential:{rst} {green}${monthly_usd:.2}{rst}");
407
408 if !discover.missed_commands.is_empty() {
409 println!();
410 println!(" {bold}Top unoptimized commands:{rst}");
411 for m in discover.missed_commands.iter().take(5) {
412 println!(
413 " {dim}{:>4}x{rst} {white}{:<12}{rst} {dim}{}{rst}",
414 m.count, m.prefix, m.description
415 );
416 }
417 }
418
419 println!();
420 if discover.already_optimized == 0 {
421 println!(
422 " {yellow}Run '{bold}lean-ctx setup{rst}{yellow}' to eliminate ghost tokens.{rst}"
423 );
424 } else {
425 println!(
426 " {dim}Already optimized: {}/{} commands{rst}",
427 discover.already_optimized, discover.total_commands
428 );
429 }
430 println!();
431}
432
433pub fn cmd_session() {
434 let history = load_shell_history();
435 let gain = stats::load_stats();
436
437 let compressible_commands = [
438 "git ",
439 "npm ",
440 "yarn ",
441 "pnpm ",
442 "cargo ",
443 "docker ",
444 "kubectl ",
445 "gh ",
446 "pip ",
447 "pip3 ",
448 "eslint",
449 "prettier",
450 "ruff ",
451 "go ",
452 "golangci-lint",
453 "curl ",
454 "wget ",
455 "grep ",
456 "rg ",
457 "find ",
458 "ls ",
459 ];
460
461 let mut total = 0u32;
462 let mut via_hook = 0u32;
463
464 for line in &history {
465 let cmd = line.trim().to_lowercase();
466 if cmd.starts_with("lean-ctx") {
467 via_hook += 1;
468 total += 1;
469 } else {
470 for p in &compressible_commands {
471 if cmd.starts_with(p) {
472 total += 1;
473 break;
474 }
475 }
476 }
477 }
478
479 let pct = if total > 0 {
480 (via_hook as f64 / total as f64 * 100.0).round() as u32
481 } else {
482 0
483 };
484
485 println!("lean-ctx session statistics\n");
486 println!(
487 "Adoption: {}% ({}/{} compressible commands)",
488 pct, via_hook, total
489 );
490 println!("Saved: {} tokens total", gain.total_saved);
491 println!("Calls: {} compressed", gain.total_calls);
492
493 if total > via_hook {
494 let missed = total - via_hook;
495 let est = missed * 150;
496 println!(
497 "Missed: {} commands (~{} tokens saveable)",
498 missed, est
499 );
500 }
501
502 println!("\nRun 'lean-ctx discover' for details on missed commands.");
503}
504
505pub fn cmd_wrapped(args: &[String]) {
506 let period = if args.iter().any(|a| a == "--month") {
507 "month"
508 } else if args.iter().any(|a| a == "--all") {
509 "all"
510 } else {
511 "week"
512 };
513
514 let report = crate::core::wrapped::WrappedReport::generate(period);
515 println!("{}", report.format_ascii());
516}
517
518pub fn cmd_sessions(args: &[String]) {
519 use crate::core::session::SessionState;
520
521 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
522
523 match action {
524 "list" | "ls" => {
525 let sessions = SessionState::list_sessions();
526 if sessions.is_empty() {
527 println!("No sessions found.");
528 return;
529 }
530 println!("Sessions ({}):\n", sessions.len());
531 for s in sessions.iter().take(20) {
532 let task = s.task.as_deref().unwrap_or("(no task)");
533 let task_short: String = task.chars().take(50).collect();
534 let date = s.updated_at.format("%Y-%m-%d %H:%M");
535 println!(
536 " {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
537 s.id,
538 s.version,
539 s.tool_calls,
540 format_tokens_cli(s.tokens_saved),
541 date,
542 task_short
543 );
544 }
545 if sessions.len() > 20 {
546 println!(" ... +{} more", sessions.len() - 20);
547 }
548 }
549 "show" => {
550 let id = args.get(1);
551 let session = if let Some(id) = id {
552 SessionState::load_by_id(id)
553 } else {
554 SessionState::load_latest()
555 };
556 match session {
557 Some(s) => println!("{}", s.format_compact()),
558 None => println!("Session not found."),
559 }
560 }
561 "cleanup" => {
562 let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
563 let removed = SessionState::cleanup_old_sessions(days);
564 println!("Cleaned up {removed} session(s) older than {days} days.");
565 }
566 _ => {
567 eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
568 std::process::exit(1);
569 }
570 }
571}
572
573pub fn cmd_benchmark(args: &[String]) {
574 use crate::core::benchmark;
575
576 let action = args.first().map(|s| s.as_str()).unwrap_or("run");
577
578 match action {
579 "run" => {
580 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
581 let is_json = args.iter().any(|a| a == "--json");
582
583 let result = benchmark::run_project_benchmark(path);
584 if is_json {
585 println!("{}", benchmark::format_json(&result));
586 } else {
587 println!("{}", benchmark::format_terminal(&result));
588 }
589 }
590 "report" => {
591 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
592 let result = benchmark::run_project_benchmark(path);
593 println!("{}", benchmark::format_markdown(&result));
594 }
595 _ => {
596 if std::path::Path::new(action).exists() {
597 let result = benchmark::run_project_benchmark(action);
598 println!("{}", benchmark::format_terminal(&result));
599 } else {
600 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
601 eprintln!(" lean-ctx benchmark report [path]");
602 std::process::exit(1);
603 }
604 }
605 }
606}
607
608fn format_tokens_cli(tokens: u64) -> String {
609 if tokens >= 1_000_000 {
610 format!("{:.1}M", tokens as f64 / 1_000_000.0)
611 } else if tokens >= 1_000 {
612 format!("{:.1}K", tokens as f64 / 1_000.0)
613 } else {
614 format!("{tokens}")
615 }
616}
617
618pub fn cmd_stats(args: &[String]) {
619 match args.first().map(|s| s.as_str()) {
620 Some("reset-cep") => {
621 crate::core::stats::reset_cep();
622 println!("CEP stats reset. Shell hook data preserved.");
623 }
624 Some("json") => {
625 let store = crate::core::stats::load();
626 println!(
627 "{}",
628 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
629 );
630 }
631 _ => {
632 let store = crate::core::stats::load();
633 let input_saved = store
634 .total_input_tokens
635 .saturating_sub(store.total_output_tokens);
636 let pct = if store.total_input_tokens > 0 {
637 input_saved as f64 / store.total_input_tokens as f64 * 100.0
638 } else {
639 0.0
640 };
641 println!("Commands: {}", store.total_commands);
642 println!("Input: {} tokens", store.total_input_tokens);
643 println!("Output: {} tokens", store.total_output_tokens);
644 println!("Saved: {} tokens ({:.1}%)", input_saved, pct);
645 println!();
646 println!("CEP sessions: {}", store.cep.sessions);
647 println!(
648 "CEP tokens: {} → {}",
649 store.cep.total_tokens_original, store.cep.total_tokens_compressed
650 );
651 println!();
652 println!("Subcommands: stats reset-cep | stats json");
653 }
654 }
655}
656
657pub fn cmd_cache(args: &[String]) {
658 use crate::core::cli_cache;
659 match args.first().map(|s| s.as_str()) {
660 Some("clear") => {
661 let count = cli_cache::clear();
662 println!("Cleared {count} cached entries.");
663 }
664 Some("reset") => {
665 let project_flag = args.get(1).map(|s| s.as_str()) == Some("--project");
666 if project_flag {
667 let root =
668 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
669 match root {
670 Some(root) => {
671 let count = cli_cache::clear_project(&root);
672 println!("Reset {count} cache entries for project: {root}");
673 }
674 None => {
675 eprintln!("No active project root found. Start a session first.");
676 std::process::exit(1);
677 }
678 }
679 } else {
680 let count = cli_cache::clear();
681 println!("Reset all {count} cache entries.");
682 }
683 }
684 Some("stats") => {
685 let (hits, reads, entries) = cli_cache::stats();
686 let rate = if reads > 0 {
687 (hits as f64 / reads as f64 * 100.0).round() as u32
688 } else {
689 0
690 };
691 println!("CLI Cache Stats:");
692 println!(" Entries: {entries}");
693 println!(" Reads: {reads}");
694 println!(" Hits: {hits}");
695 println!(" Hit Rate: {rate}%");
696 }
697 Some("invalidate") => {
698 if args.len() < 2 {
699 eprintln!("Usage: lean-ctx cache invalidate <path>");
700 std::process::exit(1);
701 }
702 cli_cache::invalidate(&args[1]);
703 println!("Invalidated cache for {}", args[1]);
704 }
705 _ => {
706 let (hits, reads, entries) = cli_cache::stats();
707 let rate = if reads > 0 {
708 (hits as f64 / reads as f64 * 100.0).round() as u32
709 } else {
710 0
711 };
712 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
713 println!();
714 println!("Subcommands:");
715 println!(" cache stats Show detailed stats");
716 println!(" cache clear Clear all cached entries");
717 println!(" cache reset Reset all cache (or --project for current project only)");
718 println!(" cache invalidate Remove specific file from cache");
719 }
720 }
721}
722
723pub fn cmd_config(args: &[String]) {
724 let cfg = config::Config::load();
725
726 if args.is_empty() {
727 println!("{}", cfg.show());
728 return;
729 }
730
731 match args[0].as_str() {
732 "init" | "create" => {
733 let default = config::Config::default();
734 match default.save() {
735 Ok(()) => {
736 let path = config::Config::path()
737 .map(|p| p.to_string_lossy().to_string())
738 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
739 println!("Created default config at {path}");
740 }
741 Err(e) => eprintln!("Error: {e}"),
742 }
743 }
744 "set" => {
745 if args.len() < 3 {
746 eprintln!("Usage: lean-ctx config set <key> <value>");
747 std::process::exit(1);
748 }
749 let mut cfg = cfg;
750 let key = &args[1];
751 let val = &args[2];
752 match key.as_str() {
753 "ultra_compact" => cfg.ultra_compact = val == "true",
754 "tee_on_error" | "tee_mode" => {
755 cfg.tee_mode = match val.as_str() {
756 "true" | "failures" => config::TeeMode::Failures,
757 "always" => config::TeeMode::Always,
758 "false" | "never" => config::TeeMode::Never,
759 _ => {
760 eprintln!("Valid tee_mode values: always, failures, never");
761 std::process::exit(1);
762 }
763 };
764 }
765 "checkpoint_interval" => {
766 cfg.checkpoint_interval = val.parse().unwrap_or(15);
767 }
768 "theme" => {
769 if theme::from_preset(val).is_some() || val == "custom" {
770 cfg.theme = val.to_string();
771 } else {
772 eprintln!(
773 "Unknown theme '{val}'. Available: {}",
774 theme::PRESET_NAMES.join(", ")
775 );
776 std::process::exit(1);
777 }
778 }
779 "slow_command_threshold_ms" => {
780 cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
781 }
782 "passthrough_urls" => {
783 cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
784 }
785 "excluded_commands" => {
786 cfg.excluded_commands = val
787 .split(',')
788 .map(|s| s.trim().to_string())
789 .filter(|s| !s.is_empty())
790 .collect();
791 }
792 "rules_scope" => match val.as_str() {
793 "global" | "project" | "both" => {
794 cfg.rules_scope = Some(val.to_string());
795 }
796 _ => {
797 eprintln!("Valid rules_scope values: global, project, both");
798 std::process::exit(1);
799 }
800 },
801 _ => {
802 eprintln!("Unknown config key: {key}");
803 std::process::exit(1);
804 }
805 }
806 match cfg.save() {
807 Ok(()) => println!("Updated {key} = {val}"),
808 Err(e) => eprintln!("Error saving config: {e}"),
809 }
810 }
811 _ => {
812 eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
813 std::process::exit(1);
814 }
815 }
816}
817
818pub fn cmd_cheatsheet() {
819 let ver = env!("CARGO_PKG_VERSION");
820 let ver_pad = format!("v{ver}");
821 let header = format!(
822 "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
823\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
824\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m");
825 println!(
826 "{header}
827
828\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
829 ctx_session load \x1b[2m# restore previous session\x1b[0m
830 ctx_overview task=\"...\" \x1b[2m# task-aware file map\x1b[0m
831 ctx_graph action=build \x1b[2m# index project (first time)\x1b[0m
832 ctx_knowledge action=recall \x1b[2m# check stored project facts\x1b[0m
833
834\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
835 ctx_read mode=full \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
836 ctx_read mode=map \x1b[2m# context-only files (~93% saved)\x1b[0m
837 ctx_read mode=diff \x1b[2m# after editing (~98% saved)\x1b[0m
838 ctx_read mode=sigs \x1b[2m# API surface of large files (~95%)\x1b[0m
839 ctx_multi_read \x1b[2m# read multiple files at once\x1b[0m
840 ctx_search \x1b[2m# search with compressed results (~70%)\x1b[0m
841 ctx_shell \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
842
843\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
844 ctx_session finding \"...\" \x1b[2m# record what you discovered\x1b[0m
845 ctx_session decision \"...\" \x1b[2m# record architectural choices\x1b[0m
846 ctx_knowledge action=remember \x1b[2m# store permanent project facts\x1b[0m
847 ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
848 ctx_metrics \x1b[2m# see session statistics\x1b[0m
849
850\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
851 ctx_agent action=register \x1b[2m# announce yourself\x1b[0m
852 ctx_agent action=list \x1b[2m# see other active agents\x1b[0m
853 ctx_agent action=post \x1b[2m# share findings\x1b[0m
854 ctx_agent action=read \x1b[2m# check messages\x1b[0m
855
856\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
857 Will edit? → \x1b[1mfull\x1b[0m (re-reads: 13 tokens) → after edit: \x1b[1mdiff\x1b[0m
858 API only? → \x1b[1msignatures\x1b[0m
859 Deps/exports? → \x1b[1mmap\x1b[0m
860 Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
861 Browsing? → \x1b[1maggressive\x1b[0m (syntax stripped)
862
863\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
864 lean-ctx gain \x1b[2m# visual savings dashboard\x1b[0m
865 lean-ctx gain --live \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
866 lean-ctx dashboard \x1b[2m# web dashboard with charts\x1b[0m
867 lean-ctx wrapped \x1b[2m# weekly savings report\x1b[0m
868 lean-ctx discover \x1b[2m# find uncompressed commands\x1b[0m
869 lean-ctx doctor \x1b[2m# diagnose installation\x1b[0m
870 lean-ctx update \x1b[2m# self-update to latest\x1b[0m
871
872\x1b[2m Full guide: https://leanctx.com/docs/workflow\x1b[0m"
873 );
874}
875
876pub fn cmd_terse(args: &[String]) {
877 use crate::core::config::{Config, TerseAgent};
878
879 let action = args.first().map(|s| s.as_str());
880 match action {
881 Some("off" | "lite" | "full" | "ultra") => {
882 let level = action.unwrap();
883 let mut cfg = Config::load();
884 cfg.terse_agent = match level {
885 "lite" => TerseAgent::Lite,
886 "full" => TerseAgent::Full,
887 "ultra" => TerseAgent::Ultra,
888 _ => TerseAgent::Off,
889 };
890 if let Err(e) = cfg.save() {
891 eprintln!("Error saving config: {e}");
892 std::process::exit(1);
893 }
894 let desc = match level {
895 "lite" => "concise responses, bullet points over paragraphs",
896 "full" => "maximum density, diff-only code, 1-sentence explanations",
897 "ultra" => "expert pair-programmer mode, minimal narration",
898 _ => "normal verbose output",
899 };
900 println!("Terse agent mode: {level} ({desc})");
901 println!("Restart your agent/IDE for changes to take effect.");
902 }
903 _ => {
904 let cfg = Config::load();
905 let effective = TerseAgent::effective(&cfg.terse_agent);
906 let name = match &effective {
907 TerseAgent::Off => "off",
908 TerseAgent::Lite => "lite",
909 TerseAgent::Full => "full",
910 TerseAgent::Ultra => "ultra",
911 };
912 println!("Terse agent mode: {name}");
913 println!();
914 println!("Usage: lean-ctx terse <off|lite|full|ultra>");
915 println!(" off — Normal verbose output (default)");
916 println!(" lite — Concise: bullet points, skip narration");
917 println!(" full — Dense: diff-only, 1-sentence max");
918 println!(" ultra — Expert: minimal narration, code speaks");
919 println!();
920 println!("Override per session: LEAN_CTX_TERSE_AGENT=full");
921 println!("Override per project: terse_agent = \"full\" in .lean-ctx.toml");
922 }
923 }
924}
925
926pub fn cmd_slow_log(args: &[String]) {
927 use crate::core::slow_log;
928
929 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
930 match action {
931 "list" | "ls" | "" => println!("{}", slow_log::list()),
932 "clear" | "purge" => println!("{}", slow_log::clear()),
933 _ => {
934 eprintln!("Usage: lean-ctx slow-log [list|clear]");
935 std::process::exit(1);
936 }
937 }
938}
939
940pub fn cmd_tee(args: &[String]) {
941 let tee_dir = match dirs::home_dir() {
942 Some(h) => h.join(".lean-ctx").join("tee"),
943 None => {
944 eprintln!("Cannot determine home directory");
945 std::process::exit(1);
946 }
947 };
948
949 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
950 match action {
951 "list" | "ls" => {
952 if !tee_dir.exists() {
953 println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
954 return;
955 }
956 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
957 .unwrap_or_else(|e| {
958 eprintln!("Error: {e}");
959 std::process::exit(1);
960 })
961 .filter_map(|e| e.ok())
962 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
963 .collect();
964 entries.sort_by_key(|e| e.file_name());
965
966 if entries.is_empty() {
967 println!("No tee logs found.");
968 return;
969 }
970
971 println!("Tee logs ({}):\n", entries.len());
972 for entry in &entries {
973 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
974 let name = entry.file_name();
975 let size_str = if size > 1024 {
976 format!("{}K", size / 1024)
977 } else {
978 format!("{}B", size)
979 };
980 println!(" {:<60} {}", name.to_string_lossy(), size_str);
981 }
982 println!("\nUse 'lean-ctx tee clear' to delete all logs.");
983 }
984 "clear" | "purge" => {
985 if !tee_dir.exists() {
986 println!("No tee logs to clear.");
987 return;
988 }
989 let mut count = 0u32;
990 if let Ok(entries) = std::fs::read_dir(&tee_dir) {
991 for entry in entries.flatten() {
992 if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
993 && std::fs::remove_file(entry.path()).is_ok()
994 {
995 count += 1;
996 }
997 }
998 }
999 println!("Cleared {count} tee log(s) from {}", tee_dir.display());
1000 }
1001 "show" => {
1002 let filename = args.get(1);
1003 if filename.is_none() {
1004 eprintln!("Usage: lean-ctx tee show <filename>");
1005 std::process::exit(1);
1006 }
1007 let path = tee_dir.join(filename.unwrap());
1008 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
1009 Ok(content) => print!("{content}"),
1010 Err(e) => {
1011 eprintln!("Error reading {}: {e}", path.display());
1012 std::process::exit(1);
1013 }
1014 }
1015 }
1016 "last" => {
1017 if !tee_dir.exists() {
1018 println!("No tee logs found.");
1019 return;
1020 }
1021 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
1022 .ok()
1023 .into_iter()
1024 .flat_map(|d| d.filter_map(|e| e.ok()))
1025 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
1026 .collect();
1027 entries.sort_by_key(|e| {
1028 e.metadata()
1029 .and_then(|m| m.modified())
1030 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
1031 });
1032 match entries.last() {
1033 Some(entry) => {
1034 let path = entry.path();
1035 println!(
1036 "--- {} ---\n",
1037 path.file_name().unwrap_or_default().to_string_lossy()
1038 );
1039 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
1040 Ok(content) => print!("{content}"),
1041 Err(e) => eprintln!("Error: {e}"),
1042 }
1043 }
1044 None => println!("No tee logs found."),
1045 }
1046 }
1047 _ => {
1048 eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
1049 std::process::exit(1);
1050 }
1051 }
1052}
1053
1054pub fn cmd_filter(args: &[String]) {
1055 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
1056 match action {
1057 "list" | "ls" => match crate::core::filters::FilterEngine::load() {
1058 Some(engine) => {
1059 let rules = engine.list_rules();
1060 println!("Loaded {} filter rule(s):\n", rules.len());
1061 for rule in &rules {
1062 println!("{rule}");
1063 }
1064 }
1065 None => {
1066 println!("No custom filters found.");
1067 println!("Create one: lean-ctx filter init");
1068 }
1069 },
1070 "validate" => {
1071 let path = args.get(1);
1072 if path.is_none() {
1073 eprintln!("Usage: lean-ctx filter validate <file.toml>");
1074 std::process::exit(1);
1075 }
1076 match crate::core::filters::validate_filter_file(path.unwrap()) {
1077 Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
1078 Err(e) => {
1079 eprintln!("Validation failed: {e}");
1080 std::process::exit(1);
1081 }
1082 }
1083 }
1084 "init" => match crate::core::filters::create_example_filter() {
1085 Ok(path) => {
1086 println!("Created example filter: {path}");
1087 println!("Edit it to add your custom compression rules.");
1088 }
1089 Err(e) => {
1090 eprintln!("{e}");
1091 std::process::exit(1);
1092 }
1093 },
1094 _ => {
1095 eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
1096 std::process::exit(1);
1097 }
1098 }
1099}
1100
1101fn quiet_enabled() -> bool {
1102 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
1103}
1104
1105macro_rules! qprintln {
1106 ($($t:tt)*) => {
1107 if !quiet_enabled() {
1108 println!($($t)*);
1109 }
1110 };
1111}
1112
1113pub fn cmd_init(args: &[String]) {
1114 let global = args.iter().any(|a| a == "--global" || a == "-g");
1115 let dry_run = args.iter().any(|a| a == "--dry-run");
1116 let no_hook = args.iter().any(|a| a == "--no-shell-hook")
1117 || crate::core::config::Config::load().shell_hook_disabled_effective();
1118
1119 let agents: Vec<&str> = args
1120 .windows(2)
1121 .filter(|w| w[0] == "--agent")
1122 .map(|w| w[1].as_str())
1123 .collect();
1124
1125 if !agents.is_empty() {
1126 for agent_name in &agents {
1127 crate::hooks::install_agent_hook(agent_name, global);
1128 if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
1129 eprintln!("MCP config for '{agent_name}' not updated: {e}");
1130 }
1131 }
1132 if !global {
1133 crate::hooks::install_project_rules();
1134 }
1135 qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
1136 return;
1137 }
1138
1139 let eval_shell = args
1140 .iter()
1141 .find(|a| matches!(a.as_str(), "bash" | "zsh" | "fish" | "powershell" | "pwsh"));
1142 if let Some(shell) = eval_shell {
1143 if !global {
1144 shell_init::print_hook_stdout(shell);
1145 return;
1146 }
1147 }
1148
1149 let shell_name = std::env::var("SHELL").unwrap_or_default();
1150 let is_zsh = shell_name.contains("zsh");
1151 let is_fish = shell_name.contains("fish");
1152 let is_powershell = cfg!(windows) && shell_name.is_empty();
1153
1154 let binary = crate::core::portable_binary::resolve_portable_binary();
1155
1156 if dry_run {
1157 let rc = if is_powershell {
1158 "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
1159 } else if is_fish {
1160 "~/.config/fish/config.fish".to_string()
1161 } else if is_zsh {
1162 "~/.zshrc".to_string()
1163 } else {
1164 "~/.bashrc".to_string()
1165 };
1166 qprintln!("\nlean-ctx init --dry-run\n");
1167 qprintln!(" Would modify: {rc}");
1168 qprintln!(" Would backup: {rc}.lean-ctx.bak");
1169 qprintln!(" Would alias: git npm pnpm yarn cargo docker docker-compose kubectl");
1170 qprintln!(" gh pip pip3 ruff go golangci-lint eslint prettier tsc");
1171 qprintln!(" curl wget php composer (24 commands + k)");
1172 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1173 .map(|p| p.to_string_lossy().to_string())
1174 .unwrap_or_else(|_| "~/.config/lean-ctx/".to_string());
1175 qprintln!(" Would create: {data_dir}");
1176 qprintln!(" Binary: {binary}");
1177 qprintln!("\n Safety: aliases auto-fallback to original command if lean-ctx is removed.");
1178 qprintln!("\n Run without --dry-run to apply.");
1179 return;
1180 }
1181
1182 if no_hook {
1183 qprintln!("Shell hook disabled (--no-shell-hook or shell_hook_disabled config).");
1184 qprintln!("MCP tools remain active. Set LEAN_CTX_NO_HOOK=1 to disable at runtime.");
1185 } else if is_powershell {
1186 init_powershell(&binary);
1187 } else {
1188 let bash_binary = to_bash_compatible_path(&binary);
1189 if is_fish {
1190 init_fish(&bash_binary);
1191 } else {
1192 init_posix(is_zsh, &bash_binary);
1193 }
1194 }
1195
1196 if let Ok(lean_dir) = crate::core::data_dir::lean_ctx_data_dir() {
1197 if !lean_dir.exists() {
1198 let _ = std::fs::create_dir_all(&lean_dir);
1199 qprintln!("Created {}", lean_dir.display());
1200 }
1201 }
1202
1203 let rc = if is_powershell {
1204 "$PROFILE"
1205 } else if is_fish {
1206 "config.fish"
1207 } else if is_zsh {
1208 ".zshrc"
1209 } else {
1210 ".bashrc"
1211 };
1212
1213 qprintln!("\nlean-ctx init complete (24 aliases installed)");
1214 qprintln!();
1215 qprintln!(" Disable temporarily: lean-ctx-off");
1216 qprintln!(" Re-enable: lean-ctx-on");
1217 qprintln!(" Check status: lean-ctx-status");
1218 qprintln!(" Full uninstall: lean-ctx uninstall");
1219 qprintln!(" Diagnose issues: lean-ctx doctor");
1220 qprintln!(" Preview changes: lean-ctx init --global --dry-run");
1221 qprintln!();
1222 if is_powershell {
1223 qprintln!(" Restart PowerShell or run: . {rc}");
1224 } else {
1225 qprintln!(" Restart your shell or run: source ~/{rc}");
1226 }
1227 qprintln!();
1228 qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1229 qprintln!(" Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1230 qprintln!(" crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1231 qprintln!(" pi, qwen, roo, sublime, trae, verdent, windsurf");
1232}
1233
1234pub fn cmd_init_quiet(args: &[String]) {
1235 std::env::set_var("LEAN_CTX_QUIET", "1");
1236 cmd_init(args);
1237 std::env::remove_var("LEAN_CTX_QUIET");
1238}
1239
1240pub fn load_shell_history_pub() -> Vec<String> {
1241 load_shell_history()
1242}
1243
1244fn load_shell_history() -> Vec<String> {
1245 let shell = std::env::var("SHELL").unwrap_or_default();
1246 let home = match dirs::home_dir() {
1247 Some(h) => h,
1248 None => return Vec::new(),
1249 };
1250
1251 let history_file = if shell.contains("zsh") {
1252 home.join(".zsh_history")
1253 } else if shell.contains("fish") {
1254 home.join(".local/share/fish/fish_history")
1255 } else if cfg!(windows) && shell.is_empty() {
1256 home.join("AppData")
1257 .join("Roaming")
1258 .join("Microsoft")
1259 .join("Windows")
1260 .join("PowerShell")
1261 .join("PSReadLine")
1262 .join("ConsoleHost_history.txt")
1263 } else {
1264 home.join(".bash_history")
1265 };
1266
1267 match std::fs::read_to_string(&history_file) {
1268 Ok(content) => content
1269 .lines()
1270 .filter_map(|l| {
1271 let trimmed = l.trim();
1272 if trimmed.starts_with(':') {
1273 trimmed.split(';').nth(1).map(|s| s.to_string())
1274 } else {
1275 Some(trimmed.to_string())
1276 }
1277 })
1278 .filter(|l| !l.is_empty())
1279 .collect(),
1280 Err(_) => Vec::new(),
1281 }
1282}
1283
1284fn print_savings(original: usize, sent: usize) {
1285 let saved = original.saturating_sub(sent);
1286 if original > 0 && saved > 0 {
1287 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1288 println!("[{saved} tok saved ({pct}%)]");
1289 }
1290}
1291
1292pub fn cmd_theme(args: &[String]) {
1293 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1294 let r = theme::rst();
1295 let b = theme::bold();
1296 let d = theme::dim();
1297
1298 match sub {
1299 "list" => {
1300 let cfg = config::Config::load();
1301 let active = cfg.theme.as_str();
1302 println!();
1303 println!(" {b}Available themes:{r}");
1304 println!(" {ln}", ln = "─".repeat(40));
1305 for name in theme::PRESET_NAMES {
1306 let marker = if *name == active { " ◀ active" } else { "" };
1307 let t = theme::from_preset(name).unwrap();
1308 let preview = format!(
1309 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1310 p = t.primary.fg(),
1311 s = t.secondary.fg(),
1312 a = t.accent.fg(),
1313 sc = t.success.fg(),
1314 w = t.warning.fg(),
1315 );
1316 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1317 }
1318 if let Some(path) = theme::theme_file_path() {
1319 if path.exists() {
1320 let custom = theme::load_theme("_custom_");
1321 let preview = format!(
1322 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1323 p = custom.primary.fg(),
1324 s = custom.secondary.fg(),
1325 a = custom.accent.fg(),
1326 sc = custom.success.fg(),
1327 w = custom.warning.fg(),
1328 );
1329 let marker = if active == "custom" {
1330 " ◀ active"
1331 } else {
1332 ""
1333 };
1334 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1335 }
1336 }
1337 println!();
1338 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1339 println!();
1340 }
1341 "set" => {
1342 if args.len() < 2 {
1343 eprintln!("Usage: lean-ctx theme set <name>");
1344 std::process::exit(1);
1345 }
1346 let name = &args[1];
1347 if theme::from_preset(name).is_none() && name != "custom" {
1348 eprintln!(
1349 "Unknown theme '{name}'. Available: {}",
1350 theme::PRESET_NAMES.join(", ")
1351 );
1352 std::process::exit(1);
1353 }
1354 let mut cfg = config::Config::load();
1355 cfg.theme = name.to_string();
1356 match cfg.save() {
1357 Ok(()) => {
1358 let t = theme::load_theme(name);
1359 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1360 let preview = t.gradient_bar(0.75, 30);
1361 println!(" {preview}");
1362 }
1363 Err(e) => eprintln!("Error: {e}"),
1364 }
1365 }
1366 "export" => {
1367 let cfg = config::Config::load();
1368 let t = theme::load_theme(&cfg.theme);
1369 println!("{}", t.to_toml());
1370 }
1371 "import" => {
1372 if args.len() < 2 {
1373 eprintln!("Usage: lean-ctx theme import <path>");
1374 std::process::exit(1);
1375 }
1376 let path = std::path::Path::new(&args[1]);
1377 if !path.exists() {
1378 eprintln!("File not found: {}", args[1]);
1379 std::process::exit(1);
1380 }
1381 match std::fs::read_to_string(path) {
1382 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1383 Ok(imported) => match theme::save_theme(&imported) {
1384 Ok(()) => {
1385 let mut cfg = config::Config::load();
1386 cfg.theme = "custom".to_string();
1387 let _ = cfg.save();
1388 println!(
1389 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1390 sc = imported.success.fg(),
1391 name = imported.name,
1392 );
1393 println!(" Config updated: theme = custom");
1394 }
1395 Err(e) => eprintln!("Error saving theme: {e}"),
1396 },
1397 Err(e) => eprintln!("Invalid theme file: {e}"),
1398 },
1399 Err(e) => eprintln!("Error reading file: {e}"),
1400 }
1401 }
1402 "preview" => {
1403 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1404 let t = match theme::from_preset(name) {
1405 Some(t) => t,
1406 None => {
1407 eprintln!("Unknown theme: {name}");
1408 std::process::exit(1);
1409 }
1410 };
1411 println!();
1412 println!(
1413 " {icon} {title} {d}Theme Preview: {name}{r}",
1414 icon = t.header_icon(),
1415 title = t.brand_title(),
1416 );
1417 println!(" {ln}", ln = t.border_line(50));
1418 println!();
1419 println!(
1420 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1421 sc = t.success.fg(),
1422 sec = t.secondary.fg(),
1423 wrn = t.warning.fg(),
1424 acc = t.accent.fg(),
1425 );
1426 println!(" {d} tokens saved compression commands USD saved{r}");
1427 println!();
1428 println!(
1429 " {b}{txt}Gradient Bar{r} {bar}",
1430 txt = t.text.fg(),
1431 bar = t.gradient_bar(0.85, 30),
1432 );
1433 println!(
1434 " {b}{txt}Sparkline{r} {spark}",
1435 txt = t.text.fg(),
1436 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1437 );
1438 println!();
1439 println!(" {top}", top = t.box_top(50));
1440 println!(
1441 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1442 side = t.box_side(),
1443 side_r = t.box_side(),
1444 txt = t.text.fg(),
1445 );
1446 println!(" {bot}", bot = t.box_bottom(50));
1447 println!();
1448 }
1449 _ => {
1450 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1451 std::process::exit(1);
1452 }
1453 }
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458 use super::*;
1459 use tempfile;
1460
1461 #[test]
1462 fn test_remove_lean_ctx_block_posix() {
1463 let input = r#"# existing config
1464export PATH="$HOME/bin:$PATH"
1465
1466# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1467if [ -z "$LEAN_CTX_ACTIVE" ]; then
1468alias git='lean-ctx -c git'
1469alias npm='lean-ctx -c npm'
1470fi
1471
1472# other stuff
1473export EDITOR=vim
1474"#;
1475 let result = remove_lean_ctx_block(input);
1476 assert!(!result.contains("lean-ctx"), "block should be removed");
1477 assert!(result.contains("export PATH"), "other content preserved");
1478 assert!(
1479 result.contains("export EDITOR"),
1480 "trailing content preserved"
1481 );
1482 }
1483
1484 #[test]
1485 fn test_remove_lean_ctx_block_fish() {
1486 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";
1487 let result = remove_lean_ctx_block(input);
1488 assert!(!result.contains("lean-ctx"), "block should be removed");
1489 assert!(result.contains("set -x FOO"), "other content preserved");
1490 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1491 }
1492
1493 #[test]
1494 fn test_remove_lean_ctx_block_ps() {
1495 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";
1496 let result = remove_lean_ctx_block_ps(input);
1497 assert!(
1498 !result.contains("lean-ctx shell hook"),
1499 "block should be removed"
1500 );
1501 assert!(result.contains("$env:FOO"), "other content preserved");
1502 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1503 }
1504
1505 #[test]
1506 fn test_remove_lean_ctx_block_ps_nested() {
1507 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";
1508 let result = remove_lean_ctx_block_ps(input);
1509 assert!(
1510 !result.contains("lean-ctx shell hook"),
1511 "block should be removed"
1512 );
1513 assert!(!result.contains("_lc"), "function should be removed");
1514 assert!(result.contains("$env:FOO"), "other content preserved");
1515 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1516 }
1517
1518 #[test]
1519 fn test_remove_block_no_lean_ctx() {
1520 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1521 let result = remove_lean_ctx_block(input);
1522 assert!(result.contains("export PATH"), "content unchanged");
1523 }
1524
1525 #[test]
1526 fn test_bash_hook_contains_pipe_guard() {
1527 let binary = "/usr/local/bin/lean-ctx";
1528 let hook = format!(
1529 r#"_lc() {{
1530 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1531 command "$@"
1532 return
1533 fi
1534 '{binary}' -t "$@"
1535}}"#
1536 );
1537 assert!(
1538 hook.contains("! -t 1"),
1539 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1540 );
1541 assert!(
1542 hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1543 "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1544 );
1545 }
1546
1547 #[test]
1548 fn test_lc_uses_track_mode_by_default() {
1549 let binary = "/usr/local/bin/lean-ctx";
1550 let alias_list = crate::rewrite_registry::shell_alias_list();
1551 let aliases = format!(
1552 r#"_lc() {{
1553 '{binary}' -t "$@"
1554}}
1555_lc_compress() {{
1556 '{binary}' -c "$@"
1557}}"#
1558 );
1559 assert!(
1560 aliases.contains("-t \"$@\""),
1561 "_lc must use -t (track mode) by default"
1562 );
1563 assert!(
1564 aliases.contains("-c \"$@\""),
1565 "_lc_compress must use -c (compress mode)"
1566 );
1567 let _ = alias_list;
1568 }
1569
1570 #[test]
1571 fn test_posix_shell_has_lean_ctx_mode() {
1572 let alias_list = crate::rewrite_registry::shell_alias_list();
1573 let aliases = r#"
1574lean-ctx-mode() {{
1575 case "${{1:-}}" in
1576 compress) echo compress ;;
1577 track) echo track ;;
1578 off) echo off ;;
1579 esac
1580}}
1581"#
1582 .to_string();
1583 assert!(
1584 aliases.contains("lean-ctx-mode()"),
1585 "lean-ctx-mode function must exist"
1586 );
1587 assert!(
1588 aliases.contains("compress"),
1589 "compress mode must be available"
1590 );
1591 assert!(aliases.contains("track"), "track mode must be available");
1592 let _ = alias_list;
1593 }
1594
1595 #[test]
1596 fn test_fish_hook_contains_pipe_guard() {
1597 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";
1598 assert!(
1599 hook.contains("isatty stdout"),
1600 "fish hook must contain pipe guard (isatty stdout)"
1601 );
1602 }
1603
1604 #[test]
1605 fn test_powershell_hook_contains_pipe_guard() {
1606 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1607 assert!(
1608 hook.contains("IsOutputRedirected"),
1609 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1610 );
1611 }
1612
1613 #[test]
1614 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1615 let input = r#"# existing config
1616export PATH="$HOME/bin:$PATH"
1617
1618# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1619_lean_ctx_cmds=(git npm pnpm)
1620
1621lean-ctx-on() {
1622 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1623 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1624 done
1625 export LEAN_CTX_ENABLED=1
1626 [ -t 1 ] && echo "lean-ctx: ON"
1627}
1628
1629lean-ctx-off() {
1630 unset LEAN_CTX_ENABLED
1631 [ -t 1 ] && echo "lean-ctx: OFF"
1632}
1633
1634if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1635 lean-ctx-on
1636fi
1637# lean-ctx shell hook — end
1638
1639# other stuff
1640export EDITOR=vim
1641"#;
1642 let result = remove_lean_ctx_block(input);
1643 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1644 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1645 assert!(result.contains("export PATH"), "other content preserved");
1646 assert!(
1647 result.contains("export EDITOR"),
1648 "trailing content preserved"
1649 );
1650 }
1651
1652 #[test]
1653 fn env_sh_for_containers_includes_self_heal() {
1654 let _g = crate::core::data_dir::test_env_lock();
1655 let tmp = tempfile::tempdir().expect("tempdir");
1656 let data_dir = tmp.path().join("data");
1657 std::fs::create_dir_all(&data_dir).expect("mkdir data");
1658 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1659
1660 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1661 let env_sh = data_dir.join("env.sh");
1662 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1663 assert!(content.contains("lean-ctx docker self-heal"));
1664 assert!(content.contains("claude mcp list"));
1665 assert!(content.contains("lean-ctx init --agent claude"));
1666
1667 std::env::remove_var("LEAN_CTX_DATA_DIR");
1668 }
1669}