Skip to main content

lean_ctx/
cli.rs

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