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 {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    let no_hook = args.iter().any(|a| a == "--no-shell-hook")
1117        || crate::core::config::Config::load().shell_hook_disabled_effective();
1118
1119    let agents: Vec<&str> = args
1120        .windows(2)
1121        .filter(|w| w[0] == "--agent")
1122        .map(|w| w[1].as_str())
1123        .collect();
1124
1125    if !agents.is_empty() {
1126        for agent_name in &agents {
1127            crate::hooks::install_agent_hook(agent_name, global);
1128            if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
1129                eprintln!("MCP config for '{agent_name}' not updated: {e}");
1130            }
1131        }
1132        if !global {
1133            crate::hooks::install_project_rules();
1134        }
1135        qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
1136        return;
1137    }
1138
1139    let eval_shell = args
1140        .iter()
1141        .find(|a| matches!(a.as_str(), "bash" | "zsh" | "fish" | "powershell" | "pwsh"));
1142    if let Some(shell) = eval_shell {
1143        if !global {
1144            shell_init::print_hook_stdout(shell);
1145            return;
1146        }
1147    }
1148
1149    let shell_name = std::env::var("SHELL").unwrap_or_default();
1150    let is_zsh = shell_name.contains("zsh");
1151    let is_fish = shell_name.contains("fish");
1152    let is_powershell = cfg!(windows) && shell_name.is_empty();
1153
1154    let binary = crate::core::portable_binary::resolve_portable_binary();
1155
1156    if dry_run {
1157        let rc = if is_powershell {
1158            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
1159        } else if is_fish {
1160            "~/.config/fish/config.fish".to_string()
1161        } else if is_zsh {
1162            "~/.zshrc".to_string()
1163        } else {
1164            "~/.bashrc".to_string()
1165        };
1166        qprintln!("\nlean-ctx init --dry-run\n");
1167        qprintln!("  Would modify:  {rc}");
1168        qprintln!("  Would backup:  {rc}.lean-ctx.bak");
1169        qprintln!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
1170        qprintln!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
1171        qprintln!("                 curl wget php composer (24 commands + k)");
1172        let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1173            .map(|p| p.to_string_lossy().to_string())
1174            .unwrap_or_else(|_| "~/.config/lean-ctx/".to_string());
1175        qprintln!("  Would create:  {data_dir}");
1176        qprintln!("  Binary:        {binary}");
1177        qprintln!("\n  Safety: aliases auto-fallback to original command if lean-ctx is removed.");
1178        qprintln!("\n  Run without --dry-run to apply.");
1179        return;
1180    }
1181
1182    if no_hook {
1183        qprintln!("Shell hook disabled (--no-shell-hook or shell_hook_disabled config).");
1184        qprintln!("MCP tools remain active. Set LEAN_CTX_NO_HOOK=1 to disable at runtime.");
1185    } else if is_powershell {
1186        init_powershell(&binary);
1187    } else {
1188        let bash_binary = to_bash_compatible_path(&binary);
1189        if is_fish {
1190            init_fish(&bash_binary);
1191        } else {
1192            init_posix(is_zsh, &bash_binary);
1193        }
1194    }
1195
1196    if let Ok(lean_dir) = crate::core::data_dir::lean_ctx_data_dir() {
1197        if !lean_dir.exists() {
1198            let _ = std::fs::create_dir_all(&lean_dir);
1199            qprintln!("Created {}", lean_dir.display());
1200        }
1201    }
1202
1203    let rc = if is_powershell {
1204        "$PROFILE"
1205    } else if is_fish {
1206        "config.fish"
1207    } else if is_zsh {
1208        ".zshrc"
1209    } else {
1210        ".bashrc"
1211    };
1212
1213    qprintln!("\nlean-ctx init complete (24 aliases installed)");
1214    qprintln!();
1215    qprintln!("  Disable temporarily:  lean-ctx-off");
1216    qprintln!("  Re-enable:            lean-ctx-on");
1217    qprintln!("  Check status:         lean-ctx-status");
1218    qprintln!("  Full uninstall:       lean-ctx uninstall");
1219    qprintln!("  Diagnose issues:      lean-ctx doctor");
1220    qprintln!("  Preview changes:      lean-ctx init --global --dry-run");
1221    qprintln!();
1222    if is_powershell {
1223        qprintln!("  Restart PowerShell or run: . {rc}");
1224    } else {
1225        qprintln!("  Restart your shell or run: source ~/{rc}");
1226    }
1227    qprintln!();
1228    qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1229    qprintln!("  Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1230    qprintln!("    crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1231    qprintln!("    pi, qwen, roo, sublime, trae, verdent, windsurf");
1232}
1233
1234pub fn cmd_init_quiet(args: &[String]) {
1235    std::env::set_var("LEAN_CTX_QUIET", "1");
1236    cmd_init(args);
1237    std::env::remove_var("LEAN_CTX_QUIET");
1238}
1239
1240pub fn load_shell_history_pub() -> Vec<String> {
1241    load_shell_history()
1242}
1243
1244fn load_shell_history() -> Vec<String> {
1245    let shell = std::env::var("SHELL").unwrap_or_default();
1246    let home = match dirs::home_dir() {
1247        Some(h) => h,
1248        None => return Vec::new(),
1249    };
1250
1251    let history_file = if shell.contains("zsh") {
1252        home.join(".zsh_history")
1253    } else if shell.contains("fish") {
1254        home.join(".local/share/fish/fish_history")
1255    } else if cfg!(windows) && shell.is_empty() {
1256        home.join("AppData")
1257            .join("Roaming")
1258            .join("Microsoft")
1259            .join("Windows")
1260            .join("PowerShell")
1261            .join("PSReadLine")
1262            .join("ConsoleHost_history.txt")
1263    } else {
1264        home.join(".bash_history")
1265    };
1266
1267    match std::fs::read_to_string(&history_file) {
1268        Ok(content) => content
1269            .lines()
1270            .filter_map(|l| {
1271                let trimmed = l.trim();
1272                if trimmed.starts_with(':') {
1273                    trimmed.split(';').nth(1).map(|s| s.to_string())
1274                } else {
1275                    Some(trimmed.to_string())
1276                }
1277            })
1278            .filter(|l| !l.is_empty())
1279            .collect(),
1280        Err(_) => Vec::new(),
1281    }
1282}
1283
1284fn print_savings(original: usize, sent: usize) {
1285    let saved = original.saturating_sub(sent);
1286    if original > 0 && saved > 0 {
1287        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1288        println!("[{saved} tok saved ({pct}%)]");
1289    }
1290}
1291
1292pub fn cmd_theme(args: &[String]) {
1293    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1294    let r = theme::rst();
1295    let b = theme::bold();
1296    let d = theme::dim();
1297
1298    match sub {
1299        "list" => {
1300            let cfg = config::Config::load();
1301            let active = cfg.theme.as_str();
1302            println!();
1303            println!("  {b}Available themes:{r}");
1304            println!("  {ln}", ln = "─".repeat(40));
1305            for name in theme::PRESET_NAMES {
1306                let marker = if *name == active { " ◀ active" } else { "" };
1307                let t = theme::from_preset(name).unwrap();
1308                let preview = format!(
1309                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1310                    p = t.primary.fg(),
1311                    s = t.secondary.fg(),
1312                    a = t.accent.fg(),
1313                    sc = t.success.fg(),
1314                    w = t.warning.fg(),
1315                );
1316                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1317            }
1318            if let Some(path) = theme::theme_file_path() {
1319                if path.exists() {
1320                    let custom = theme::load_theme("_custom_");
1321                    let preview = format!(
1322                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1323                        p = custom.primary.fg(),
1324                        s = custom.secondary.fg(),
1325                        a = custom.accent.fg(),
1326                        sc = custom.success.fg(),
1327                        w = custom.warning.fg(),
1328                    );
1329                    let marker = if active == "custom" {
1330                        " ◀ active"
1331                    } else {
1332                        ""
1333                    };
1334                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1335                }
1336            }
1337            println!();
1338            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1339            println!();
1340        }
1341        "set" => {
1342            if args.len() < 2 {
1343                eprintln!("Usage: lean-ctx theme set <name>");
1344                std::process::exit(1);
1345            }
1346            let name = &args[1];
1347            if theme::from_preset(name).is_none() && name != "custom" {
1348                eprintln!(
1349                    "Unknown theme '{name}'. Available: {}",
1350                    theme::PRESET_NAMES.join(", ")
1351                );
1352                std::process::exit(1);
1353            }
1354            let mut cfg = config::Config::load();
1355            cfg.theme = name.to_string();
1356            match cfg.save() {
1357                Ok(()) => {
1358                    let t = theme::load_theme(name);
1359                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1360                    let preview = t.gradient_bar(0.75, 30);
1361                    println!("  {preview}");
1362                }
1363                Err(e) => eprintln!("Error: {e}"),
1364            }
1365        }
1366        "export" => {
1367            let cfg = config::Config::load();
1368            let t = theme::load_theme(&cfg.theme);
1369            println!("{}", t.to_toml());
1370        }
1371        "import" => {
1372            if args.len() < 2 {
1373                eprintln!("Usage: lean-ctx theme import <path>");
1374                std::process::exit(1);
1375            }
1376            let path = std::path::Path::new(&args[1]);
1377            if !path.exists() {
1378                eprintln!("File not found: {}", args[1]);
1379                std::process::exit(1);
1380            }
1381            match std::fs::read_to_string(path) {
1382                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1383                    Ok(imported) => match theme::save_theme(&imported) {
1384                        Ok(()) => {
1385                            let mut cfg = config::Config::load();
1386                            cfg.theme = "custom".to_string();
1387                            let _ = cfg.save();
1388                            println!(
1389                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1390                                sc = imported.success.fg(),
1391                                name = imported.name,
1392                            );
1393                            println!("  Config updated: theme = custom");
1394                        }
1395                        Err(e) => eprintln!("Error saving theme: {e}"),
1396                    },
1397                    Err(e) => eprintln!("Invalid theme file: {e}"),
1398                },
1399                Err(e) => eprintln!("Error reading file: {e}"),
1400            }
1401        }
1402        "preview" => {
1403            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1404            let t = match theme::from_preset(name) {
1405                Some(t) => t,
1406                None => {
1407                    eprintln!("Unknown theme: {name}");
1408                    std::process::exit(1);
1409                }
1410            };
1411            println!();
1412            println!(
1413                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1414                icon = t.header_icon(),
1415                title = t.brand_title(),
1416            );
1417            println!("  {ln}", ln = t.border_line(50));
1418            println!();
1419            println!(
1420                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1421                sc = t.success.fg(),
1422                sec = t.secondary.fg(),
1423                wrn = t.warning.fg(),
1424                acc = t.accent.fg(),
1425            );
1426            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1427            println!();
1428            println!(
1429                "  {b}{txt}Gradient Bar{r}      {bar}",
1430                txt = t.text.fg(),
1431                bar = t.gradient_bar(0.85, 30),
1432            );
1433            println!(
1434                "  {b}{txt}Sparkline{r}         {spark}",
1435                txt = t.text.fg(),
1436                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1437            );
1438            println!();
1439            println!("  {top}", top = t.box_top(50));
1440            println!(
1441                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1442                side = t.box_side(),
1443                side_r = t.box_side(),
1444                txt = t.text.fg(),
1445            );
1446            println!("  {bot}", bot = t.box_bottom(50));
1447            println!();
1448        }
1449        _ => {
1450            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1451            std::process::exit(1);
1452        }
1453    }
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458    use super::*;
1459    use tempfile;
1460
1461    #[test]
1462    fn test_remove_lean_ctx_block_posix() {
1463        let input = r#"# existing config
1464export PATH="$HOME/bin:$PATH"
1465
1466# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1467if [ -z "$LEAN_CTX_ACTIVE" ]; then
1468alias git='lean-ctx -c git'
1469alias npm='lean-ctx -c npm'
1470fi
1471
1472# other stuff
1473export EDITOR=vim
1474"#;
1475        let result = remove_lean_ctx_block(input);
1476        assert!(!result.contains("lean-ctx"), "block should be removed");
1477        assert!(result.contains("export PATH"), "other content preserved");
1478        assert!(
1479            result.contains("export EDITOR"),
1480            "trailing content preserved"
1481        );
1482    }
1483
1484    #[test]
1485    fn test_remove_lean_ctx_block_fish() {
1486        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";
1487        let result = remove_lean_ctx_block(input);
1488        assert!(!result.contains("lean-ctx"), "block should be removed");
1489        assert!(result.contains("set -x FOO"), "other content preserved");
1490        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1491    }
1492
1493    #[test]
1494    fn test_remove_lean_ctx_block_ps() {
1495        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";
1496        let result = remove_lean_ctx_block_ps(input);
1497        assert!(
1498            !result.contains("lean-ctx shell hook"),
1499            "block should be removed"
1500        );
1501        assert!(result.contains("$env:FOO"), "other content preserved");
1502        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1503    }
1504
1505    #[test]
1506    fn test_remove_lean_ctx_block_ps_nested() {
1507        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";
1508        let result = remove_lean_ctx_block_ps(input);
1509        assert!(
1510            !result.contains("lean-ctx shell hook"),
1511            "block should be removed"
1512        );
1513        assert!(!result.contains("_lc"), "function should be removed");
1514        assert!(result.contains("$env:FOO"), "other content preserved");
1515        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1516    }
1517
1518    #[test]
1519    fn test_remove_block_no_lean_ctx() {
1520        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1521        let result = remove_lean_ctx_block(input);
1522        assert!(result.contains("export PATH"), "content unchanged");
1523    }
1524
1525    #[test]
1526    fn test_bash_hook_contains_pipe_guard() {
1527        let binary = "/usr/local/bin/lean-ctx";
1528        let hook = format!(
1529            r#"_lc() {{
1530    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1531        command "$@"
1532        return
1533    fi
1534    '{binary}' -t "$@"
1535}}"#
1536        );
1537        assert!(
1538            hook.contains("! -t 1"),
1539            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1540        );
1541        assert!(
1542            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1543            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1544        );
1545    }
1546
1547    #[test]
1548    fn test_lc_uses_track_mode_by_default() {
1549        let binary = "/usr/local/bin/lean-ctx";
1550        let alias_list = crate::rewrite_registry::shell_alias_list();
1551        let aliases = format!(
1552            r#"_lc() {{
1553    '{binary}' -t "$@"
1554}}
1555_lc_compress() {{
1556    '{binary}' -c "$@"
1557}}"#
1558        );
1559        assert!(
1560            aliases.contains("-t \"$@\""),
1561            "_lc must use -t (track mode) by default"
1562        );
1563        assert!(
1564            aliases.contains("-c \"$@\""),
1565            "_lc_compress must use -c (compress mode)"
1566        );
1567        let _ = alias_list;
1568    }
1569
1570    #[test]
1571    fn test_posix_shell_has_lean_ctx_mode() {
1572        let alias_list = crate::rewrite_registry::shell_alias_list();
1573        let aliases = r#"
1574lean-ctx-mode() {{
1575    case "${{1:-}}" in
1576        compress) echo compress ;;
1577        track) echo track ;;
1578        off) echo off ;;
1579    esac
1580}}
1581"#
1582        .to_string();
1583        assert!(
1584            aliases.contains("lean-ctx-mode()"),
1585            "lean-ctx-mode function must exist"
1586        );
1587        assert!(
1588            aliases.contains("compress"),
1589            "compress mode must be available"
1590        );
1591        assert!(aliases.contains("track"), "track mode must be available");
1592        let _ = alias_list;
1593    }
1594
1595    #[test]
1596    fn test_fish_hook_contains_pipe_guard() {
1597        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";
1598        assert!(
1599            hook.contains("isatty stdout"),
1600            "fish hook must contain pipe guard (isatty stdout)"
1601        );
1602    }
1603
1604    #[test]
1605    fn test_powershell_hook_contains_pipe_guard() {
1606        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1607        assert!(
1608            hook.contains("IsOutputRedirected"),
1609            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1610        );
1611    }
1612
1613    #[test]
1614    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1615        let input = r#"# existing config
1616export PATH="$HOME/bin:$PATH"
1617
1618# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1619_lean_ctx_cmds=(git npm pnpm)
1620
1621lean-ctx-on() {
1622    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1623        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1624    done
1625    export LEAN_CTX_ENABLED=1
1626    [ -t 1 ] && echo "lean-ctx: ON"
1627}
1628
1629lean-ctx-off() {
1630    unset LEAN_CTX_ENABLED
1631    [ -t 1 ] && echo "lean-ctx: OFF"
1632}
1633
1634if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1635    lean-ctx-on
1636fi
1637# lean-ctx shell hook — end
1638
1639# other stuff
1640export EDITOR=vim
1641"#;
1642        let result = remove_lean_ctx_block(input);
1643        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1644        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1645        assert!(result.contains("export PATH"), "other content preserved");
1646        assert!(
1647            result.contains("export EDITOR"),
1648            "trailing content preserved"
1649        );
1650    }
1651
1652    #[test]
1653    fn env_sh_for_containers_includes_self_heal() {
1654        let _g = crate::core::data_dir::test_env_lock();
1655        let tmp = tempfile::tempdir().expect("tempdir");
1656        let data_dir = tmp.path().join("data");
1657        std::fs::create_dir_all(&data_dir).expect("mkdir data");
1658        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1659
1660        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1661        let env_sh = data_dir.join("env.sh");
1662        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1663        assert!(content.contains("lean-ctx docker self-heal"));
1664        assert!(content.contains("claude mcp list"));
1665        assert!(content.contains("lean-ctx init --agent claude"));
1666
1667        std::env::remove_var("LEAN_CTX_DATA_DIR");
1668    }
1669}