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_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
1117 let agents: Vec<&str> = args
1118 .windows(2)
1119 .filter(|w| w[0] == "--agent")
1120 .map(|w| w[1].as_str())
1121 .collect();
1122
1123 if !agents.is_empty() {
1124 for agent_name in &agents {
1125 crate::hooks::install_agent_hook(agent_name, global);
1126 if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
1127 eprintln!("MCP config for '{agent_name}' not updated: {e}");
1128 }
1129 }
1130 if !global {
1131 crate::hooks::install_project_rules();
1132 }
1133 qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
1134 return;
1135 }
1136
1137 let eval_shell = args
1138 .iter()
1139 .find(|a| matches!(a.as_str(), "bash" | "zsh" | "fish" | "powershell" | "pwsh"));
1140 if let Some(shell) = eval_shell {
1141 if !global {
1142 shell_init::print_hook_stdout(shell);
1143 return;
1144 }
1145 }
1146
1147 let shell_name = std::env::var("SHELL").unwrap_or_default();
1148 let is_zsh = shell_name.contains("zsh");
1149 let is_fish = shell_name.contains("fish");
1150 let is_powershell = cfg!(windows) && shell_name.is_empty();
1151
1152 let binary = crate::core::portable_binary::resolve_portable_binary();
1153
1154 if dry_run {
1155 let rc = if is_powershell {
1156 "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
1157 } else if is_fish {
1158 "~/.config/fish/config.fish".to_string()
1159 } else if is_zsh {
1160 "~/.zshrc".to_string()
1161 } else {
1162 "~/.bashrc".to_string()
1163 };
1164 qprintln!("\nlean-ctx init --dry-run\n");
1165 qprintln!(" Would modify: {rc}");
1166 qprintln!(" Would backup: {rc}.lean-ctx.bak");
1167 qprintln!(" Would alias: git npm pnpm yarn cargo docker docker-compose kubectl");
1168 qprintln!(" gh pip pip3 ruff go golangci-lint eslint prettier tsc");
1169 qprintln!(" curl wget php composer (24 commands + k)");
1170 qprintln!(" Would create: ~/.lean-ctx/");
1171 qprintln!(" Binary: {binary}");
1172 qprintln!("\n Safety: aliases auto-fallback to original command if lean-ctx is removed.");
1173 qprintln!("\n Run without --dry-run to apply.");
1174 return;
1175 }
1176
1177 if is_powershell {
1178 init_powershell(&binary);
1179 } else {
1180 let bash_binary = to_bash_compatible_path(&binary);
1181 if is_fish {
1182 init_fish(&bash_binary);
1183 } else {
1184 init_posix(is_zsh, &bash_binary);
1185 }
1186 }
1187
1188 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
1189 if let Some(dir) = lean_dir {
1190 if !dir.exists() {
1191 let _ = std::fs::create_dir_all(&dir);
1192 qprintln!("Created {}", dir.display());
1193 }
1194 }
1195
1196 let rc = if is_powershell {
1197 "$PROFILE"
1198 } else if is_fish {
1199 "config.fish"
1200 } else if is_zsh {
1201 ".zshrc"
1202 } else {
1203 ".bashrc"
1204 };
1205
1206 qprintln!("\nlean-ctx init complete (24 aliases installed)");
1207 qprintln!();
1208 qprintln!(" Disable temporarily: lean-ctx-off");
1209 qprintln!(" Re-enable: lean-ctx-on");
1210 qprintln!(" Check status: lean-ctx-status");
1211 qprintln!(" Full uninstall: lean-ctx uninstall");
1212 qprintln!(" Diagnose issues: lean-ctx doctor");
1213 qprintln!(" Preview changes: lean-ctx init --global --dry-run");
1214 qprintln!();
1215 if is_powershell {
1216 qprintln!(" Restart PowerShell or run: . {rc}");
1217 } else {
1218 qprintln!(" Restart your shell or run: source ~/{rc}");
1219 }
1220 qprintln!();
1221 qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1222 qprintln!(" Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1223 qprintln!(" crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1224 qprintln!(" pi, qwen, roo, sublime, trae, verdent, windsurf");
1225}
1226
1227pub fn cmd_init_quiet(args: &[String]) {
1228 std::env::set_var("LEAN_CTX_QUIET", "1");
1229 cmd_init(args);
1230 std::env::remove_var("LEAN_CTX_QUIET");
1231}
1232
1233pub fn load_shell_history_pub() -> Vec<String> {
1234 load_shell_history()
1235}
1236
1237fn load_shell_history() -> Vec<String> {
1238 let shell = std::env::var("SHELL").unwrap_or_default();
1239 let home = match dirs::home_dir() {
1240 Some(h) => h,
1241 None => return Vec::new(),
1242 };
1243
1244 let history_file = if shell.contains("zsh") {
1245 home.join(".zsh_history")
1246 } else if shell.contains("fish") {
1247 home.join(".local/share/fish/fish_history")
1248 } else if cfg!(windows) && shell.is_empty() {
1249 home.join("AppData")
1250 .join("Roaming")
1251 .join("Microsoft")
1252 .join("Windows")
1253 .join("PowerShell")
1254 .join("PSReadLine")
1255 .join("ConsoleHost_history.txt")
1256 } else {
1257 home.join(".bash_history")
1258 };
1259
1260 match std::fs::read_to_string(&history_file) {
1261 Ok(content) => content
1262 .lines()
1263 .filter_map(|l| {
1264 let trimmed = l.trim();
1265 if trimmed.starts_with(':') {
1266 trimmed.split(';').nth(1).map(|s| s.to_string())
1267 } else {
1268 Some(trimmed.to_string())
1269 }
1270 })
1271 .filter(|l| !l.is_empty())
1272 .collect(),
1273 Err(_) => Vec::new(),
1274 }
1275}
1276
1277fn print_savings(original: usize, sent: usize) {
1278 let saved = original.saturating_sub(sent);
1279 if original > 0 && saved > 0 {
1280 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1281 println!("[{saved} tok saved ({pct}%)]");
1282 }
1283}
1284
1285pub fn cmd_theme(args: &[String]) {
1286 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1287 let r = theme::rst();
1288 let b = theme::bold();
1289 let d = theme::dim();
1290
1291 match sub {
1292 "list" => {
1293 let cfg = config::Config::load();
1294 let active = cfg.theme.as_str();
1295 println!();
1296 println!(" {b}Available themes:{r}");
1297 println!(" {ln}", ln = "─".repeat(40));
1298 for name in theme::PRESET_NAMES {
1299 let marker = if *name == active { " ◀ active" } else { "" };
1300 let t = theme::from_preset(name).unwrap();
1301 let preview = format!(
1302 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1303 p = t.primary.fg(),
1304 s = t.secondary.fg(),
1305 a = t.accent.fg(),
1306 sc = t.success.fg(),
1307 w = t.warning.fg(),
1308 );
1309 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1310 }
1311 if let Some(path) = theme::theme_file_path() {
1312 if path.exists() {
1313 let custom = theme::load_theme("_custom_");
1314 let preview = format!(
1315 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1316 p = custom.primary.fg(),
1317 s = custom.secondary.fg(),
1318 a = custom.accent.fg(),
1319 sc = custom.success.fg(),
1320 w = custom.warning.fg(),
1321 );
1322 let marker = if active == "custom" {
1323 " ◀ active"
1324 } else {
1325 ""
1326 };
1327 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1328 }
1329 }
1330 println!();
1331 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1332 println!();
1333 }
1334 "set" => {
1335 if args.len() < 2 {
1336 eprintln!("Usage: lean-ctx theme set <name>");
1337 std::process::exit(1);
1338 }
1339 let name = &args[1];
1340 if theme::from_preset(name).is_none() && name != "custom" {
1341 eprintln!(
1342 "Unknown theme '{name}'. Available: {}",
1343 theme::PRESET_NAMES.join(", ")
1344 );
1345 std::process::exit(1);
1346 }
1347 let mut cfg = config::Config::load();
1348 cfg.theme = name.to_string();
1349 match cfg.save() {
1350 Ok(()) => {
1351 let t = theme::load_theme(name);
1352 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1353 let preview = t.gradient_bar(0.75, 30);
1354 println!(" {preview}");
1355 }
1356 Err(e) => eprintln!("Error: {e}"),
1357 }
1358 }
1359 "export" => {
1360 let cfg = config::Config::load();
1361 let t = theme::load_theme(&cfg.theme);
1362 println!("{}", t.to_toml());
1363 }
1364 "import" => {
1365 if args.len() < 2 {
1366 eprintln!("Usage: lean-ctx theme import <path>");
1367 std::process::exit(1);
1368 }
1369 let path = std::path::Path::new(&args[1]);
1370 if !path.exists() {
1371 eprintln!("File not found: {}", args[1]);
1372 std::process::exit(1);
1373 }
1374 match std::fs::read_to_string(path) {
1375 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1376 Ok(imported) => match theme::save_theme(&imported) {
1377 Ok(()) => {
1378 let mut cfg = config::Config::load();
1379 cfg.theme = "custom".to_string();
1380 let _ = cfg.save();
1381 println!(
1382 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1383 sc = imported.success.fg(),
1384 name = imported.name,
1385 );
1386 println!(" Config updated: theme = custom");
1387 }
1388 Err(e) => eprintln!("Error saving theme: {e}"),
1389 },
1390 Err(e) => eprintln!("Invalid theme file: {e}"),
1391 },
1392 Err(e) => eprintln!("Error reading file: {e}"),
1393 }
1394 }
1395 "preview" => {
1396 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1397 let t = match theme::from_preset(name) {
1398 Some(t) => t,
1399 None => {
1400 eprintln!("Unknown theme: {name}");
1401 std::process::exit(1);
1402 }
1403 };
1404 println!();
1405 println!(
1406 " {icon} {title} {d}Theme Preview: {name}{r}",
1407 icon = t.header_icon(),
1408 title = t.brand_title(),
1409 );
1410 println!(" {ln}", ln = t.border_line(50));
1411 println!();
1412 println!(
1413 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1414 sc = t.success.fg(),
1415 sec = t.secondary.fg(),
1416 wrn = t.warning.fg(),
1417 acc = t.accent.fg(),
1418 );
1419 println!(" {d} tokens saved compression commands USD saved{r}");
1420 println!();
1421 println!(
1422 " {b}{txt}Gradient Bar{r} {bar}",
1423 txt = t.text.fg(),
1424 bar = t.gradient_bar(0.85, 30),
1425 );
1426 println!(
1427 " {b}{txt}Sparkline{r} {spark}",
1428 txt = t.text.fg(),
1429 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1430 );
1431 println!();
1432 println!(" {top}", top = t.box_top(50));
1433 println!(
1434 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1435 side = t.box_side(),
1436 side_r = t.box_side(),
1437 txt = t.text.fg(),
1438 );
1439 println!(" {bot}", bot = t.box_bottom(50));
1440 println!();
1441 }
1442 _ => {
1443 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1444 std::process::exit(1);
1445 }
1446 }
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451 use super::*;
1452 use tempfile;
1453
1454 #[test]
1455 fn test_remove_lean_ctx_block_posix() {
1456 let input = r#"# existing config
1457export PATH="$HOME/bin:$PATH"
1458
1459# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1460if [ -z "$LEAN_CTX_ACTIVE" ]; then
1461alias git='lean-ctx -c git'
1462alias npm='lean-ctx -c npm'
1463fi
1464
1465# other stuff
1466export EDITOR=vim
1467"#;
1468 let result = remove_lean_ctx_block(input);
1469 assert!(!result.contains("lean-ctx"), "block should be removed");
1470 assert!(result.contains("export PATH"), "other content preserved");
1471 assert!(
1472 result.contains("export EDITOR"),
1473 "trailing content preserved"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_remove_lean_ctx_block_fish() {
1479 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";
1480 let result = remove_lean_ctx_block(input);
1481 assert!(!result.contains("lean-ctx"), "block should be removed");
1482 assert!(result.contains("set -x FOO"), "other content preserved");
1483 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1484 }
1485
1486 #[test]
1487 fn test_remove_lean_ctx_block_ps() {
1488 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";
1489 let result = remove_lean_ctx_block_ps(input);
1490 assert!(
1491 !result.contains("lean-ctx shell hook"),
1492 "block should be removed"
1493 );
1494 assert!(result.contains("$env:FOO"), "other content preserved");
1495 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1496 }
1497
1498 #[test]
1499 fn test_remove_lean_ctx_block_ps_nested() {
1500 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";
1501 let result = remove_lean_ctx_block_ps(input);
1502 assert!(
1503 !result.contains("lean-ctx shell hook"),
1504 "block should be removed"
1505 );
1506 assert!(!result.contains("_lc"), "function should be removed");
1507 assert!(result.contains("$env:FOO"), "other content preserved");
1508 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1509 }
1510
1511 #[test]
1512 fn test_remove_block_no_lean_ctx() {
1513 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1514 let result = remove_lean_ctx_block(input);
1515 assert!(result.contains("export PATH"), "content unchanged");
1516 }
1517
1518 #[test]
1519 fn test_bash_hook_contains_pipe_guard() {
1520 let binary = "/usr/local/bin/lean-ctx";
1521 let hook = format!(
1522 r#"_lc() {{
1523 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1524 command "$@"
1525 return
1526 fi
1527 '{binary}' -t "$@"
1528}}"#
1529 );
1530 assert!(
1531 hook.contains("! -t 1"),
1532 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1533 );
1534 assert!(
1535 hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1536 "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1537 );
1538 }
1539
1540 #[test]
1541 fn test_lc_uses_track_mode_by_default() {
1542 let binary = "/usr/local/bin/lean-ctx";
1543 let alias_list = crate::rewrite_registry::shell_alias_list();
1544 let aliases = format!(
1545 r#"_lc() {{
1546 '{binary}' -t "$@"
1547}}
1548_lc_compress() {{
1549 '{binary}' -c "$@"
1550}}"#
1551 );
1552 assert!(
1553 aliases.contains("-t \"$@\""),
1554 "_lc must use -t (track mode) by default"
1555 );
1556 assert!(
1557 aliases.contains("-c \"$@\""),
1558 "_lc_compress must use -c (compress mode)"
1559 );
1560 let _ = alias_list;
1561 }
1562
1563 #[test]
1564 fn test_posix_shell_has_lean_ctx_mode() {
1565 let alias_list = crate::rewrite_registry::shell_alias_list();
1566 let aliases = r#"
1567lean-ctx-mode() {{
1568 case "${{1:-}}" in
1569 compress) echo compress ;;
1570 track) echo track ;;
1571 off) echo off ;;
1572 esac
1573}}
1574"#
1575 .to_string();
1576 assert!(
1577 aliases.contains("lean-ctx-mode()"),
1578 "lean-ctx-mode function must exist"
1579 );
1580 assert!(
1581 aliases.contains("compress"),
1582 "compress mode must be available"
1583 );
1584 assert!(aliases.contains("track"), "track mode must be available");
1585 let _ = alias_list;
1586 }
1587
1588 #[test]
1589 fn test_fish_hook_contains_pipe_guard() {
1590 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";
1591 assert!(
1592 hook.contains("isatty stdout"),
1593 "fish hook must contain pipe guard (isatty stdout)"
1594 );
1595 }
1596
1597 #[test]
1598 fn test_powershell_hook_contains_pipe_guard() {
1599 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1600 assert!(
1601 hook.contains("IsOutputRedirected"),
1602 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1603 );
1604 }
1605
1606 #[test]
1607 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1608 let input = r#"# existing config
1609export PATH="$HOME/bin:$PATH"
1610
1611# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1612_lean_ctx_cmds=(git npm pnpm)
1613
1614lean-ctx-on() {
1615 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1616 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1617 done
1618 export LEAN_CTX_ENABLED=1
1619 [ -t 1 ] && echo "lean-ctx: ON"
1620}
1621
1622lean-ctx-off() {
1623 unset LEAN_CTX_ENABLED
1624 [ -t 1 ] && echo "lean-ctx: OFF"
1625}
1626
1627if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1628 lean-ctx-on
1629fi
1630# lean-ctx shell hook — end
1631
1632# other stuff
1633export EDITOR=vim
1634"#;
1635 let result = remove_lean_ctx_block(input);
1636 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1637 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1638 assert!(result.contains("export PATH"), "other content preserved");
1639 assert!(
1640 result.contains("export EDITOR"),
1641 "trailing content preserved"
1642 );
1643 }
1644
1645 #[test]
1646 fn env_sh_for_containers_includes_self_heal() {
1647 let _g = crate::core::data_dir::test_env_lock();
1648 let tmp = tempfile::tempdir().expect("tempdir");
1649 let data_dir = tmp.path().join("data");
1650 std::fs::create_dir_all(&data_dir).expect("mkdir data");
1651 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1652
1653 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1654 let env_sh = data_dir.join("env.sh");
1655 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1656 assert!(content.contains("lean-ctx docker self-heal"));
1657 assert!(content.contains("claude mcp list"));
1658 assert!(content.contains("lean-ctx init --agent claude"));
1659
1660 std::env::remove_var("LEAN_CTX_DATA_DIR");
1661 }
1662}