Skip to main content

lean_ctx/
cli.rs

1use std::path::Path;
2
3use crate::core::compressor;
4use crate::core::config;
5use crate::core::deps as dep_extract;
6use crate::core::entropy;
7use crate::core::patterns::deps_cmd;
8use crate::core::protocol;
9use crate::core::signatures;
10use crate::core::stats;
11use crate::core::theme;
12use crate::core::tokens::count_tokens;
13use crate::hooks::to_bash_compatible_path;
14
15pub fn cmd_read(args: &[String]) {
16    if args.is_empty() {
17        eprintln!(
18            "Usage: lean-ctx read <file> [--mode full|map|signatures|aggressive|entropy] [--fresh]"
19        );
20        std::process::exit(1);
21    }
22
23    let path = &args[0];
24    let mode = args
25        .iter()
26        .position(|a| a == "--mode" || a == "-m")
27        .and_then(|i| args.get(i + 1))
28        .map(|s| s.as_str())
29        .unwrap_or("full");
30    let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
31
32    let short = protocol::shorten_path(path);
33
34    if !force_fresh && mode == "full" {
35        use crate::core::cli_cache::{self, CacheResult};
36        match cli_cache::check_and_read(path) {
37            CacheResult::Hit { entry, file_ref } => {
38                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
39                println!("{msg}");
40                stats::record("cli_read", entry.original_tokens, msg.len());
41                return;
42            }
43            CacheResult::Miss { content } if content.is_empty() => {
44                eprintln!("Error: could not read {path}");
45                std::process::exit(1);
46            }
47            CacheResult::Miss { content } => {
48                let line_count = content.lines().count();
49                println!("{short} [{line_count}L]");
50                println!("{content}");
51                stats::record("cli_read", count_tokens(&content), count_tokens(&content));
52                return;
53            }
54        }
55    }
56
57    let content = match crate::tools::ctx_read::read_file_lossy(path) {
58        Ok(c) => c,
59        Err(e) => {
60            eprintln!("Error: {e}");
61            std::process::exit(1);
62        }
63    };
64
65    let ext = Path::new(path)
66        .extension()
67        .and_then(|e| e.to_str())
68        .unwrap_or("");
69    let line_count = content.lines().count();
70    let original_tokens = count_tokens(&content);
71
72    let mode = if mode == "auto" {
73        let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
74        let predictor = crate::core::mode_predictor::ModePredictor::new();
75        predictor
76            .predict_best_mode(&sig)
77            .unwrap_or_else(|| "full".to_string())
78    } else {
79        mode.to_string()
80    };
81    let mode = mode.as_str();
82
83    match mode {
84        "map" => {
85            let sigs = signatures::extract_signatures(&content, ext);
86            let dep_info = dep_extract::extract_deps(&content, ext);
87
88            println!("{short} [{line_count}L]");
89            if !dep_info.imports.is_empty() {
90                println!("  deps: {}", dep_info.imports.join(", "));
91            }
92            if !dep_info.exports.is_empty() {
93                println!("  exports: {}", dep_info.exports.join(", "));
94            }
95            let key_sigs: Vec<_> = sigs
96                .iter()
97                .filter(|s| s.is_exported || s.indent == 0)
98                .collect();
99            if !key_sigs.is_empty() {
100                println!("  API:");
101                for sig in &key_sigs {
102                    println!("    {}", sig.to_compact());
103                }
104            }
105            let sent = count_tokens(&short.to_string());
106            print_savings(original_tokens, sent);
107        }
108        "signatures" => {
109            let sigs = signatures::extract_signatures(&content, ext);
110            println!("{short} [{line_count}L]");
111            for sig in &sigs {
112                println!("{}", sig.to_compact());
113            }
114            let sent = count_tokens(&short.to_string());
115            print_savings(original_tokens, sent);
116        }
117        "aggressive" => {
118            let compressed = compressor::aggressive_compress(&content, Some(ext));
119            println!("{short} [{line_count}L]");
120            println!("{compressed}");
121            let sent = count_tokens(&compressed);
122            print_savings(original_tokens, sent);
123        }
124        "entropy" => {
125            let result = entropy::entropy_compress(&content);
126            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
127            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
128            for tech in &result.techniques {
129                println!("{tech}");
130            }
131            println!("{}", result.output);
132            let sent = count_tokens(&result.output);
133            print_savings(original_tokens, sent);
134        }
135        _ => {
136            println!("{short} [{line_count}L]");
137            println!("{content}");
138        }
139    }
140}
141
142pub fn cmd_diff(args: &[String]) {
143    if args.len() < 2 {
144        eprintln!("Usage: lean-ctx diff <file1> <file2>");
145        std::process::exit(1);
146    }
147
148    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
149        Ok(c) => c,
150        Err(e) => {
151            eprintln!("Error reading {}: {e}", args[0]);
152            std::process::exit(1);
153        }
154    };
155
156    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
157        Ok(c) => c,
158        Err(e) => {
159            eprintln!("Error reading {}: {e}", args[1]);
160            std::process::exit(1);
161        }
162    };
163
164    let diff = compressor::diff_content(&content1, &content2);
165    let original = count_tokens(&content1) + count_tokens(&content2);
166    let sent = count_tokens(&diff);
167
168    println!(
169        "diff {} {}",
170        protocol::shorten_path(&args[0]),
171        protocol::shorten_path(&args[1])
172    );
173    println!("{diff}");
174    print_savings(original, sent);
175}
176
177pub fn cmd_grep(args: &[String]) {
178    if args.is_empty() {
179        eprintln!("Usage: lean-ctx grep <pattern> [path]");
180        std::process::exit(1);
181    }
182
183    let pattern = &args[0];
184    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
185
186    let re = match regex::Regex::new(pattern) {
187        Ok(r) => r,
188        Err(e) => {
189            eprintln!("Invalid regex pattern: {e}");
190            std::process::exit(1);
191        }
192    };
193
194    let mut found = false;
195    for entry in ignore::WalkBuilder::new(path)
196        .hidden(true)
197        .git_ignore(true)
198        .git_global(true)
199        .git_exclude(true)
200        .max_depth(Some(10))
201        .build()
202        .flatten()
203    {
204        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
205            continue;
206        }
207        let file_path = entry.path();
208        if let Ok(content) = std::fs::read_to_string(file_path) {
209            for (i, line) in content.lines().enumerate() {
210                if re.is_match(line) {
211                    println!("{}:{}:{}", file_path.display(), i + 1, line);
212                    found = true;
213                }
214            }
215        }
216    }
217
218    if !found {
219        std::process::exit(1);
220    }
221}
222
223pub fn cmd_find(args: &[String]) {
224    if args.is_empty() {
225        eprintln!("Usage: lean-ctx find <pattern> [path]");
226        std::process::exit(1);
227    }
228
229    let pattern = args[0].to_lowercase();
230    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
231
232    let mut found = false;
233    for entry in ignore::WalkBuilder::new(path)
234        .hidden(true)
235        .git_ignore(true)
236        .git_global(true)
237        .git_exclude(true)
238        .max_depth(Some(10))
239        .build()
240        .flatten()
241    {
242        let name = entry.file_name().to_string_lossy().to_lowercase();
243        if name.contains(&pattern) {
244            println!("{}", entry.path().display());
245            found = true;
246        }
247    }
248
249    if !found {
250        std::process::exit(1);
251    }
252}
253
254pub fn cmd_ls(args: &[String]) {
255    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
256    let command = if cfg!(windows) {
257        format!("dir {}", path.replace('/', "\\"))
258    } else {
259        format!("ls -la {path}")
260    };
261    let code = crate::shell::exec(&command);
262    std::process::exit(code);
263}
264
265pub fn cmd_deps(args: &[String]) {
266    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
267
268    match deps_cmd::detect_and_compress(path) {
269        Some(result) => println!("{result}"),
270        None => {
271            eprintln!("No dependency file found in {path}");
272            std::process::exit(1);
273        }
274    }
275}
276
277pub fn cmd_discover(_args: &[String]) {
278    let history = load_shell_history();
279    if history.is_empty() {
280        println!("No shell history found.");
281        return;
282    }
283
284    let result = crate::tools::ctx_discover::analyze_history(&history, 20);
285    println!("{}", crate::tools::ctx_discover::format_cli_output(&result));
286}
287
288pub fn cmd_session() {
289    let history = load_shell_history();
290    let gain = stats::load_stats();
291
292    let compressible_commands = [
293        "git ",
294        "npm ",
295        "yarn ",
296        "pnpm ",
297        "cargo ",
298        "docker ",
299        "kubectl ",
300        "gh ",
301        "pip ",
302        "pip3 ",
303        "eslint",
304        "prettier",
305        "ruff ",
306        "go ",
307        "golangci-lint",
308        "curl ",
309        "wget ",
310        "grep ",
311        "rg ",
312        "find ",
313        "ls ",
314    ];
315
316    let mut total = 0u32;
317    let mut via_hook = 0u32;
318
319    for line in &history {
320        let cmd = line.trim().to_lowercase();
321        if cmd.starts_with("lean-ctx") {
322            via_hook += 1;
323            total += 1;
324        } else {
325            for p in &compressible_commands {
326                if cmd.starts_with(p) {
327                    total += 1;
328                    break;
329                }
330            }
331        }
332    }
333
334    let pct = if total > 0 {
335        (via_hook as f64 / total as f64 * 100.0).round() as u32
336    } else {
337        0
338    };
339
340    println!("lean-ctx session statistics\n");
341    println!(
342        "Adoption:    {}% ({}/{} compressible commands)",
343        pct, via_hook, total
344    );
345    println!("Saved:       {} tokens total", gain.total_saved);
346    println!("Calls:       {} compressed", gain.total_calls);
347
348    if total > via_hook {
349        let missed = total - via_hook;
350        let est = missed * 150;
351        println!(
352            "Missed:      {} commands (~{} tokens saveable)",
353            missed, est
354        );
355    }
356
357    println!("\nRun 'lean-ctx discover' for details on missed commands.");
358}
359
360pub fn cmd_wrapped(args: &[String]) {
361    let period = if args.iter().any(|a| a == "--month") {
362        "month"
363    } else if args.iter().any(|a| a == "--all") {
364        "all"
365    } else {
366        "week"
367    };
368
369    let report = crate::core::wrapped::WrappedReport::generate(period);
370    println!("{}", report.format_ascii());
371}
372
373pub fn cmd_sessions(args: &[String]) {
374    use crate::core::session::SessionState;
375
376    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
377
378    match action {
379        "list" | "ls" => {
380            let sessions = SessionState::list_sessions();
381            if sessions.is_empty() {
382                println!("No sessions found.");
383                return;
384            }
385            println!("Sessions ({}):\n", sessions.len());
386            for s in sessions.iter().take(20) {
387                let task = s.task.as_deref().unwrap_or("(no task)");
388                let task_short: String = task.chars().take(50).collect();
389                let date = s.updated_at.format("%Y-%m-%d %H:%M");
390                println!(
391                    "  {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
392                    s.id,
393                    s.version,
394                    s.tool_calls,
395                    format_tokens_cli(s.tokens_saved),
396                    date,
397                    task_short
398                );
399            }
400            if sessions.len() > 20 {
401                println!("  ... +{} more", sessions.len() - 20);
402            }
403        }
404        "show" => {
405            let id = args.get(1);
406            let session = if let Some(id) = id {
407                SessionState::load_by_id(id)
408            } else {
409                SessionState::load_latest()
410            };
411            match session {
412                Some(s) => println!("{}", s.format_compact()),
413                None => println!("Session not found."),
414            }
415        }
416        "cleanup" => {
417            let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
418            let removed = SessionState::cleanup_old_sessions(days);
419            println!("Cleaned up {removed} session(s) older than {days} days.");
420        }
421        _ => {
422            eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
423            std::process::exit(1);
424        }
425    }
426}
427
428pub fn cmd_benchmark(args: &[String]) {
429    use crate::core::benchmark;
430
431    let action = args.first().map(|s| s.as_str()).unwrap_or("run");
432
433    match action {
434        "run" => {
435            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
436            let is_json = args.iter().any(|a| a == "--json");
437
438            let result = benchmark::run_project_benchmark(path);
439            if is_json {
440                println!("{}", benchmark::format_json(&result));
441            } else {
442                println!("{}", benchmark::format_terminal(&result));
443            }
444        }
445        "report" => {
446            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
447            let result = benchmark::run_project_benchmark(path);
448            println!("{}", benchmark::format_markdown(&result));
449        }
450        _ => {
451            if std::path::Path::new(action).exists() {
452                let result = benchmark::run_project_benchmark(action);
453                println!("{}", benchmark::format_terminal(&result));
454            } else {
455                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
456                eprintln!("       lean-ctx benchmark report [path]");
457                std::process::exit(1);
458            }
459        }
460    }
461}
462
463fn format_tokens_cli(tokens: u64) -> String {
464    if tokens >= 1_000_000 {
465        format!("{:.1}M", tokens as f64 / 1_000_000.0)
466    } else if tokens >= 1_000 {
467        format!("{:.1}K", tokens as f64 / 1_000.0)
468    } else {
469        format!("{tokens}")
470    }
471}
472
473pub fn cmd_stats(args: &[String]) {
474    match args.first().map(|s| s.as_str()) {
475        Some("reset-cep") => {
476            crate::core::stats::reset_cep();
477            println!("CEP stats reset. Shell hook data preserved.");
478        }
479        Some("json") => {
480            let store = crate::core::stats::load();
481            println!(
482                "{}",
483                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
484            );
485        }
486        _ => {
487            let store = crate::core::stats::load();
488            let input_saved = store
489                .total_input_tokens
490                .saturating_sub(store.total_output_tokens);
491            let pct = if store.total_input_tokens > 0 {
492                input_saved as f64 / store.total_input_tokens as f64 * 100.0
493            } else {
494                0.0
495            };
496            println!("Commands:    {}", store.total_commands);
497            println!("Input:       {} tokens", store.total_input_tokens);
498            println!("Output:      {} tokens", store.total_output_tokens);
499            println!("Saved:       {} tokens ({:.1}%)", input_saved, pct);
500            println!();
501            println!("CEP sessions:  {}", store.cep.sessions);
502            println!(
503                "CEP tokens:    {} → {}",
504                store.cep.total_tokens_original, store.cep.total_tokens_compressed
505            );
506            println!();
507            println!("Subcommands: stats reset-cep | stats json");
508        }
509    }
510}
511
512pub fn cmd_cache(args: &[String]) {
513    use crate::core::cli_cache;
514    match args.first().map(|s| s.as_str()) {
515        Some("clear") => {
516            let count = cli_cache::clear();
517            println!("Cleared {count} cached entries.");
518        }
519        Some("stats") => {
520            let (hits, reads, entries) = cli_cache::stats();
521            let rate = if reads > 0 {
522                (hits as f64 / reads as f64 * 100.0).round() as u32
523            } else {
524                0
525            };
526            println!("CLI Cache Stats:");
527            println!("  Entries:   {entries}");
528            println!("  Reads:     {reads}");
529            println!("  Hits:      {hits}");
530            println!("  Hit Rate:  {rate}%");
531        }
532        Some("invalidate") => {
533            if args.len() < 2 {
534                eprintln!("Usage: lean-ctx cache invalidate <path>");
535                std::process::exit(1);
536            }
537            cli_cache::invalidate(&args[1]);
538            println!("Invalidated cache for {}", args[1]);
539        }
540        _ => {
541            let (hits, reads, entries) = cli_cache::stats();
542            let rate = if reads > 0 {
543                (hits as f64 / reads as f64 * 100.0).round() as u32
544            } else {
545                0
546            };
547            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
548            println!();
549            println!("Subcommands:");
550            println!("  cache stats       Show detailed stats");
551            println!("  cache clear       Clear all cached entries");
552            println!("  cache invalidate  Remove specific file from cache");
553        }
554    }
555}
556
557pub fn cmd_config(args: &[String]) {
558    let cfg = config::Config::load();
559
560    if args.is_empty() {
561        println!("{}", cfg.show());
562        return;
563    }
564
565    match args[0].as_str() {
566        "init" | "create" => {
567            let default = config::Config::default();
568            match default.save() {
569                Ok(()) => {
570                    let path = config::Config::path()
571                        .map(|p| p.to_string_lossy().to_string())
572                        .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
573                    println!("Created default config at {path}");
574                }
575                Err(e) => eprintln!("Error: {e}"),
576            }
577        }
578        "set" => {
579            if args.len() < 3 {
580                eprintln!("Usage: lean-ctx config set <key> <value>");
581                std::process::exit(1);
582            }
583            let mut cfg = cfg;
584            let key = &args[1];
585            let val = &args[2];
586            match key.as_str() {
587                "ultra_compact" => cfg.ultra_compact = val == "true",
588                "tee_on_error" | "tee_mode" => {
589                    cfg.tee_mode = match val.as_str() {
590                        "true" | "failures" => config::TeeMode::Failures,
591                        "always" => config::TeeMode::Always,
592                        "false" | "never" => config::TeeMode::Never,
593                        _ => {
594                            eprintln!("Valid tee_mode values: always, failures, never");
595                            std::process::exit(1);
596                        }
597                    };
598                }
599                "checkpoint_interval" => {
600                    cfg.checkpoint_interval = val.parse().unwrap_or(15);
601                }
602                "theme" => {
603                    if theme::from_preset(val).is_some() || val == "custom" {
604                        cfg.theme = val.to_string();
605                    } else {
606                        eprintln!(
607                            "Unknown theme '{val}'. Available: {}",
608                            theme::PRESET_NAMES.join(", ")
609                        );
610                        std::process::exit(1);
611                    }
612                }
613                "slow_command_threshold_ms" => {
614                    cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
615                }
616                "passthrough_urls" => {
617                    cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
618                }
619                _ => {
620                    eprintln!("Unknown config key: {key}");
621                    std::process::exit(1);
622                }
623            }
624            match cfg.save() {
625                Ok(()) => println!("Updated {key} = {val}"),
626                Err(e) => eprintln!("Error saving config: {e}"),
627            }
628        }
629        _ => {
630            eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
631            std::process::exit(1);
632        }
633    }
634}
635
636pub fn cmd_cheatsheet() {
637    println!(
638        "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
639\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
640\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m
641
642\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
643  ctx_session load               \x1b[2m# restore previous session\x1b[0m
644  ctx_overview task=\"...\"         \x1b[2m# task-aware file map\x1b[0m
645  ctx_graph action=build          \x1b[2m# index project (first time)\x1b[0m
646  ctx_knowledge action=recall     \x1b[2m# check stored project facts\x1b[0m
647
648\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
649  ctx_read mode=full    \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
650  ctx_read mode=map     \x1b[2m# context-only files (~93% saved)\x1b[0m
651  ctx_read mode=diff    \x1b[2m# after editing (~98% saved)\x1b[0m
652  ctx_read mode=sigs    \x1b[2m# API surface of large files (~95%)\x1b[0m
653  ctx_multi_read        \x1b[2m# read multiple files at once\x1b[0m
654  ctx_search            \x1b[2m# search with compressed results (~70%)\x1b[0m
655  ctx_shell             \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
656
657\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
658  ctx_session finding \"...\"       \x1b[2m# record what you discovered\x1b[0m
659  ctx_session decision \"...\"      \x1b[2m# record architectural choices\x1b[0m
660  ctx_knowledge action=remember   \x1b[2m# store permanent project facts\x1b[0m
661  ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
662  ctx_metrics                     \x1b[2m# see session statistics\x1b[0m
663
664\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
665  ctx_agent action=register       \x1b[2m# announce yourself\x1b[0m
666  ctx_agent action=list           \x1b[2m# see other active agents\x1b[0m
667  ctx_agent action=post           \x1b[2m# share findings\x1b[0m
668  ctx_agent action=read           \x1b[2m# check messages\x1b[0m
669
670\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
671  Will edit?  → \x1b[1mfull\x1b[0m (re-reads: 13 tokens)  → after edit: \x1b[1mdiff\x1b[0m
672  API only?   → \x1b[1msignatures\x1b[0m
673  Deps/exports? → \x1b[1mmap\x1b[0m
674  Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
675  Browsing?   → \x1b[1maggressive\x1b[0m (syntax stripped)
676
677\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
678  lean-ctx gain          \x1b[2m# visual savings dashboard\x1b[0m
679  lean-ctx gain --live   \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
680  lean-ctx dashboard     \x1b[2m# web dashboard with charts\x1b[0m
681  lean-ctx wrapped       \x1b[2m# weekly savings report\x1b[0m
682  lean-ctx discover      \x1b[2m# find uncompressed commands\x1b[0m
683  lean-ctx doctor        \x1b[2m# diagnose installation\x1b[0m
684  lean-ctx update        \x1b[2m# self-update to latest\x1b[0m
685
686\x1b[2m  Full guide: https://leanctx.com/docs/workflow\x1b[0m"
687    );
688}
689
690pub fn cmd_slow_log(args: &[String]) {
691    use crate::core::slow_log;
692
693    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
694    match action {
695        "list" | "ls" | "" => println!("{}", slow_log::list()),
696        "clear" | "purge" => println!("{}", slow_log::clear()),
697        _ => {
698            eprintln!("Usage: lean-ctx slow-log [list|clear]");
699            std::process::exit(1);
700        }
701    }
702}
703
704pub fn cmd_tee(args: &[String]) {
705    let tee_dir = match dirs::home_dir() {
706        Some(h) => h.join(".lean-ctx").join("tee"),
707        None => {
708            eprintln!("Cannot determine home directory");
709            std::process::exit(1);
710        }
711    };
712
713    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
714    match action {
715        "list" | "ls" => {
716            if !tee_dir.exists() {
717                println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
718                return;
719            }
720            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
721                .unwrap_or_else(|e| {
722                    eprintln!("Error: {e}");
723                    std::process::exit(1);
724                })
725                .filter_map(|e| e.ok())
726                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
727                .collect();
728            entries.sort_by_key(|e| e.file_name());
729
730            if entries.is_empty() {
731                println!("No tee logs found.");
732                return;
733            }
734
735            println!("Tee logs ({}):\n", entries.len());
736            for entry in &entries {
737                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
738                let name = entry.file_name();
739                let size_str = if size > 1024 {
740                    format!("{}K", size / 1024)
741                } else {
742                    format!("{}B", size)
743                };
744                println!("  {:<60} {}", name.to_string_lossy(), size_str);
745            }
746            println!("\nUse 'lean-ctx tee clear' to delete all logs.");
747        }
748        "clear" | "purge" => {
749            if !tee_dir.exists() {
750                println!("No tee logs to clear.");
751                return;
752            }
753            let mut count = 0u32;
754            if let Ok(entries) = std::fs::read_dir(&tee_dir) {
755                for entry in entries.flatten() {
756                    if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
757                        && std::fs::remove_file(entry.path()).is_ok()
758                    {
759                        count += 1;
760                    }
761                }
762            }
763            println!("Cleared {count} tee log(s) from {}", tee_dir.display());
764        }
765        "show" => {
766            let filename = args.get(1);
767            if filename.is_none() {
768                eprintln!("Usage: lean-ctx tee show <filename>");
769                std::process::exit(1);
770            }
771            let path = tee_dir.join(filename.unwrap());
772            match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
773                Ok(content) => print!("{content}"),
774                Err(e) => {
775                    eprintln!("Error reading {}: {e}", path.display());
776                    std::process::exit(1);
777                }
778            }
779        }
780        "last" => {
781            if !tee_dir.exists() {
782                println!("No tee logs found.");
783                return;
784            }
785            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
786                .ok()
787                .into_iter()
788                .flat_map(|d| d.filter_map(|e| e.ok()))
789                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
790                .collect();
791            entries.sort_by_key(|e| {
792                e.metadata()
793                    .and_then(|m| m.modified())
794                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
795            });
796            match entries.last() {
797                Some(entry) => {
798                    let path = entry.path();
799                    println!(
800                        "--- {} ---\n",
801                        path.file_name().unwrap_or_default().to_string_lossy()
802                    );
803                    match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
804                        Ok(content) => print!("{content}"),
805                        Err(e) => eprintln!("Error: {e}"),
806                    }
807                }
808                None => println!("No tee logs found."),
809            }
810        }
811        _ => {
812            eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
813            std::process::exit(1);
814        }
815    }
816}
817
818pub fn cmd_filter(args: &[String]) {
819    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
820    match action {
821        "list" | "ls" => match crate::core::filters::FilterEngine::load() {
822            Some(engine) => {
823                let rules = engine.list_rules();
824                println!("Loaded {} filter rule(s):\n", rules.len());
825                for rule in &rules {
826                    println!("{rule}");
827                }
828            }
829            None => {
830                println!("No custom filters found.");
831                println!("Create one: lean-ctx filter init");
832            }
833        },
834        "validate" => {
835            let path = args.get(1);
836            if path.is_none() {
837                eprintln!("Usage: lean-ctx filter validate <file.toml>");
838                std::process::exit(1);
839            }
840            match crate::core::filters::validate_filter_file(path.unwrap()) {
841                Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
842                Err(e) => {
843                    eprintln!("Validation failed: {e}");
844                    std::process::exit(1);
845                }
846            }
847        }
848        "init" => match crate::core::filters::create_example_filter() {
849            Ok(path) => {
850                println!("Created example filter: {path}");
851                println!("Edit it to add your custom compression rules.");
852            }
853            Err(e) => {
854                eprintln!("{e}");
855                std::process::exit(1);
856            }
857        },
858        _ => {
859            eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
860            std::process::exit(1);
861        }
862    }
863}
864
865pub fn cmd_init(args: &[String]) {
866    let global = args.iter().any(|a| a == "--global" || a == "-g");
867    let dry_run = args.iter().any(|a| a == "--dry-run");
868
869    let agents: Vec<&str> = args
870        .windows(2)
871        .filter(|w| w[0] == "--agent")
872        .map(|w| w[1].as_str())
873        .collect();
874
875    if !agents.is_empty() {
876        for agent_name in &agents {
877            crate::hooks::install_agent_hook(agent_name, global);
878        }
879        if !global {
880            crate::hooks::install_project_rules();
881        }
882        println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
883        return;
884    }
885
886    let shell_name = std::env::var("SHELL").unwrap_or_default();
887    let is_zsh = shell_name.contains("zsh");
888    let is_fish = shell_name.contains("fish");
889    let is_powershell = cfg!(windows) && shell_name.is_empty();
890
891    let binary = std::env::current_exe()
892        .map(|p| p.to_string_lossy().to_string())
893        .unwrap_or_else(|_| "lean-ctx".to_string());
894
895    if dry_run {
896        let rc = if is_powershell {
897            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
898        } else if is_fish {
899            "~/.config/fish/config.fish".to_string()
900        } else if is_zsh {
901            "~/.zshrc".to_string()
902        } else {
903            "~/.bashrc".to_string()
904        };
905        println!("\nlean-ctx init --dry-run\n");
906        println!("  Would modify:  {rc}");
907        println!("  Would backup:  {rc}.lean-ctx.bak");
908        println!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
909        println!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
910        println!("                 ls find grep curl wget php composer (24 commands + k)");
911        println!("  Would create:  ~/.lean-ctx/");
912        println!("  Binary:        {binary}");
913        println!("\n  Safety: aliases auto-fallback to original command if lean-ctx is removed.");
914        println!("\n  Run without --dry-run to apply.");
915        return;
916    }
917
918    if is_powershell {
919        init_powershell(&binary);
920    } else {
921        let bash_binary = to_bash_compatible_path(&binary);
922        if is_fish {
923            init_fish(&bash_binary);
924        } else {
925            init_posix(is_zsh, &bash_binary);
926        }
927    }
928
929    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
930    if let Some(dir) = lean_dir {
931        if !dir.exists() {
932            let _ = std::fs::create_dir_all(&dir);
933            println!("Created {}", dir.display());
934        }
935    }
936
937    let rc = if is_powershell {
938        "$PROFILE"
939    } else if is_fish {
940        "config.fish"
941    } else if is_zsh {
942        ".zshrc"
943    } else {
944        ".bashrc"
945    };
946
947    println!("\nlean-ctx init complete (24 aliases installed)");
948    println!();
949    println!("  Disable temporarily:  lean-ctx-off");
950    println!("  Re-enable:            lean-ctx-on");
951    println!("  Check status:         lean-ctx-status");
952    println!("  Full uninstall:       lean-ctx uninstall");
953    println!("  Diagnose issues:      lean-ctx doctor");
954    println!("  Preview changes:      lean-ctx init --global --dry-run");
955    println!();
956    if is_powershell {
957        println!("  Restart PowerShell or run: . {rc}");
958    } else {
959        println!("  Restart your shell or run: source ~/{rc}");
960    }
961    println!();
962    println!("For AI tool integration: lean-ctx init --agent <tool>");
963    println!("  Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, crush, pi");
964}
965
966fn backup_shell_config(path: &std::path::Path) {
967    if !path.exists() {
968        return;
969    }
970    let bak = path.with_extension("lean-ctx.bak");
971    if std::fs::copy(path, &bak).is_ok() {
972        println!(
973            "  Backup: {}",
974            bak.file_name()
975                .map(|n| format!("~/{}", n.to_string_lossy()))
976                .unwrap_or_else(|| bak.display().to_string())
977        );
978    }
979}
980
981fn init_powershell(binary: &str) {
982    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
983    let profile_path = match profile_dir {
984        Some(dir) => {
985            let _ = std::fs::create_dir_all(&dir);
986            dir.join("Microsoft.PowerShell_profile.ps1")
987        }
988        None => {
989            eprintln!("Could not resolve PowerShell profile directory");
990            return;
991        }
992    };
993
994    let binary_escaped = binary.replace('\\', "\\\\");
995    let functions = format!(
996        r#"
997# lean-ctx shell hook — transparent CLI compression (90+ patterns)
998if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED) {{
999  $LeanCtxBin = "{binary_escaped}"
1000  function _lc {{
1001    if ($env:LEAN_CTX_DISABLED) {{ & $args[0] $args[1..($args.Length)]; return }}
1002    & $LeanCtxBin -c @args
1003    if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
1004      $cmd = $args[0]; $rest = $args[1..($args.Length)]
1005      & $cmd @rest
1006    }}
1007  }}
1008  function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
1009  if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
1010    function git {{ _lc git @args }}
1011    function cargo {{ _lc cargo @args }}
1012    function docker {{ _lc docker @args }}
1013    function kubectl {{ _lc kubectl @args }}
1014    function gh {{ _lc gh @args }}
1015    function pip {{ _lc pip @args }}
1016    function pip3 {{ _lc pip3 @args }}
1017    function ruff {{ _lc ruff @args }}
1018    function go {{ _lc go @args }}
1019    function curl {{ _lc curl @args }}
1020    function wget {{ _lc wget @args }}
1021    foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
1022      $a = Get-Command $c -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
1023      if ($a) {{
1024        Set-Variable -Name "_lc_$c" -Value $a.Source -Scope Script
1025        New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc `$script:_lc_$c @args")) -Force | Out-Null
1026      }}
1027    }}
1028  }}
1029}}
1030"#
1031    );
1032
1033    backup_shell_config(&profile_path);
1034
1035    if let Ok(existing) = std::fs::read_to_string(&profile_path) {
1036        if existing.contains("lean-ctx shell hook") {
1037            let cleaned = remove_lean_ctx_block_ps(&existing);
1038            match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
1039                Ok(()) => {
1040                    println!("Updated lean-ctx functions in {}", profile_path.display());
1041                    println!("  Binary: {binary}");
1042                    return;
1043                }
1044                Err(e) => {
1045                    eprintln!("Error updating {}: {e}", profile_path.display());
1046                    return;
1047                }
1048            }
1049        }
1050    }
1051
1052    match std::fs::OpenOptions::new()
1053        .append(true)
1054        .create(true)
1055        .open(&profile_path)
1056    {
1057        Ok(mut f) => {
1058            use std::io::Write;
1059            let _ = f.write_all(functions.as_bytes());
1060            println!("Added lean-ctx functions to {}", profile_path.display());
1061            println!("  Binary: {binary}");
1062        }
1063        Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
1064    }
1065}
1066
1067fn remove_lean_ctx_block_ps(content: &str) -> String {
1068    let mut result = String::new();
1069    let mut in_block = false;
1070    let mut brace_depth = 0i32;
1071
1072    for line in content.lines() {
1073        if line.contains("lean-ctx shell hook") {
1074            in_block = true;
1075            continue;
1076        }
1077        if in_block {
1078            brace_depth += line.matches('{').count() as i32;
1079            brace_depth -= line.matches('}').count() as i32;
1080            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
1081                if line.trim() == "}" {
1082                    in_block = false;
1083                    brace_depth = 0;
1084                }
1085                continue;
1086            }
1087            continue;
1088        }
1089        result.push_str(line);
1090        result.push('\n');
1091    }
1092    result
1093}
1094
1095fn init_fish(binary: &str) {
1096    let config = dirs::home_dir()
1097        .map(|h| h.join(".config/fish/config.fish"))
1098        .unwrap_or_default();
1099
1100    let aliases = format!(
1101        "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
1102        set -g _lean_ctx_cmds git npm pnpm yarn cargo docker docker-compose kubectl gh pip pip3 ruff go golangci-lint eslint prettier tsc ls find grep curl wget\n\
1103        \n\
1104        function _lc\n\
1105        \tif set -q LEAN_CTX_DISABLED\n\
1106        \t\tcommand $argv\n\
1107        \t\treturn\n\
1108        \tend\n\
1109        \t'{binary}' -c $argv\n\
1110        \tset -l _lc_rc $status\n\
1111        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1112        \t\tcommand $argv\n\
1113        \telse\n\
1114        \t\treturn $_lc_rc\n\
1115        \tend\n\
1116        end\n\
1117        \n\
1118        function lean-ctx-on\n\
1119        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1120        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
1121        \tend\n\
1122        \talias k '_lc kubectl'\n\
1123        \tset -gx LEAN_CTX_ENABLED 1\n\
1124        \techo 'lean-ctx: ON'\n\
1125        end\n\
1126        \n\
1127        function lean-ctx-off\n\
1128        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1129        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
1130        \tend\n\
1131        \tfunctions --erase k 2>/dev/null; true\n\
1132        \tset -e LEAN_CTX_ENABLED\n\
1133        \techo 'lean-ctx: OFF'\n\
1134        end\n\
1135        \n\
1136        function lean-ctx-raw\n\
1137        \tset -lx LEAN_CTX_RAW 1\n\
1138        \tcommand $argv\n\
1139        end\n\
1140        \n\
1141        function lean-ctx-status\n\
1142        \tif set -q LEAN_CTX_DISABLED\n\
1143        \t\techo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
1144        \telse if set -q LEAN_CTX_ENABLED\n\
1145        \t\techo 'lean-ctx: ON'\n\
1146        \telse\n\
1147        \t\techo 'lean-ctx: OFF'\n\
1148        \tend\n\
1149        end\n\
1150        \n\
1151        if not set -q LEAN_CTX_ACTIVE; and not set -q LEAN_CTX_DISABLED; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
1152        \tif command -q lean-ctx\n\
1153        \t\tlean-ctx-on\n\
1154        \tend\n\
1155        end\n\
1156        # lean-ctx shell hook — end\n"
1157    );
1158
1159    backup_shell_config(&config);
1160
1161    if let Ok(existing) = std::fs::read_to_string(&config) {
1162        if existing.contains("lean-ctx shell hook") {
1163            let cleaned = remove_lean_ctx_block(&existing);
1164            match std::fs::write(&config, format!("{cleaned}{aliases}")) {
1165                Ok(()) => {
1166                    println!("Updated lean-ctx aliases in {}", config.display());
1167                    println!("  Binary: {binary}");
1168                    return;
1169                }
1170                Err(e) => {
1171                    eprintln!("Error updating {}: {e}", config.display());
1172                    return;
1173                }
1174            }
1175        }
1176    }
1177
1178    match std::fs::OpenOptions::new()
1179        .append(true)
1180        .create(true)
1181        .open(&config)
1182    {
1183        Ok(mut f) => {
1184            use std::io::Write;
1185            let _ = f.write_all(aliases.as_bytes());
1186            println!("Added lean-ctx aliases to {}", config.display());
1187            println!("  Binary: {binary}");
1188        }
1189        Err(e) => eprintln!("Error writing {}: {e}", config.display()),
1190    }
1191}
1192
1193fn init_posix(is_zsh: bool, binary: &str) {
1194    let rc_file = if is_zsh {
1195        dirs::home_dir()
1196            .map(|h| h.join(".zshrc"))
1197            .unwrap_or_default()
1198    } else {
1199        dirs::home_dir()
1200            .map(|h| h.join(".bashrc"))
1201            .unwrap_or_default()
1202    };
1203
1204    let aliases = format!(
1205        r#"
1206# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1207_lean_ctx_cmds=(git npm pnpm yarn cargo docker docker-compose kubectl gh pip pip3 ruff go golangci-lint eslint prettier tsc ls find grep curl wget php composer)
1208
1209_lc() {{
1210    if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1211        command "$@"
1212        return
1213    fi
1214    '{binary}' -c "$@"
1215    local _lc_rc=$?
1216    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1217        command "$@"
1218    else
1219        return "$_lc_rc"
1220    fi
1221}}
1222
1223lean-ctx-on() {{
1224    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1225        # shellcheck disable=SC2139
1226        alias "$_lc_cmd"='_lc '"$_lc_cmd"
1227    done
1228    alias k='_lc kubectl'
1229    export LEAN_CTX_ENABLED=1
1230    echo "lean-ctx: ON"
1231}}
1232
1233lean-ctx-off() {{
1234    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1235        unalias "$_lc_cmd" 2>/dev/null || true
1236    done
1237    unalias k 2>/dev/null || true
1238    unset LEAN_CTX_ENABLED
1239    echo "lean-ctx: OFF"
1240}}
1241
1242lean-ctx-raw() {{
1243    LEAN_CTX_RAW=1 command "$@"
1244}}
1245
1246lean-ctx-status() {{
1247    if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1248        echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
1249    elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1250        echo "lean-ctx: ON"
1251    else
1252        echo "lean-ctx: OFF"
1253    fi
1254}}
1255
1256if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1257    command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
1258fi
1259# lean-ctx shell hook — end
1260"#
1261    );
1262
1263    backup_shell_config(&rc_file);
1264
1265    if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1266        if existing.contains("lean-ctx shell hook") {
1267            let cleaned = remove_lean_ctx_block(&existing);
1268            match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1269                Ok(()) => {
1270                    println!("Updated lean-ctx aliases in {}", rc_file.display());
1271                    println!("  Binary: {binary}");
1272                    return;
1273                }
1274                Err(e) => {
1275                    eprintln!("Error updating {}: {e}", rc_file.display());
1276                    return;
1277                }
1278            }
1279        }
1280    }
1281
1282    match std::fs::OpenOptions::new()
1283        .append(true)
1284        .create(true)
1285        .open(&rc_file)
1286    {
1287        Ok(mut f) => {
1288            use std::io::Write;
1289            let _ = f.write_all(aliases.as_bytes());
1290            println!("Added lean-ctx aliases to {}", rc_file.display());
1291            println!("  Binary: {binary}");
1292        }
1293        Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1294    }
1295}
1296
1297fn remove_lean_ctx_block(content: &str) -> String {
1298    // New format uses explicit end marker; old format ends at first top-level `fi`/`end`.
1299    if content.contains("# lean-ctx shell hook — end") {
1300        return remove_lean_ctx_block_by_marker(content);
1301    }
1302    remove_lean_ctx_block_legacy(content)
1303}
1304
1305fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1306    let mut result = String::new();
1307    let mut in_block = false;
1308
1309    for line in content.lines() {
1310        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1311            in_block = true;
1312            continue;
1313        }
1314        if in_block {
1315            if line.trim() == "# lean-ctx shell hook — end" {
1316                in_block = false;
1317            }
1318            continue;
1319        }
1320        result.push_str(line);
1321        result.push('\n');
1322    }
1323    result
1324}
1325
1326fn remove_lean_ctx_block_legacy(content: &str) -> String {
1327    let mut result = String::new();
1328    let mut in_block = false;
1329
1330    for line in content.lines() {
1331        if line.contains("lean-ctx shell hook") {
1332            in_block = true;
1333            continue;
1334        }
1335        if in_block {
1336            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1337                if line.trim() == "fi" || line.trim() == "end" {
1338                    in_block = false;
1339                }
1340                continue;
1341            }
1342            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1343                in_block = false;
1344                result.push_str(line);
1345                result.push('\n');
1346            }
1347            continue;
1348        }
1349        result.push_str(line);
1350        result.push('\n');
1351    }
1352    result
1353}
1354
1355pub fn load_shell_history_pub() -> Vec<String> {
1356    load_shell_history()
1357}
1358
1359fn load_shell_history() -> Vec<String> {
1360    let shell = std::env::var("SHELL").unwrap_or_default();
1361    let home = match dirs::home_dir() {
1362        Some(h) => h,
1363        None => return Vec::new(),
1364    };
1365
1366    let history_file = if shell.contains("zsh") {
1367        home.join(".zsh_history")
1368    } else if shell.contains("fish") {
1369        home.join(".local/share/fish/fish_history")
1370    } else if cfg!(windows) && shell.is_empty() {
1371        home.join("AppData")
1372            .join("Roaming")
1373            .join("Microsoft")
1374            .join("Windows")
1375            .join("PowerShell")
1376            .join("PSReadLine")
1377            .join("ConsoleHost_history.txt")
1378    } else {
1379        home.join(".bash_history")
1380    };
1381
1382    match std::fs::read_to_string(&history_file) {
1383        Ok(content) => content
1384            .lines()
1385            .filter_map(|l| {
1386                let trimmed = l.trim();
1387                if trimmed.starts_with(':') {
1388                    trimmed.split(';').nth(1).map(|s| s.to_string())
1389                } else {
1390                    Some(trimmed.to_string())
1391                }
1392            })
1393            .filter(|l| !l.is_empty())
1394            .collect(),
1395        Err(_) => Vec::new(),
1396    }
1397}
1398
1399fn print_savings(original: usize, sent: usize) {
1400    let saved = original.saturating_sub(sent);
1401    if original > 0 && saved > 0 {
1402        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1403        println!("[{saved} tok saved ({pct}%)]");
1404    }
1405}
1406
1407pub fn cmd_theme(args: &[String]) {
1408    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1409    let r = theme::rst();
1410    let b = theme::bold();
1411    let d = theme::dim();
1412
1413    match sub {
1414        "list" => {
1415            let cfg = config::Config::load();
1416            let active = cfg.theme.as_str();
1417            println!();
1418            println!("  {b}Available themes:{r}");
1419            println!("  {ln}", ln = "─".repeat(40));
1420            for name in theme::PRESET_NAMES {
1421                let marker = if *name == active { " ◀ active" } else { "" };
1422                let t = theme::from_preset(name).unwrap();
1423                let preview = format!(
1424                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1425                    p = t.primary.fg(),
1426                    s = t.secondary.fg(),
1427                    a = t.accent.fg(),
1428                    sc = t.success.fg(),
1429                    w = t.warning.fg(),
1430                );
1431                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1432            }
1433            if let Some(path) = theme::theme_file_path() {
1434                if path.exists() {
1435                    let custom = theme::load_theme("_custom_");
1436                    let preview = format!(
1437                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1438                        p = custom.primary.fg(),
1439                        s = custom.secondary.fg(),
1440                        a = custom.accent.fg(),
1441                        sc = custom.success.fg(),
1442                        w = custom.warning.fg(),
1443                    );
1444                    let marker = if active == "custom" {
1445                        " ◀ active"
1446                    } else {
1447                        ""
1448                    };
1449                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1450                }
1451            }
1452            println!();
1453            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1454            println!();
1455        }
1456        "set" => {
1457            if args.len() < 2 {
1458                eprintln!("Usage: lean-ctx theme set <name>");
1459                std::process::exit(1);
1460            }
1461            let name = &args[1];
1462            if theme::from_preset(name).is_none() && name != "custom" {
1463                eprintln!(
1464                    "Unknown theme '{name}'. Available: {}",
1465                    theme::PRESET_NAMES.join(", ")
1466                );
1467                std::process::exit(1);
1468            }
1469            let mut cfg = config::Config::load();
1470            cfg.theme = name.to_string();
1471            match cfg.save() {
1472                Ok(()) => {
1473                    let t = theme::load_theme(name);
1474                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1475                    let preview = t.gradient_bar(0.75, 30);
1476                    println!("  {preview}");
1477                }
1478                Err(e) => eprintln!("Error: {e}"),
1479            }
1480        }
1481        "export" => {
1482            let cfg = config::Config::load();
1483            let t = theme::load_theme(&cfg.theme);
1484            println!("{}", t.to_toml());
1485        }
1486        "import" => {
1487            if args.len() < 2 {
1488                eprintln!("Usage: lean-ctx theme import <path>");
1489                std::process::exit(1);
1490            }
1491            let path = std::path::Path::new(&args[1]);
1492            if !path.exists() {
1493                eprintln!("File not found: {}", args[1]);
1494                std::process::exit(1);
1495            }
1496            match std::fs::read_to_string(path) {
1497                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1498                    Ok(imported) => match theme::save_theme(&imported) {
1499                        Ok(()) => {
1500                            let mut cfg = config::Config::load();
1501                            cfg.theme = "custom".to_string();
1502                            let _ = cfg.save();
1503                            println!(
1504                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1505                                sc = imported.success.fg(),
1506                                name = imported.name,
1507                            );
1508                            println!("  Config updated: theme = custom");
1509                        }
1510                        Err(e) => eprintln!("Error saving theme: {e}"),
1511                    },
1512                    Err(e) => eprintln!("Invalid theme file: {e}"),
1513                },
1514                Err(e) => eprintln!("Error reading file: {e}"),
1515            }
1516        }
1517        "preview" => {
1518            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1519            let t = match theme::from_preset(name) {
1520                Some(t) => t,
1521                None => {
1522                    eprintln!("Unknown theme: {name}");
1523                    std::process::exit(1);
1524                }
1525            };
1526            println!();
1527            println!(
1528                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1529                icon = t.header_icon(),
1530                title = t.brand_title(),
1531            );
1532            println!("  {ln}", ln = t.border_line(50));
1533            println!();
1534            println!(
1535                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1536                sc = t.success.fg(),
1537                sec = t.secondary.fg(),
1538                wrn = t.warning.fg(),
1539                acc = t.accent.fg(),
1540            );
1541            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1542            println!();
1543            println!(
1544                "  {b}{txt}Gradient Bar{r}      {bar}",
1545                txt = t.text.fg(),
1546                bar = t.gradient_bar(0.85, 30),
1547            );
1548            println!(
1549                "  {b}{txt}Sparkline{r}         {spark}",
1550                txt = t.text.fg(),
1551                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1552            );
1553            println!();
1554            println!("  {top}", top = t.box_top(50));
1555            println!(
1556                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1557                side = t.box_side(),
1558                side_r = t.box_side(),
1559                txt = t.text.fg(),
1560            );
1561            println!("  {bot}", bot = t.box_bottom(50));
1562            println!();
1563        }
1564        _ => {
1565            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1566            std::process::exit(1);
1567        }
1568    }
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573    use super::*;
1574
1575    #[test]
1576    fn test_remove_lean_ctx_block_posix() {
1577        let input = r#"# existing config
1578export PATH="$HOME/bin:$PATH"
1579
1580# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1581if [ -z "$LEAN_CTX_ACTIVE" ]; then
1582alias git='lean-ctx -c git'
1583alias npm='lean-ctx -c npm'
1584fi
1585
1586# other stuff
1587export EDITOR=vim
1588"#;
1589        let result = remove_lean_ctx_block(input);
1590        assert!(!result.contains("lean-ctx"), "block should be removed");
1591        assert!(result.contains("export PATH"), "other content preserved");
1592        assert!(
1593            result.contains("export EDITOR"),
1594            "trailing content preserved"
1595        );
1596    }
1597
1598    #[test]
1599    fn test_remove_lean_ctx_block_fish() {
1600        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";
1601        let result = remove_lean_ctx_block(input);
1602        assert!(!result.contains("lean-ctx"), "block should be removed");
1603        assert!(result.contains("set -x FOO"), "other content preserved");
1604        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1605    }
1606
1607    #[test]
1608    fn test_remove_lean_ctx_block_ps() {
1609        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";
1610        let result = remove_lean_ctx_block_ps(input);
1611        assert!(
1612            !result.contains("lean-ctx shell hook"),
1613            "block should be removed"
1614        );
1615        assert!(result.contains("$env:FOO"), "other content preserved");
1616        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1617    }
1618
1619    #[test]
1620    fn test_remove_lean_ctx_block_ps_nested() {
1621        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";
1622        let result = remove_lean_ctx_block_ps(input);
1623        assert!(
1624            !result.contains("lean-ctx shell hook"),
1625            "block should be removed"
1626        );
1627        assert!(!result.contains("_lc"), "function should be removed");
1628        assert!(result.contains("$env:FOO"), "other content preserved");
1629        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1630    }
1631
1632    #[test]
1633    fn test_remove_block_no_lean_ctx() {
1634        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1635        let result = remove_lean_ctx_block(input);
1636        assert!(result.contains("export PATH"), "content unchanged");
1637    }
1638
1639    #[test]
1640    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1641        let input = r#"# existing config
1642export PATH="$HOME/bin:$PATH"
1643
1644# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1645_lean_ctx_cmds=(git npm pnpm)
1646
1647lean-ctx-on() {
1648    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1649        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1650    done
1651    export LEAN_CTX_ENABLED=1
1652    echo "lean-ctx: ON"
1653}
1654
1655lean-ctx-off() {
1656    unset LEAN_CTX_ENABLED
1657    echo "lean-ctx: OFF"
1658}
1659
1660if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1661    lean-ctx-on
1662fi
1663# lean-ctx shell hook — end
1664
1665# other stuff
1666export EDITOR=vim
1667"#;
1668        let result = remove_lean_ctx_block(input);
1669        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1670        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1671        assert!(result.contains("export PATH"), "other content preserved");
1672        assert!(
1673            result.contains("export EDITOR"),
1674            "trailing content preserved"
1675        );
1676    }
1677}