Skip to main content

lean_ctx/cli/
mod.rs

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