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            if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
892                eprintln!("MCP config for '{agent_name}' not updated: {e}");
893            }
894        }
895        if !global {
896            crate::hooks::install_project_rules();
897        }
898        println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
899        return;
900    }
901
902    let shell_name = std::env::var("SHELL").unwrap_or_default();
903    let is_zsh = shell_name.contains("zsh");
904    let is_fish = shell_name.contains("fish");
905    let is_powershell = cfg!(windows) && shell_name.is_empty();
906
907    let binary = std::env::current_exe()
908        .map(|p| p.to_string_lossy().to_string())
909        .unwrap_or_else(|_| "lean-ctx".to_string());
910
911    if dry_run {
912        let rc = if is_powershell {
913            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
914        } else if is_fish {
915            "~/.config/fish/config.fish".to_string()
916        } else if is_zsh {
917            "~/.zshrc".to_string()
918        } else {
919            "~/.bashrc".to_string()
920        };
921        println!("\nlean-ctx init --dry-run\n");
922        println!("  Would modify:  {rc}");
923        println!("  Would backup:  {rc}.lean-ctx.bak");
924        println!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
925        println!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
926        println!("                 ls find grep curl wget php composer (24 commands + k)");
927        println!("  Would create:  ~/.lean-ctx/");
928        println!("  Binary:        {binary}");
929        println!("\n  Safety: aliases auto-fallback to original command if lean-ctx is removed.");
930        println!("\n  Run without --dry-run to apply.");
931        return;
932    }
933
934    if is_powershell {
935        init_powershell(&binary);
936    } else {
937        let bash_binary = to_bash_compatible_path(&binary);
938        if is_fish {
939            init_fish(&bash_binary);
940        } else {
941            init_posix(is_zsh, &bash_binary);
942        }
943    }
944
945    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
946    if let Some(dir) = lean_dir {
947        if !dir.exists() {
948            let _ = std::fs::create_dir_all(&dir);
949            println!("Created {}", dir.display());
950        }
951    }
952
953    let rc = if is_powershell {
954        "$PROFILE"
955    } else if is_fish {
956        "config.fish"
957    } else if is_zsh {
958        ".zshrc"
959    } else {
960        ".bashrc"
961    };
962
963    println!("\nlean-ctx init complete (24 aliases installed)");
964    println!();
965    println!("  Disable temporarily:  lean-ctx-off");
966    println!("  Re-enable:            lean-ctx-on");
967    println!("  Check status:         lean-ctx-status");
968    println!("  Full uninstall:       lean-ctx uninstall");
969    println!("  Diagnose issues:      lean-ctx doctor");
970    println!("  Preview changes:      lean-ctx init --global --dry-run");
971    println!();
972    if is_powershell {
973        println!("  Restart PowerShell or run: . {rc}");
974    } else {
975        println!("  Restart your shell or run: source ~/{rc}");
976    }
977    println!();
978    println!("For AI tool integration: lean-ctx init --agent <tool>");
979    println!("  Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, crush, pi");
980}
981
982fn backup_shell_config(path: &std::path::Path) {
983    if !path.exists() {
984        return;
985    }
986    let bak = path.with_extension("lean-ctx.bak");
987    if std::fs::copy(path, &bak).is_ok() {
988        println!(
989            "  Backup: {}",
990            bak.file_name()
991                .map(|n| format!("~/{}", n.to_string_lossy()))
992                .unwrap_or_else(|| bak.display().to_string())
993        );
994    }
995}
996
997pub fn init_powershell(binary: &str) {
998    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
999    let profile_path = match profile_dir {
1000        Some(dir) => {
1001            let _ = std::fs::create_dir_all(&dir);
1002            dir.join("Microsoft.PowerShell_profile.ps1")
1003        }
1004        None => {
1005            eprintln!("Could not resolve PowerShell profile directory");
1006            return;
1007        }
1008    };
1009
1010    let binary_escaped = binary.replace('\\', "\\\\");
1011    let functions = format!(
1012        r#"
1013# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1014if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED) {{
1015  $LeanCtxBin = "{binary_escaped}"
1016  function _lc {{
1017    if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) {{ & $args[0] $args[1..($args.Length)]; return }}
1018    & $LeanCtxBin -c @args
1019    if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
1020      $cmd = $args[0]; $rest = $args[1..($args.Length)]
1021      & $cmd @rest
1022    }}
1023  }}
1024  function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
1025  if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
1026    function git {{ _lc git @args }}
1027    function cargo {{ _lc cargo @args }}
1028    function docker {{ _lc docker @args }}
1029    function kubectl {{ _lc kubectl @args }}
1030    function gh {{ _lc gh @args }}
1031    function pip {{ _lc pip @args }}
1032    function pip3 {{ _lc pip3 @args }}
1033    function ruff {{ _lc ruff @args }}
1034    function go {{ _lc go @args }}
1035    function curl {{ _lc curl @args }}
1036    function wget {{ _lc wget @args }}
1037    foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
1038      $a = Get-Command $c -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
1039      if ($a) {{
1040        Set-Variable -Name "_lc_$c" -Value $a.Source -Scope Script
1041        New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc `$script:_lc_$c @args")) -Force | Out-Null
1042      }}
1043    }}
1044  }}
1045}}
1046"#
1047    );
1048
1049    backup_shell_config(&profile_path);
1050
1051    if let Ok(existing) = std::fs::read_to_string(&profile_path) {
1052        if existing.contains("lean-ctx shell hook") {
1053            let cleaned = remove_lean_ctx_block_ps(&existing);
1054            match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
1055                Ok(()) => {
1056                    println!("Updated lean-ctx functions in {}", profile_path.display());
1057                    println!("  Binary: {binary}");
1058                    return;
1059                }
1060                Err(e) => {
1061                    eprintln!("Error updating {}: {e}", profile_path.display());
1062                    return;
1063                }
1064            }
1065        }
1066    }
1067
1068    match std::fs::OpenOptions::new()
1069        .append(true)
1070        .create(true)
1071        .open(&profile_path)
1072    {
1073        Ok(mut f) => {
1074            use std::io::Write;
1075            let _ = f.write_all(functions.as_bytes());
1076            println!("Added lean-ctx functions to {}", profile_path.display());
1077            println!("  Binary: {binary}");
1078        }
1079        Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
1080    }
1081}
1082
1083fn remove_lean_ctx_block_ps(content: &str) -> String {
1084    let mut result = String::new();
1085    let mut in_block = false;
1086    let mut brace_depth = 0i32;
1087
1088    for line in content.lines() {
1089        if line.contains("lean-ctx shell hook") {
1090            in_block = true;
1091            continue;
1092        }
1093        if in_block {
1094            brace_depth += line.matches('{').count() as i32;
1095            brace_depth -= line.matches('}').count() as i32;
1096            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
1097                if line.trim() == "}" {
1098                    in_block = false;
1099                    brace_depth = 0;
1100                }
1101                continue;
1102            }
1103            continue;
1104        }
1105        result.push_str(line);
1106        result.push('\n');
1107    }
1108    result
1109}
1110
1111pub fn init_fish(binary: &str) {
1112    let config = dirs::home_dir()
1113        .map(|h| h.join(".config/fish/config.fish"))
1114        .unwrap_or_default();
1115
1116    let aliases = format!(
1117        "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
1118        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\
1119        \n\
1120        function _lc\n\
1121        \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1122        \t\tcommand $argv\n\
1123        \t\treturn\n\
1124        \tend\n\
1125        \t'{binary}' -c $argv\n\
1126        \tset -l _lc_rc $status\n\
1127        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1128        \t\tcommand $argv\n\
1129        \telse\n\
1130        \t\treturn $_lc_rc\n\
1131        \tend\n\
1132        end\n\
1133        \n\
1134        function lean-ctx-on\n\
1135        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1136        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
1137        \tend\n\
1138        \talias k '_lc kubectl'\n\
1139        \tset -gx LEAN_CTX_ENABLED 1\n\
1140        \techo 'lean-ctx: ON'\n\
1141        end\n\
1142        \n\
1143        function lean-ctx-off\n\
1144        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1145        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
1146        \tend\n\
1147        \tfunctions --erase k 2>/dev/null; true\n\
1148        \tset -e LEAN_CTX_ENABLED\n\
1149        \techo 'lean-ctx: OFF'\n\
1150        end\n\
1151        \n\
1152        function lean-ctx-raw\n\
1153        \tset -lx LEAN_CTX_RAW 1\n\
1154        \tcommand $argv\n\
1155        end\n\
1156        \n\
1157        function lean-ctx-status\n\
1158        \tif set -q LEAN_CTX_DISABLED\n\
1159        \t\techo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
1160        \telse if set -q LEAN_CTX_ENABLED\n\
1161        \t\techo 'lean-ctx: ON'\n\
1162        \telse\n\
1163        \t\techo 'lean-ctx: OFF'\n\
1164        \tend\n\
1165        end\n\
1166        \n\
1167        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\
1168        \tif command -q lean-ctx\n\
1169        \t\tlean-ctx-on\n\
1170        \tend\n\
1171        end\n\
1172        # lean-ctx shell hook — end\n"
1173    );
1174
1175    backup_shell_config(&config);
1176
1177    if let Ok(existing) = std::fs::read_to_string(&config) {
1178        if existing.contains("lean-ctx shell hook") {
1179            let cleaned = remove_lean_ctx_block(&existing);
1180            match std::fs::write(&config, format!("{cleaned}{aliases}")) {
1181                Ok(()) => {
1182                    println!("Updated lean-ctx aliases in {}", config.display());
1183                    println!("  Binary: {binary}");
1184                    return;
1185                }
1186                Err(e) => {
1187                    eprintln!("Error updating {}: {e}", config.display());
1188                    return;
1189                }
1190            }
1191        }
1192    }
1193
1194    match std::fs::OpenOptions::new()
1195        .append(true)
1196        .create(true)
1197        .open(&config)
1198    {
1199        Ok(mut f) => {
1200            use std::io::Write;
1201            let _ = f.write_all(aliases.as_bytes());
1202            println!("Added lean-ctx aliases to {}", config.display());
1203            println!("  Binary: {binary}");
1204        }
1205        Err(e) => eprintln!("Error writing {}: {e}", config.display()),
1206    }
1207}
1208
1209pub fn init_posix(is_zsh: bool, binary: &str) {
1210    let rc_file = if is_zsh {
1211        dirs::home_dir()
1212            .map(|h| h.join(".zshrc"))
1213            .unwrap_or_default()
1214    } else {
1215        dirs::home_dir()
1216            .map(|h| h.join(".bashrc"))
1217            .unwrap_or_default()
1218    };
1219
1220    let aliases = format!(
1221        r#"
1222# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1223_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)
1224
1225_lc() {{
1226    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1227        command "$@"
1228        return
1229    fi
1230    '{binary}' -c "$@"
1231    local _lc_rc=$?
1232    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1233        command "$@"
1234    else
1235        return "$_lc_rc"
1236    fi
1237}}
1238
1239lean-ctx-on() {{
1240    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1241        # shellcheck disable=SC2139
1242        alias "$_lc_cmd"='_lc '"$_lc_cmd"
1243    done
1244    alias k='_lc kubectl'
1245    export LEAN_CTX_ENABLED=1
1246    echo "lean-ctx: ON"
1247}}
1248
1249lean-ctx-off() {{
1250    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1251        unalias "$_lc_cmd" 2>/dev/null || true
1252    done
1253    unalias k 2>/dev/null || true
1254    unset LEAN_CTX_ENABLED
1255    echo "lean-ctx: OFF"
1256}}
1257
1258lean-ctx-raw() {{
1259    LEAN_CTX_RAW=1 command "$@"
1260}}
1261
1262lean-ctx-status() {{
1263    if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1264        echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
1265    elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1266        echo "lean-ctx: ON"
1267    else
1268        echo "lean-ctx: OFF"
1269    fi
1270}}
1271
1272if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1273    command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
1274fi
1275# lean-ctx shell hook — end
1276"#
1277    );
1278
1279    backup_shell_config(&rc_file);
1280
1281    if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1282        if existing.contains("lean-ctx shell hook") {
1283            let cleaned = remove_lean_ctx_block(&existing);
1284            match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1285                Ok(()) => {
1286                    println!("Updated lean-ctx aliases in {}", rc_file.display());
1287                    println!("  Binary: {binary}");
1288                    return;
1289                }
1290                Err(e) => {
1291                    eprintln!("Error updating {}: {e}", rc_file.display());
1292                    return;
1293                }
1294            }
1295        }
1296    }
1297
1298    match std::fs::OpenOptions::new()
1299        .append(true)
1300        .create(true)
1301        .open(&rc_file)
1302    {
1303        Ok(mut f) => {
1304            use std::io::Write;
1305            let _ = f.write_all(aliases.as_bytes());
1306            println!("Added lean-ctx aliases to {}", rc_file.display());
1307            println!("  Binary: {binary}");
1308        }
1309        Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1310    }
1311}
1312
1313fn remove_lean_ctx_block(content: &str) -> String {
1314    // New format uses explicit end marker; old format ends at first top-level `fi`/`end`.
1315    if content.contains("# lean-ctx shell hook — end") {
1316        return remove_lean_ctx_block_by_marker(content);
1317    }
1318    remove_lean_ctx_block_legacy(content)
1319}
1320
1321fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1322    let mut result = String::new();
1323    let mut in_block = false;
1324
1325    for line in content.lines() {
1326        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1327            in_block = true;
1328            continue;
1329        }
1330        if in_block {
1331            if line.trim() == "# lean-ctx shell hook — end" {
1332                in_block = false;
1333            }
1334            continue;
1335        }
1336        result.push_str(line);
1337        result.push('\n');
1338    }
1339    result
1340}
1341
1342fn remove_lean_ctx_block_legacy(content: &str) -> String {
1343    let mut result = String::new();
1344    let mut in_block = false;
1345
1346    for line in content.lines() {
1347        if line.contains("lean-ctx shell hook") {
1348            in_block = true;
1349            continue;
1350        }
1351        if in_block {
1352            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1353                if line.trim() == "fi" || line.trim() == "end" {
1354                    in_block = false;
1355                }
1356                continue;
1357            }
1358            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1359                in_block = false;
1360                result.push_str(line);
1361                result.push('\n');
1362            }
1363            continue;
1364        }
1365        result.push_str(line);
1366        result.push('\n');
1367    }
1368    result
1369}
1370
1371pub fn load_shell_history_pub() -> Vec<String> {
1372    load_shell_history()
1373}
1374
1375fn load_shell_history() -> Vec<String> {
1376    let shell = std::env::var("SHELL").unwrap_or_default();
1377    let home = match dirs::home_dir() {
1378        Some(h) => h,
1379        None => return Vec::new(),
1380    };
1381
1382    let history_file = if shell.contains("zsh") {
1383        home.join(".zsh_history")
1384    } else if shell.contains("fish") {
1385        home.join(".local/share/fish/fish_history")
1386    } else if cfg!(windows) && shell.is_empty() {
1387        home.join("AppData")
1388            .join("Roaming")
1389            .join("Microsoft")
1390            .join("Windows")
1391            .join("PowerShell")
1392            .join("PSReadLine")
1393            .join("ConsoleHost_history.txt")
1394    } else {
1395        home.join(".bash_history")
1396    };
1397
1398    match std::fs::read_to_string(&history_file) {
1399        Ok(content) => content
1400            .lines()
1401            .filter_map(|l| {
1402                let trimmed = l.trim();
1403                if trimmed.starts_with(':') {
1404                    trimmed.split(';').nth(1).map(|s| s.to_string())
1405                } else {
1406                    Some(trimmed.to_string())
1407                }
1408            })
1409            .filter(|l| !l.is_empty())
1410            .collect(),
1411        Err(_) => Vec::new(),
1412    }
1413}
1414
1415fn print_savings(original: usize, sent: usize) {
1416    let saved = original.saturating_sub(sent);
1417    if original > 0 && saved > 0 {
1418        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1419        println!("[{saved} tok saved ({pct}%)]");
1420    }
1421}
1422
1423pub fn cmd_theme(args: &[String]) {
1424    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1425    let r = theme::rst();
1426    let b = theme::bold();
1427    let d = theme::dim();
1428
1429    match sub {
1430        "list" => {
1431            let cfg = config::Config::load();
1432            let active = cfg.theme.as_str();
1433            println!();
1434            println!("  {b}Available themes:{r}");
1435            println!("  {ln}", ln = "─".repeat(40));
1436            for name in theme::PRESET_NAMES {
1437                let marker = if *name == active { " ◀ active" } else { "" };
1438                let t = theme::from_preset(name).unwrap();
1439                let preview = format!(
1440                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1441                    p = t.primary.fg(),
1442                    s = t.secondary.fg(),
1443                    a = t.accent.fg(),
1444                    sc = t.success.fg(),
1445                    w = t.warning.fg(),
1446                );
1447                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1448            }
1449            if let Some(path) = theme::theme_file_path() {
1450                if path.exists() {
1451                    let custom = theme::load_theme("_custom_");
1452                    let preview = format!(
1453                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1454                        p = custom.primary.fg(),
1455                        s = custom.secondary.fg(),
1456                        a = custom.accent.fg(),
1457                        sc = custom.success.fg(),
1458                        w = custom.warning.fg(),
1459                    );
1460                    let marker = if active == "custom" {
1461                        " ◀ active"
1462                    } else {
1463                        ""
1464                    };
1465                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1466                }
1467            }
1468            println!();
1469            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1470            println!();
1471        }
1472        "set" => {
1473            if args.len() < 2 {
1474                eprintln!("Usage: lean-ctx theme set <name>");
1475                std::process::exit(1);
1476            }
1477            let name = &args[1];
1478            if theme::from_preset(name).is_none() && name != "custom" {
1479                eprintln!(
1480                    "Unknown theme '{name}'. Available: {}",
1481                    theme::PRESET_NAMES.join(", ")
1482                );
1483                std::process::exit(1);
1484            }
1485            let mut cfg = config::Config::load();
1486            cfg.theme = name.to_string();
1487            match cfg.save() {
1488                Ok(()) => {
1489                    let t = theme::load_theme(name);
1490                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1491                    let preview = t.gradient_bar(0.75, 30);
1492                    println!("  {preview}");
1493                }
1494                Err(e) => eprintln!("Error: {e}"),
1495            }
1496        }
1497        "export" => {
1498            let cfg = config::Config::load();
1499            let t = theme::load_theme(&cfg.theme);
1500            println!("{}", t.to_toml());
1501        }
1502        "import" => {
1503            if args.len() < 2 {
1504                eprintln!("Usage: lean-ctx theme import <path>");
1505                std::process::exit(1);
1506            }
1507            let path = std::path::Path::new(&args[1]);
1508            if !path.exists() {
1509                eprintln!("File not found: {}", args[1]);
1510                std::process::exit(1);
1511            }
1512            match std::fs::read_to_string(path) {
1513                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1514                    Ok(imported) => match theme::save_theme(&imported) {
1515                        Ok(()) => {
1516                            let mut cfg = config::Config::load();
1517                            cfg.theme = "custom".to_string();
1518                            let _ = cfg.save();
1519                            println!(
1520                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1521                                sc = imported.success.fg(),
1522                                name = imported.name,
1523                            );
1524                            println!("  Config updated: theme = custom");
1525                        }
1526                        Err(e) => eprintln!("Error saving theme: {e}"),
1527                    },
1528                    Err(e) => eprintln!("Invalid theme file: {e}"),
1529                },
1530                Err(e) => eprintln!("Error reading file: {e}"),
1531            }
1532        }
1533        "preview" => {
1534            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1535            let t = match theme::from_preset(name) {
1536                Some(t) => t,
1537                None => {
1538                    eprintln!("Unknown theme: {name}");
1539                    std::process::exit(1);
1540                }
1541            };
1542            println!();
1543            println!(
1544                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1545                icon = t.header_icon(),
1546                title = t.brand_title(),
1547            );
1548            println!("  {ln}", ln = t.border_line(50));
1549            println!();
1550            println!(
1551                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1552                sc = t.success.fg(),
1553                sec = t.secondary.fg(),
1554                wrn = t.warning.fg(),
1555                acc = t.accent.fg(),
1556            );
1557            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1558            println!();
1559            println!(
1560                "  {b}{txt}Gradient Bar{r}      {bar}",
1561                txt = t.text.fg(),
1562                bar = t.gradient_bar(0.85, 30),
1563            );
1564            println!(
1565                "  {b}{txt}Sparkline{r}         {spark}",
1566                txt = t.text.fg(),
1567                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1568            );
1569            println!();
1570            println!("  {top}", top = t.box_top(50));
1571            println!(
1572                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1573                side = t.box_side(),
1574                side_r = t.box_side(),
1575                txt = t.text.fg(),
1576            );
1577            println!("  {bot}", bot = t.box_bottom(50));
1578            println!();
1579        }
1580        _ => {
1581            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1582            std::process::exit(1);
1583        }
1584    }
1585}
1586
1587#[cfg(test)]
1588mod tests {
1589    use super::*;
1590
1591    #[test]
1592    fn test_remove_lean_ctx_block_posix() {
1593        let input = r#"# existing config
1594export PATH="$HOME/bin:$PATH"
1595
1596# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1597if [ -z "$LEAN_CTX_ACTIVE" ]; then
1598alias git='lean-ctx -c git'
1599alias npm='lean-ctx -c npm'
1600fi
1601
1602# other stuff
1603export EDITOR=vim
1604"#;
1605        let result = remove_lean_ctx_block(input);
1606        assert!(!result.contains("lean-ctx"), "block should be removed");
1607        assert!(result.contains("export PATH"), "other content preserved");
1608        assert!(
1609            result.contains("export EDITOR"),
1610            "trailing content preserved"
1611        );
1612    }
1613
1614    #[test]
1615    fn test_remove_lean_ctx_block_fish() {
1616        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";
1617        let result = remove_lean_ctx_block(input);
1618        assert!(!result.contains("lean-ctx"), "block should be removed");
1619        assert!(result.contains("set -x FOO"), "other content preserved");
1620        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1621    }
1622
1623    #[test]
1624    fn test_remove_lean_ctx_block_ps() {
1625        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";
1626        let result = remove_lean_ctx_block_ps(input);
1627        assert!(
1628            !result.contains("lean-ctx shell hook"),
1629            "block should be removed"
1630        );
1631        assert!(result.contains("$env:FOO"), "other content preserved");
1632        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1633    }
1634
1635    #[test]
1636    fn test_remove_lean_ctx_block_ps_nested() {
1637        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";
1638        let result = remove_lean_ctx_block_ps(input);
1639        assert!(
1640            !result.contains("lean-ctx shell hook"),
1641            "block should be removed"
1642        );
1643        assert!(!result.contains("_lc"), "function should be removed");
1644        assert!(result.contains("$env:FOO"), "other content preserved");
1645        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1646    }
1647
1648    #[test]
1649    fn test_remove_block_no_lean_ctx() {
1650        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1651        let result = remove_lean_ctx_block(input);
1652        assert!(result.contains("export PATH"), "content unchanged");
1653    }
1654
1655    #[test]
1656    fn test_bash_hook_contains_pipe_guard() {
1657        let binary = "/usr/local/bin/lean-ctx";
1658        let hook = format!(
1659            r#"_lc() {{
1660    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1661        command "$@"
1662        return
1663    fi
1664    '{binary}' -c "$@"
1665}}"#
1666        );
1667        assert!(
1668            hook.contains("! -t 1"),
1669            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1670        );
1671        assert!(
1672            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1673            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1674        );
1675    }
1676
1677    #[test]
1678    fn test_fish_hook_contains_pipe_guard() {
1679        let hook = "function _lc\n\tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\t\tcommand $argv\n\t\treturn\n\tend\nend";
1680        assert!(
1681            hook.contains("isatty stdout"),
1682            "fish hook must contain pipe guard (isatty stdout)"
1683        );
1684    }
1685
1686    #[test]
1687    fn test_powershell_hook_contains_pipe_guard() {
1688        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & $args[0] $args[1..($args.Length)]; return } }";
1689        assert!(
1690            hook.contains("IsOutputRedirected"),
1691            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1692        );
1693    }
1694
1695    #[test]
1696    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1697        let input = r#"# existing config
1698export PATH="$HOME/bin:$PATH"
1699
1700# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1701_lean_ctx_cmds=(git npm pnpm)
1702
1703lean-ctx-on() {
1704    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1705        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1706    done
1707    export LEAN_CTX_ENABLED=1
1708    echo "lean-ctx: ON"
1709}
1710
1711lean-ctx-off() {
1712    unset LEAN_CTX_ENABLED
1713    echo "lean-ctx: OFF"
1714}
1715
1716if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1717    lean-ctx-on
1718fi
1719# lean-ctx shell hook — end
1720
1721# other stuff
1722export EDITOR=vim
1723"#;
1724        let result = remove_lean_ctx_block(input);
1725        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1726        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1727        assert!(result.contains("export PATH"), "other content preserved");
1728        assert!(
1729            result.contains("export EDITOR"),
1730            "trailing content preserved"
1731        );
1732    }
1733}