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