1mod cloud;
2mod dispatch;
3pub mod shell_init;
4
5pub use dispatch::run;
6pub use shell_init::{cmd_init, cmd_init_quiet, init_fish, init_posix, init_powershell};
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;
20
21pub fn cmd_read(args: &[String]) {
22 if args.is_empty() {
23 eprintln!(
24 "Usage: lean-ctx read <file> [--mode full|map|signatures|aggressive|entropy] [--fresh]"
25 );
26 std::process::exit(1);
27 }
28
29 let path = &args[0];
30 let mode = args
31 .iter()
32 .position(|a| a == "--mode" || a == "-m")
33 .and_then(|i| args.get(i + 1))
34 .map(|s| s.as_str())
35 .unwrap_or("full");
36 let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
37
38 let short = protocol::shorten_path(path);
39
40 if !force_fresh && mode == "full" {
41 use crate::core::cli_cache::{self, CacheResult};
42 match cli_cache::check_and_read(path) {
43 CacheResult::Hit { entry, file_ref } => {
44 let msg = cli_cache::format_hit(&entry, &file_ref, &short);
45 println!("{msg}");
46 stats::record("cli_read", entry.original_tokens, msg.len());
47 return;
48 }
49 CacheResult::Miss { content } if content.is_empty() => {
50 eprintln!("Error: could not read {path}");
51 std::process::exit(1);
52 }
53 CacheResult::Miss { content } => {
54 let line_count = content.lines().count();
55 println!("{short} [{line_count}L]");
56 println!("{content}");
57 stats::record("cli_read", count_tokens(&content), count_tokens(&content));
58 return;
59 }
60 }
61 }
62
63 let content = match crate::tools::ctx_read::read_file_lossy(path) {
64 Ok(c) => c,
65 Err(e) => {
66 eprintln!("Error: {e}");
67 std::process::exit(1);
68 }
69 };
70
71 let ext = Path::new(path)
72 .extension()
73 .and_then(|e| e.to_str())
74 .unwrap_or("");
75 let line_count = content.lines().count();
76 let original_tokens = count_tokens(&content);
77
78 let mode = if mode == "auto" {
79 let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
80 let predictor = crate::core::mode_predictor::ModePredictor::new();
81 predictor
82 .predict_best_mode(&sig)
83 .unwrap_or_else(|| "full".to_string())
84 } else {
85 mode.to_string()
86 };
87 let mode = mode.as_str();
88
89 match mode {
90 "map" => {
91 let sigs = signatures::extract_signatures(&content, ext);
92 let dep_info = dep_extract::extract_deps(&content, ext);
93
94 println!("{short} [{line_count}L]");
95 if !dep_info.imports.is_empty() {
96 println!(" deps: {}", dep_info.imports.join(", "));
97 }
98 if !dep_info.exports.is_empty() {
99 println!(" exports: {}", dep_info.exports.join(", "));
100 }
101 let key_sigs: Vec<_> = sigs
102 .iter()
103 .filter(|s| s.is_exported || s.indent == 0)
104 .collect();
105 if !key_sigs.is_empty() {
106 println!(" API:");
107 for sig in &key_sigs {
108 println!(" {}", sig.to_compact());
109 }
110 }
111 let sent = count_tokens(&short.to_string());
112 print_savings(original_tokens, sent);
113 }
114 "signatures" => {
115 let sigs = signatures::extract_signatures(&content, ext);
116 println!("{short} [{line_count}L]");
117 for sig in &sigs {
118 println!("{}", sig.to_compact());
119 }
120 let sent = count_tokens(&short.to_string());
121 print_savings(original_tokens, sent);
122 }
123 "aggressive" => {
124 let compressed = compressor::aggressive_compress(&content, Some(ext));
125 println!("{short} [{line_count}L]");
126 println!("{compressed}");
127 let sent = count_tokens(&compressed);
128 print_savings(original_tokens, sent);
129 }
130 "entropy" => {
131 let result = entropy::entropy_compress(&content);
132 let avg_h = entropy::analyze_entropy(&content).avg_entropy;
133 println!("{short} [{line_count}L] (H̄={avg_h:.1})");
134 for tech in &result.techniques {
135 println!("{tech}");
136 }
137 println!("{}", result.output);
138 let sent = count_tokens(&result.output);
139 print_savings(original_tokens, sent);
140 }
141 _ => {
142 println!("{short} [{line_count}L]");
143 println!("{content}");
144 }
145 }
146}
147
148pub fn cmd_diff(args: &[String]) {
149 if args.len() < 2 {
150 eprintln!("Usage: lean-ctx diff <file1> <file2>");
151 std::process::exit(1);
152 }
153
154 let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
155 Ok(c) => c,
156 Err(e) => {
157 eprintln!("Error reading {}: {e}", args[0]);
158 std::process::exit(1);
159 }
160 };
161
162 let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
163 Ok(c) => c,
164 Err(e) => {
165 eprintln!("Error reading {}: {e}", args[1]);
166 std::process::exit(1);
167 }
168 };
169
170 let diff = compressor::diff_content(&content1, &content2);
171 let original = count_tokens(&content1) + count_tokens(&content2);
172 let sent = count_tokens(&diff);
173
174 println!(
175 "diff {} {}",
176 protocol::shorten_path(&args[0]),
177 protocol::shorten_path(&args[1])
178 );
179 println!("{diff}");
180 print_savings(original, sent);
181}
182
183pub fn cmd_grep(args: &[String]) {
184 if args.is_empty() {
185 eprintln!("Usage: lean-ctx grep <pattern> [path]");
186 std::process::exit(1);
187 }
188
189 let pattern = &args[0];
190 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
191
192 let re = match regex::Regex::new(pattern) {
193 Ok(r) => r,
194 Err(e) => {
195 eprintln!("Invalid regex pattern: {e}");
196 std::process::exit(1);
197 }
198 };
199
200 let mut found = false;
201 for entry in ignore::WalkBuilder::new(path)
202 .hidden(true)
203 .git_ignore(true)
204 .git_global(true)
205 .git_exclude(true)
206 .max_depth(Some(10))
207 .build()
208 .flatten()
209 {
210 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
211 continue;
212 }
213 let file_path = entry.path();
214 if let Ok(content) = std::fs::read_to_string(file_path) {
215 for (i, line) in content.lines().enumerate() {
216 if re.is_match(line) {
217 println!("{}:{}:{}", file_path.display(), i + 1, line);
218 found = true;
219 }
220 }
221 }
222 }
223
224 if !found {
225 std::process::exit(1);
226 }
227}
228
229pub fn cmd_find(args: &[String]) {
230 if args.is_empty() {
231 eprintln!("Usage: lean-ctx find <pattern> [path]");
232 std::process::exit(1);
233 }
234
235 let raw_pattern = &args[0];
236 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
237
238 let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
239 let glob_matcher = if is_glob {
240 glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
241 } else {
242 None
243 };
244 let substring = raw_pattern.to_lowercase();
245
246 let mut found = false;
247 for entry in ignore::WalkBuilder::new(path)
248 .hidden(true)
249 .git_ignore(true)
250 .git_global(true)
251 .git_exclude(true)
252 .max_depth(Some(10))
253 .build()
254 .flatten()
255 {
256 let name = entry.file_name().to_string_lossy().to_lowercase();
257 let matches = if let Some(ref g) = glob_matcher {
258 g.matches(&name)
259 } else {
260 name.contains(&substring)
261 };
262 if matches {
263 println!("{}", entry.path().display());
264 found = true;
265 }
266 }
267
268 if !found {
269 std::process::exit(1);
270 }
271}
272
273pub fn cmd_ls(args: &[String]) {
274 let path = args.first().map(|s| s.as_str()).unwrap_or(".");
275 let command = if cfg!(windows) {
276 format!("dir {}", path.replace('/', "\\"))
277 } else {
278 format!("ls -la {path}")
279 };
280 let code = crate::shell::exec(&command);
281 std::process::exit(code);
282}
283
284pub fn cmd_deps(args: &[String]) {
285 let path = args.first().map(|s| s.as_str()).unwrap_or(".");
286
287 match deps_cmd::detect_and_compress(path) {
288 Some(result) => println!("{result}"),
289 None => {
290 eprintln!("No dependency file found in {path}");
291 std::process::exit(1);
292 }
293 }
294}
295
296pub fn cmd_discover(_args: &[String]) {
297 let history = load_shell_history();
298 if history.is_empty() {
299 println!("No shell history found.");
300 return;
301 }
302
303 let result = crate::tools::ctx_discover::analyze_history(&history, 20);
304 println!("{}", crate::tools::ctx_discover::format_cli_output(&result));
305}
306
307pub fn cmd_session() {
308 let history = load_shell_history();
309 let gain = stats::load_stats();
310
311 let compressible_commands = [
312 "git ",
313 "npm ",
314 "yarn ",
315 "pnpm ",
316 "cargo ",
317 "docker ",
318 "kubectl ",
319 "gh ",
320 "pip ",
321 "pip3 ",
322 "eslint",
323 "prettier",
324 "ruff ",
325 "go ",
326 "golangci-lint",
327 "curl ",
328 "wget ",
329 "grep ",
330 "rg ",
331 "find ",
332 "ls ",
333 ];
334
335 let mut total = 0u32;
336 let mut via_hook = 0u32;
337
338 for line in &history {
339 let cmd = line.trim().to_lowercase();
340 if cmd.starts_with("lean-ctx") {
341 via_hook += 1;
342 total += 1;
343 } else {
344 for p in &compressible_commands {
345 if cmd.starts_with(p) {
346 total += 1;
347 break;
348 }
349 }
350 }
351 }
352
353 let pct = if total > 0 {
354 (via_hook as f64 / total as f64 * 100.0).round() as u32
355 } else {
356 0
357 };
358
359 println!("lean-ctx session statistics\n");
360 println!(
361 "Adoption: {}% ({}/{} compressible commands)",
362 pct, via_hook, total
363 );
364 println!("Saved: {} tokens total", gain.total_saved);
365 println!("Calls: {} compressed", gain.total_calls);
366
367 if total > via_hook {
368 let missed = total - via_hook;
369 let est = missed * 150;
370 println!(
371 "Missed: {} commands (~{} tokens saveable)",
372 missed, est
373 );
374 }
375
376 println!("\nRun 'lean-ctx discover' for details on missed commands.");
377}
378
379pub fn cmd_wrapped(args: &[String]) {
380 let period = if args.iter().any(|a| a == "--month") {
381 "month"
382 } else if args.iter().any(|a| a == "--all") {
383 "all"
384 } else {
385 "week"
386 };
387
388 let report = crate::core::wrapped::WrappedReport::generate(period);
389 println!("{}", report.format_ascii());
390}
391
392pub fn cmd_sessions(args: &[String]) {
393 use crate::core::session::SessionState;
394
395 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
396
397 match action {
398 "list" | "ls" => {
399 let sessions = SessionState::list_sessions();
400 if sessions.is_empty() {
401 println!("No sessions found.");
402 return;
403 }
404 println!("Sessions ({}):\n", sessions.len());
405 for s in sessions.iter().take(20) {
406 let task = s.task.as_deref().unwrap_or("(no task)");
407 let task_short: String = task.chars().take(50).collect();
408 let date = s.updated_at.format("%Y-%m-%d %H:%M");
409 println!(
410 " {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
411 s.id,
412 s.version,
413 s.tool_calls,
414 format_tokens_cli(s.tokens_saved),
415 date,
416 task_short
417 );
418 }
419 if sessions.len() > 20 {
420 println!(" ... +{} more", sessions.len() - 20);
421 }
422 }
423 "show" => {
424 let id = args.get(1);
425 let session = if let Some(id) = id {
426 SessionState::load_by_id(id)
427 } else {
428 SessionState::load_latest()
429 };
430 match session {
431 Some(s) => println!("{}", s.format_compact()),
432 None => println!("Session not found."),
433 }
434 }
435 "cleanup" => {
436 let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
437 let removed = SessionState::cleanup_old_sessions(days);
438 println!("Cleaned up {removed} session(s) older than {days} days.");
439 }
440 _ => {
441 eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
442 std::process::exit(1);
443 }
444 }
445}
446
447pub fn cmd_benchmark(args: &[String]) {
448 use crate::core::benchmark;
449
450 let action = args.first().map(|s| s.as_str()).unwrap_or("run");
451
452 match action {
453 "run" => {
454 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
455 let is_json = args.iter().any(|a| a == "--json");
456
457 let result = benchmark::run_project_benchmark(path);
458 if is_json {
459 println!("{}", benchmark::format_json(&result));
460 } else {
461 println!("{}", benchmark::format_terminal(&result));
462 }
463 }
464 "report" => {
465 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
466 let result = benchmark::run_project_benchmark(path);
467 println!("{}", benchmark::format_markdown(&result));
468 }
469 _ => {
470 if std::path::Path::new(action).exists() {
471 let result = benchmark::run_project_benchmark(action);
472 println!("{}", benchmark::format_terminal(&result));
473 } else {
474 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
475 eprintln!(" lean-ctx benchmark report [path]");
476 std::process::exit(1);
477 }
478 }
479 }
480}
481
482fn format_tokens_cli(tokens: u64) -> String {
483 if tokens >= 1_000_000 {
484 format!("{:.1}M", tokens as f64 / 1_000_000.0)
485 } else if tokens >= 1_000 {
486 format!("{:.1}K", tokens as f64 / 1_000.0)
487 } else {
488 format!("{tokens}")
489 }
490}
491
492pub fn cmd_stats(args: &[String]) {
493 match args.first().map(|s| s.as_str()) {
494 Some("reset-cep") => {
495 crate::core::stats::reset_cep();
496 println!("CEP stats reset. Shell hook data preserved.");
497 }
498 Some("json") => {
499 let store = crate::core::stats::load();
500 println!(
501 "{}",
502 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
503 );
504 }
505 _ => {
506 let store = crate::core::stats::load();
507 let input_saved = store
508 .total_input_tokens
509 .saturating_sub(store.total_output_tokens);
510 let pct = if store.total_input_tokens > 0 {
511 input_saved as f64 / store.total_input_tokens as f64 * 100.0
512 } else {
513 0.0
514 };
515 println!("Commands: {}", store.total_commands);
516 println!("Input: {} tokens", store.total_input_tokens);
517 println!("Output: {} tokens", store.total_output_tokens);
518 println!("Saved: {} tokens ({:.1}%)", input_saved, pct);
519 println!();
520 println!("CEP sessions: {}", store.cep.sessions);
521 println!(
522 "CEP tokens: {} → {}",
523 store.cep.total_tokens_original, store.cep.total_tokens_compressed
524 );
525 println!();
526 println!("Subcommands: stats reset-cep | stats json");
527 }
528 }
529}
530
531pub fn cmd_cache(args: &[String]) {
532 use crate::core::cli_cache;
533 match args.first().map(|s| s.as_str()) {
534 Some("clear") => {
535 let count = cli_cache::clear();
536 println!("Cleared {count} cached entries.");
537 }
538 Some("reset") => {
539 let project_flag = args.get(1).map(|s| s.as_str()) == Some("--project");
540 if project_flag {
541 let root =
542 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
543 match root {
544 Some(root) => {
545 let count = cli_cache::clear_project(&root);
546 println!("Reset {count} cache entries for project: {root}");
547 }
548 None => {
549 eprintln!("No active project root found. Start a session first.");
550 std::process::exit(1);
551 }
552 }
553 } else {
554 let count = cli_cache::clear();
555 println!("Reset all {count} cache entries.");
556 }
557 }
558 Some("stats") => {
559 let (hits, reads, entries) = cli_cache::stats();
560 let rate = if reads > 0 {
561 (hits as f64 / reads as f64 * 100.0).round() as u32
562 } else {
563 0
564 };
565 println!("CLI Cache Stats:");
566 println!(" Entries: {entries}");
567 println!(" Reads: {reads}");
568 println!(" Hits: {hits}");
569 println!(" Hit Rate: {rate}%");
570 }
571 Some("invalidate") => {
572 if args.len() < 2 {
573 eprintln!("Usage: lean-ctx cache invalidate <path>");
574 std::process::exit(1);
575 }
576 cli_cache::invalidate(&args[1]);
577 println!("Invalidated cache for {}", args[1]);
578 }
579 _ => {
580 let (hits, reads, entries) = cli_cache::stats();
581 let rate = if reads > 0 {
582 (hits as f64 / reads as f64 * 100.0).round() as u32
583 } else {
584 0
585 };
586 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
587 println!();
588 println!("Subcommands:");
589 println!(" cache stats Show detailed stats");
590 println!(" cache clear Clear all cached entries");
591 println!(" cache reset Reset all cache (or --project for current project only)");
592 println!(" cache invalidate Remove specific file from cache");
593 }
594 }
595}
596
597pub fn cmd_config(args: &[String]) {
598 let cfg = config::Config::load();
599
600 if args.is_empty() {
601 println!("{}", cfg.show());
602 return;
603 }
604
605 match args[0].as_str() {
606 "init" | "create" => {
607 let default = config::Config::default();
608 match default.save() {
609 Ok(()) => {
610 let path = config::Config::path()
611 .map(|p| p.to_string_lossy().to_string())
612 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
613 println!("Created default config at {path}");
614 }
615 Err(e) => eprintln!("Error: {e}"),
616 }
617 }
618 "set" => {
619 if args.len() < 3 {
620 eprintln!("Usage: lean-ctx config set <key> <value>");
621 std::process::exit(1);
622 }
623 let mut cfg = cfg;
624 let key = &args[1];
625 let val = &args[2];
626 match key.as_str() {
627 "ultra_compact" => cfg.ultra_compact = val == "true",
628 "tee_on_error" | "tee_mode" => {
629 cfg.tee_mode = match val.as_str() {
630 "true" | "failures" => config::TeeMode::Failures,
631 "always" => config::TeeMode::Always,
632 "false" | "never" => config::TeeMode::Never,
633 _ => {
634 eprintln!("Valid tee_mode values: always, failures, never");
635 std::process::exit(1);
636 }
637 };
638 }
639 "checkpoint_interval" => {
640 cfg.checkpoint_interval = val.parse().unwrap_or(15);
641 }
642 "theme" => {
643 if theme::from_preset(val).is_some() || val == "custom" {
644 cfg.theme = val.to_string();
645 } else {
646 eprintln!(
647 "Unknown theme '{val}'. Available: {}",
648 theme::PRESET_NAMES.join(", ")
649 );
650 std::process::exit(1);
651 }
652 }
653 "slow_command_threshold_ms" => {
654 cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
655 }
656 "passthrough_urls" => {
657 cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
658 }
659 _ => {
660 eprintln!("Unknown config key: {key}");
661 std::process::exit(1);
662 }
663 }
664 match cfg.save() {
665 Ok(()) => println!("Updated {key} = {val}"),
666 Err(e) => eprintln!("Error saving config: {e}"),
667 }
668 }
669 _ => {
670 eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
671 std::process::exit(1);
672 }
673 }
674}
675
676pub fn cmd_cheatsheet() {
677 println!(
678 "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
679\x1b[1;36m║\x1b[0m \x1b[1;37mlean-ctx Workflow Cheat Sheet\x1b[0m \x1b[2mv2.9.7\x1b[0m \x1b[1;36m║\x1b[0m
680\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m
681
682\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
683 ctx_session load \x1b[2m# restore previous session\x1b[0m
684 ctx_overview task=\"...\" \x1b[2m# task-aware file map\x1b[0m
685 ctx_graph action=build \x1b[2m# index project (first time)\x1b[0m
686 ctx_knowledge action=recall \x1b[2m# check stored project facts\x1b[0m
687
688\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
689 ctx_read mode=full \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
690 ctx_read mode=map \x1b[2m# context-only files (~93% saved)\x1b[0m
691 ctx_read mode=diff \x1b[2m# after editing (~98% saved)\x1b[0m
692 ctx_read mode=sigs \x1b[2m# API surface of large files (~95%)\x1b[0m
693 ctx_multi_read \x1b[2m# read multiple files at once\x1b[0m
694 ctx_search \x1b[2m# search with compressed results (~70%)\x1b[0m
695 ctx_shell \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
696
697\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
698 ctx_session finding \"...\" \x1b[2m# record what you discovered\x1b[0m
699 ctx_session decision \"...\" \x1b[2m# record architectural choices\x1b[0m
700 ctx_knowledge action=remember \x1b[2m# store permanent project facts\x1b[0m
701 ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
702 ctx_metrics \x1b[2m# see session statistics\x1b[0m
703
704\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
705 ctx_agent action=register \x1b[2m# announce yourself\x1b[0m
706 ctx_agent action=list \x1b[2m# see other active agents\x1b[0m
707 ctx_agent action=post \x1b[2m# share findings\x1b[0m
708 ctx_agent action=read \x1b[2m# check messages\x1b[0m
709
710\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
711 Will edit? → \x1b[1mfull\x1b[0m (re-reads: 13 tokens) → after edit: \x1b[1mdiff\x1b[0m
712 API only? → \x1b[1msignatures\x1b[0m
713 Deps/exports? → \x1b[1mmap\x1b[0m
714 Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
715 Browsing? → \x1b[1maggressive\x1b[0m (syntax stripped)
716
717\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
718 lean-ctx gain \x1b[2m# visual savings dashboard\x1b[0m
719 lean-ctx gain --live \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
720 lean-ctx dashboard \x1b[2m# web dashboard with charts\x1b[0m
721 lean-ctx wrapped \x1b[2m# weekly savings report\x1b[0m
722 lean-ctx discover \x1b[2m# find uncompressed commands\x1b[0m
723 lean-ctx doctor \x1b[2m# diagnose installation\x1b[0m
724 lean-ctx update \x1b[2m# self-update to latest\x1b[0m
725
726\x1b[2m Full guide: https://leanctx.com/docs/workflow\x1b[0m"
727 );
728}
729
730pub fn cmd_slow_log(args: &[String]) {
731 use crate::core::slow_log;
732
733 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
734 match action {
735 "list" | "ls" | "" => println!("{}", slow_log::list()),
736 "clear" | "purge" => println!("{}", slow_log::clear()),
737 _ => {
738 eprintln!("Usage: lean-ctx slow-log [list|clear]");
739 std::process::exit(1);
740 }
741 }
742}
743
744pub fn cmd_tee(args: &[String]) {
745 let tee_dir = match dirs::home_dir() {
746 Some(h) => h.join(".lean-ctx").join("tee"),
747 None => {
748 eprintln!("Cannot determine home directory");
749 std::process::exit(1);
750 }
751 };
752
753 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
754 match action {
755 "list" | "ls" => {
756 if !tee_dir.exists() {
757 println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
758 return;
759 }
760 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
761 .unwrap_or_else(|e| {
762 eprintln!("Error: {e}");
763 std::process::exit(1);
764 })
765 .filter_map(|e| e.ok())
766 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
767 .collect();
768 entries.sort_by_key(|e| e.file_name());
769
770 if entries.is_empty() {
771 println!("No tee logs found.");
772 return;
773 }
774
775 println!("Tee logs ({}):\n", entries.len());
776 for entry in &entries {
777 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
778 let name = entry.file_name();
779 let size_str = if size > 1024 {
780 format!("{}K", size / 1024)
781 } else {
782 format!("{}B", size)
783 };
784 println!(" {:<60} {}", name.to_string_lossy(), size_str);
785 }
786 println!("\nUse 'lean-ctx tee clear' to delete all logs.");
787 }
788 "clear" | "purge" => {
789 if !tee_dir.exists() {
790 println!("No tee logs to clear.");
791 return;
792 }
793 let mut count = 0u32;
794 if let Ok(entries) = std::fs::read_dir(&tee_dir) {
795 for entry in entries.flatten() {
796 if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
797 && std::fs::remove_file(entry.path()).is_ok()
798 {
799 count += 1;
800 }
801 }
802 }
803 println!("Cleared {count} tee log(s) from {}", tee_dir.display());
804 }
805 "show" => {
806 let filename = args.get(1);
807 if filename.is_none() {
808 eprintln!("Usage: lean-ctx tee show <filename>");
809 std::process::exit(1);
810 }
811 let path = tee_dir.join(filename.unwrap());
812 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
813 Ok(content) => print!("{content}"),
814 Err(e) => {
815 eprintln!("Error reading {}: {e}", path.display());
816 std::process::exit(1);
817 }
818 }
819 }
820 "last" => {
821 if !tee_dir.exists() {
822 println!("No tee logs found.");
823 return;
824 }
825 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
826 .ok()
827 .into_iter()
828 .flat_map(|d| d.filter_map(|e| e.ok()))
829 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
830 .collect();
831 entries.sort_by_key(|e| {
832 e.metadata()
833 .and_then(|m| m.modified())
834 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
835 });
836 match entries.last() {
837 Some(entry) => {
838 let path = entry.path();
839 println!(
840 "--- {} ---\n",
841 path.file_name().unwrap_or_default().to_string_lossy()
842 );
843 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
844 Ok(content) => print!("{content}"),
845 Err(e) => eprintln!("Error: {e}"),
846 }
847 }
848 None => println!("No tee logs found."),
849 }
850 }
851 _ => {
852 eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
853 std::process::exit(1);
854 }
855 }
856}
857
858pub fn cmd_filter(args: &[String]) {
859 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
860 match action {
861 "list" | "ls" => match crate::core::filters::FilterEngine::load() {
862 Some(engine) => {
863 let rules = engine.list_rules();
864 println!("Loaded {} filter rule(s):\n", rules.len());
865 for rule in &rules {
866 println!("{rule}");
867 }
868 }
869 None => {
870 println!("No custom filters found.");
871 println!("Create one: lean-ctx filter init");
872 }
873 },
874 "validate" => {
875 let path = args.get(1);
876 if path.is_none() {
877 eprintln!("Usage: lean-ctx filter validate <file.toml>");
878 std::process::exit(1);
879 }
880 match crate::core::filters::validate_filter_file(path.unwrap()) {
881 Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
882 Err(e) => {
883 eprintln!("Validation failed: {e}");
884 std::process::exit(1);
885 }
886 }
887 }
888 "init" => match crate::core::filters::create_example_filter() {
889 Ok(path) => {
890 println!("Created example filter: {path}");
891 println!("Edit it to add your custom compression rules.");
892 }
893 Err(e) => {
894 eprintln!("{e}");
895 std::process::exit(1);
896 }
897 },
898 _ => {
899 eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
900 std::process::exit(1);
901 }
902 }
903}
904
905pub fn load_shell_history_pub() -> Vec<String> {
906 load_shell_history()
907}
908
909fn load_shell_history() -> Vec<String> {
910 let shell = std::env::var("SHELL").unwrap_or_default();
911 let home = match dirs::home_dir() {
912 Some(h) => h,
913 None => return Vec::new(),
914 };
915
916 let history_file = if shell.contains("zsh") {
917 home.join(".zsh_history")
918 } else if shell.contains("fish") {
919 home.join(".local/share/fish/fish_history")
920 } else if cfg!(windows) && shell.is_empty() {
921 home.join("AppData")
922 .join("Roaming")
923 .join("Microsoft")
924 .join("Windows")
925 .join("PowerShell")
926 .join("PSReadLine")
927 .join("ConsoleHost_history.txt")
928 } else {
929 home.join(".bash_history")
930 };
931
932 match std::fs::read_to_string(&history_file) {
933 Ok(content) => content
934 .lines()
935 .filter_map(|l| {
936 let trimmed = l.trim();
937 if trimmed.starts_with(':') {
938 trimmed.split(';').nth(1).map(|s| s.to_string())
939 } else {
940 Some(trimmed.to_string())
941 }
942 })
943 .filter(|l| !l.is_empty())
944 .collect(),
945 Err(_) => Vec::new(),
946 }
947}
948
949fn print_savings(original: usize, sent: usize) {
950 let saved = original.saturating_sub(sent);
951 if original > 0 && saved > 0 {
952 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
953 println!("[{saved} tok saved ({pct}%)]");
954 }
955}
956
957pub fn cmd_theme(args: &[String]) {
958 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
959 let r = theme::rst();
960 let b = theme::bold();
961 let d = theme::dim();
962
963 match sub {
964 "list" => {
965 let cfg = config::Config::load();
966 let active = cfg.theme.as_str();
967 println!();
968 println!(" {b}Available themes:{r}");
969 println!(" {ln}", ln = "─".repeat(40));
970 for name in theme::PRESET_NAMES {
971 let marker = if *name == active { " ◀ active" } else { "" };
972 let t = theme::from_preset(name).unwrap();
973 let preview = format!(
974 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
975 p = t.primary.fg(),
976 s = t.secondary.fg(),
977 a = t.accent.fg(),
978 sc = t.success.fg(),
979 w = t.warning.fg(),
980 );
981 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
982 }
983 if let Some(path) = theme::theme_file_path() {
984 if path.exists() {
985 let custom = theme::load_theme("_custom_");
986 let preview = format!(
987 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
988 p = custom.primary.fg(),
989 s = custom.secondary.fg(),
990 a = custom.accent.fg(),
991 sc = custom.success.fg(),
992 w = custom.warning.fg(),
993 );
994 let marker = if active == "custom" {
995 " ◀ active"
996 } else {
997 ""
998 };
999 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1000 }
1001 }
1002 println!();
1003 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1004 println!();
1005 }
1006 "set" => {
1007 if args.len() < 2 {
1008 eprintln!("Usage: lean-ctx theme set <name>");
1009 std::process::exit(1);
1010 }
1011 let name = &args[1];
1012 if theme::from_preset(name).is_none() && name != "custom" {
1013 eprintln!(
1014 "Unknown theme '{name}'. Available: {}",
1015 theme::PRESET_NAMES.join(", ")
1016 );
1017 std::process::exit(1);
1018 }
1019 let mut cfg = config::Config::load();
1020 cfg.theme = name.to_string();
1021 match cfg.save() {
1022 Ok(()) => {
1023 let t = theme::load_theme(name);
1024 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1025 let preview = t.gradient_bar(0.75, 30);
1026 println!(" {preview}");
1027 }
1028 Err(e) => eprintln!("Error: {e}"),
1029 }
1030 }
1031 "export" => {
1032 let cfg = config::Config::load();
1033 let t = theme::load_theme(&cfg.theme);
1034 println!("{}", t.to_toml());
1035 }
1036 "import" => {
1037 if args.len() < 2 {
1038 eprintln!("Usage: lean-ctx theme import <path>");
1039 std::process::exit(1);
1040 }
1041 let path = std::path::Path::new(&args[1]);
1042 if !path.exists() {
1043 eprintln!("File not found: {}", args[1]);
1044 std::process::exit(1);
1045 }
1046 match std::fs::read_to_string(path) {
1047 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1048 Ok(imported) => match theme::save_theme(&imported) {
1049 Ok(()) => {
1050 let mut cfg = config::Config::load();
1051 cfg.theme = "custom".to_string();
1052 let _ = cfg.save();
1053 println!(
1054 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1055 sc = imported.success.fg(),
1056 name = imported.name,
1057 );
1058 println!(" Config updated: theme = custom");
1059 }
1060 Err(e) => eprintln!("Error saving theme: {e}"),
1061 },
1062 Err(e) => eprintln!("Invalid theme file: {e}"),
1063 },
1064 Err(e) => eprintln!("Error reading file: {e}"),
1065 }
1066 }
1067 "preview" => {
1068 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1069 let t = match theme::from_preset(name) {
1070 Some(t) => t,
1071 None => {
1072 eprintln!("Unknown theme: {name}");
1073 std::process::exit(1);
1074 }
1075 };
1076 println!();
1077 println!(
1078 " {icon} {title} {d}Theme Preview: {name}{r}",
1079 icon = t.header_icon(),
1080 title = t.brand_title(),
1081 );
1082 println!(" {ln}", ln = t.border_line(50));
1083 println!();
1084 println!(
1085 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1086 sc = t.success.fg(),
1087 sec = t.secondary.fg(),
1088 wrn = t.warning.fg(),
1089 acc = t.accent.fg(),
1090 );
1091 println!(" {d} tokens saved compression commands USD saved{r}");
1092 println!();
1093 println!(
1094 " {b}{txt}Gradient Bar{r} {bar}",
1095 txt = t.text.fg(),
1096 bar = t.gradient_bar(0.85, 30),
1097 );
1098 println!(
1099 " {b}{txt}Sparkline{r} {spark}",
1100 txt = t.text.fg(),
1101 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1102 );
1103 println!();
1104 println!(" {top}", top = t.box_top(50));
1105 println!(
1106 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1107 side = t.box_side(),
1108 side_r = t.box_side(),
1109 txt = t.text.fg(),
1110 );
1111 println!(" {bot}", bot = t.box_bottom(50));
1112 println!();
1113 }
1114 _ => {
1115 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1116 std::process::exit(1);
1117 }
1118 }
1119}