Skip to main content

lean_ctx/cli/
mod.rs

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}