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    println!(
672        "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
673\x1b[1;36m║\x1b[0m  \x1b[1;37mlean-ctx Workflow Cheat Sheet\x1b[0m                     \x1b[2mv2.9.7\x1b[0m  \x1b[1;36m║\x1b[0m
674\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m
675
676\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
677  ctx_session load               \x1b[2m# restore previous session\x1b[0m
678  ctx_overview task=\"...\"         \x1b[2m# task-aware file map\x1b[0m
679  ctx_graph action=build          \x1b[2m# index project (first time)\x1b[0m
680  ctx_knowledge action=recall     \x1b[2m# check stored project facts\x1b[0m
681
682\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
683  ctx_read mode=full    \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
684  ctx_read mode=map     \x1b[2m# context-only files (~93% saved)\x1b[0m
685  ctx_read mode=diff    \x1b[2m# after editing (~98% saved)\x1b[0m
686  ctx_read mode=sigs    \x1b[2m# API surface of large files (~95%)\x1b[0m
687  ctx_multi_read        \x1b[2m# read multiple files at once\x1b[0m
688  ctx_search            \x1b[2m# search with compressed results (~70%)\x1b[0m
689  ctx_shell             \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
690
691\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
692  ctx_session finding \"...\"       \x1b[2m# record what you discovered\x1b[0m
693  ctx_session decision \"...\"      \x1b[2m# record architectural choices\x1b[0m
694  ctx_knowledge action=remember   \x1b[2m# store permanent project facts\x1b[0m
695  ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
696  ctx_metrics                     \x1b[2m# see session statistics\x1b[0m
697
698\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
699  ctx_agent action=register       \x1b[2m# announce yourself\x1b[0m
700  ctx_agent action=list           \x1b[2m# see other active agents\x1b[0m
701  ctx_agent action=post           \x1b[2m# share findings\x1b[0m
702  ctx_agent action=read           \x1b[2m# check messages\x1b[0m
703
704\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
705  Will edit?  → \x1b[1mfull\x1b[0m (re-reads: 13 tokens)  → after edit: \x1b[1mdiff\x1b[0m
706  API only?   → \x1b[1msignatures\x1b[0m
707  Deps/exports? → \x1b[1mmap\x1b[0m
708  Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
709  Browsing?   → \x1b[1maggressive\x1b[0m (syntax stripped)
710
711\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
712  lean-ctx gain          \x1b[2m# visual savings dashboard\x1b[0m
713  lean-ctx gain --live   \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
714  lean-ctx dashboard     \x1b[2m# web dashboard with charts\x1b[0m
715  lean-ctx wrapped       \x1b[2m# weekly savings report\x1b[0m
716  lean-ctx discover      \x1b[2m# find uncompressed commands\x1b[0m
717  lean-ctx doctor        \x1b[2m# diagnose installation\x1b[0m
718  lean-ctx update        \x1b[2m# self-update to latest\x1b[0m
719
720\x1b[2m  Full guide: https://leanctx.com/docs/workflow\x1b[0m"
721    );
722}
723
724pub fn cmd_slow_log(args: &[String]) {
725    use crate::core::slow_log;
726
727    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
728    match action {
729        "list" | "ls" | "" => println!("{}", slow_log::list()),
730        "clear" | "purge" => println!("{}", slow_log::clear()),
731        _ => {
732            eprintln!("Usage: lean-ctx slow-log [list|clear]");
733            std::process::exit(1);
734        }
735    }
736}
737
738pub fn cmd_tee(args: &[String]) {
739    let tee_dir = match dirs::home_dir() {
740        Some(h) => h.join(".lean-ctx").join("tee"),
741        None => {
742            eprintln!("Cannot determine home directory");
743            std::process::exit(1);
744        }
745    };
746
747    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
748    match action {
749        "list" | "ls" => {
750            if !tee_dir.exists() {
751                println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
752                return;
753            }
754            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
755                .unwrap_or_else(|e| {
756                    eprintln!("Error: {e}");
757                    std::process::exit(1);
758                })
759                .filter_map(|e| e.ok())
760                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
761                .collect();
762            entries.sort_by_key(|e| e.file_name());
763
764            if entries.is_empty() {
765                println!("No tee logs found.");
766                return;
767            }
768
769            println!("Tee logs ({}):\n", entries.len());
770            for entry in &entries {
771                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
772                let name = entry.file_name();
773                let size_str = if size > 1024 {
774                    format!("{}K", size / 1024)
775                } else {
776                    format!("{}B", size)
777                };
778                println!("  {:<60} {}", name.to_string_lossy(), size_str);
779            }
780            println!("\nUse 'lean-ctx tee clear' to delete all logs.");
781        }
782        "clear" | "purge" => {
783            if !tee_dir.exists() {
784                println!("No tee logs to clear.");
785                return;
786            }
787            let mut count = 0u32;
788            if let Ok(entries) = std::fs::read_dir(&tee_dir) {
789                for entry in entries.flatten() {
790                    if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
791                        && std::fs::remove_file(entry.path()).is_ok()
792                    {
793                        count += 1;
794                    }
795                }
796            }
797            println!("Cleared {count} tee log(s) from {}", tee_dir.display());
798        }
799        "show" => {
800            let filename = args.get(1);
801            if filename.is_none() {
802                eprintln!("Usage: lean-ctx tee show <filename>");
803                std::process::exit(1);
804            }
805            let path = tee_dir.join(filename.unwrap());
806            match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
807                Ok(content) => print!("{content}"),
808                Err(e) => {
809                    eprintln!("Error reading {}: {e}", path.display());
810                    std::process::exit(1);
811                }
812            }
813        }
814        "last" => {
815            if !tee_dir.exists() {
816                println!("No tee logs found.");
817                return;
818            }
819            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
820                .ok()
821                .into_iter()
822                .flat_map(|d| d.filter_map(|e| e.ok()))
823                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
824                .collect();
825            entries.sort_by_key(|e| {
826                e.metadata()
827                    .and_then(|m| m.modified())
828                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
829            });
830            match entries.last() {
831                Some(entry) => {
832                    let path = entry.path();
833                    println!(
834                        "--- {} ---\n",
835                        path.file_name().unwrap_or_default().to_string_lossy()
836                    );
837                    match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
838                        Ok(content) => print!("{content}"),
839                        Err(e) => eprintln!("Error: {e}"),
840                    }
841                }
842                None => println!("No tee logs found."),
843            }
844        }
845        _ => {
846            eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
847            std::process::exit(1);
848        }
849    }
850}
851
852pub fn cmd_filter(args: &[String]) {
853    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
854    match action {
855        "list" | "ls" => match crate::core::filters::FilterEngine::load() {
856            Some(engine) => {
857                let rules = engine.list_rules();
858                println!("Loaded {} filter rule(s):\n", rules.len());
859                for rule in &rules {
860                    println!("{rule}");
861                }
862            }
863            None => {
864                println!("No custom filters found.");
865                println!("Create one: lean-ctx filter init");
866            }
867        },
868        "validate" => {
869            let path = args.get(1);
870            if path.is_none() {
871                eprintln!("Usage: lean-ctx filter validate <file.toml>");
872                std::process::exit(1);
873            }
874            match crate::core::filters::validate_filter_file(path.unwrap()) {
875                Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
876                Err(e) => {
877                    eprintln!("Validation failed: {e}");
878                    std::process::exit(1);
879                }
880            }
881        }
882        "init" => match crate::core::filters::create_example_filter() {
883            Ok(path) => {
884                println!("Created example filter: {path}");
885                println!("Edit it to add your custom compression rules.");
886            }
887            Err(e) => {
888                eprintln!("{e}");
889                std::process::exit(1);
890            }
891        },
892        _ => {
893            eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
894            std::process::exit(1);
895        }
896    }
897}
898
899fn quiet_enabled() -> bool {
900    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
901}
902
903macro_rules! qprintln {
904    ($($t:tt)*) => {
905        if !quiet_enabled() {
906            println!($($t)*);
907        }
908    };
909}
910
911pub fn cmd_init(args: &[String]) {
912    let global = args.iter().any(|a| a == "--global" || a == "-g");
913    let dry_run = args.iter().any(|a| a == "--dry-run");
914
915    let agents: Vec<&str> = args
916        .windows(2)
917        .filter(|w| w[0] == "--agent")
918        .map(|w| w[1].as_str())
919        .collect();
920
921    if !agents.is_empty() {
922        for agent_name in &agents {
923            crate::hooks::install_agent_hook(agent_name, global);
924            if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
925                eprintln!("MCP config for '{agent_name}' not updated: {e}");
926            }
927        }
928        if !global {
929            crate::hooks::install_project_rules();
930        }
931        qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
932        return;
933    }
934
935    let shell_name = std::env::var("SHELL").unwrap_or_default();
936    let is_zsh = shell_name.contains("zsh");
937    let is_fish = shell_name.contains("fish");
938    let is_powershell = cfg!(windows) && shell_name.is_empty();
939
940    let binary = std::env::current_exe()
941        .map(|p| p.to_string_lossy().to_string())
942        .unwrap_or_else(|_| "lean-ctx".to_string());
943
944    if dry_run {
945        let rc = if is_powershell {
946            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
947        } else if is_fish {
948            "~/.config/fish/config.fish".to_string()
949        } else if is_zsh {
950            "~/.zshrc".to_string()
951        } else {
952            "~/.bashrc".to_string()
953        };
954        qprintln!("\nlean-ctx init --dry-run\n");
955        qprintln!("  Would modify:  {rc}");
956        qprintln!("  Would backup:  {rc}.lean-ctx.bak");
957        qprintln!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
958        qprintln!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
959        qprintln!("                 curl wget php composer (24 commands + k)");
960        qprintln!("  Would create:  ~/.lean-ctx/");
961        qprintln!("  Binary:        {binary}");
962        qprintln!("\n  Safety: aliases auto-fallback to original command if lean-ctx is removed.");
963        qprintln!("\n  Run without --dry-run to apply.");
964        return;
965    }
966
967    if is_powershell {
968        init_powershell(&binary);
969    } else {
970        let bash_binary = to_bash_compatible_path(&binary);
971        if is_fish {
972            init_fish(&bash_binary);
973        } else {
974            init_posix(is_zsh, &bash_binary);
975        }
976    }
977
978    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
979    if let Some(dir) = lean_dir {
980        if !dir.exists() {
981            let _ = std::fs::create_dir_all(&dir);
982            qprintln!("Created {}", dir.display());
983        }
984    }
985
986    let rc = if is_powershell {
987        "$PROFILE"
988    } else if is_fish {
989        "config.fish"
990    } else if is_zsh {
991        ".zshrc"
992    } else {
993        ".bashrc"
994    };
995
996    qprintln!("\nlean-ctx init complete (24 aliases installed)");
997    qprintln!();
998    qprintln!("  Disable temporarily:  lean-ctx-off");
999    qprintln!("  Re-enable:            lean-ctx-on");
1000    qprintln!("  Check status:         lean-ctx-status");
1001    qprintln!("  Full uninstall:       lean-ctx uninstall");
1002    qprintln!("  Diagnose issues:      lean-ctx doctor");
1003    qprintln!("  Preview changes:      lean-ctx init --global --dry-run");
1004    qprintln!();
1005    if is_powershell {
1006        qprintln!("  Restart PowerShell or run: . {rc}");
1007    } else {
1008        qprintln!("  Restart your shell or run: source ~/{rc}");
1009    }
1010    qprintln!();
1011    qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1012    qprintln!("  Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, crush, pi");
1013}
1014
1015pub fn cmd_init_quiet(args: &[String]) {
1016    std::env::set_var("LEAN_CTX_QUIET", "1");
1017    cmd_init(args);
1018    std::env::remove_var("LEAN_CTX_QUIET");
1019}
1020
1021fn backup_shell_config(path: &std::path::Path) {
1022    if !path.exists() {
1023        return;
1024    }
1025    let bak = path.with_extension("lean-ctx.bak");
1026    if std::fs::copy(path, &bak).is_ok() {
1027        qprintln!(
1028            "  Backup: {}",
1029            bak.file_name()
1030                .map(|n| format!("~/{}", n.to_string_lossy()))
1031                .unwrap_or_else(|| bak.display().to_string())
1032        );
1033    }
1034}
1035
1036pub fn init_powershell(binary: &str) {
1037    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
1038    let profile_path = match profile_dir {
1039        Some(dir) => {
1040            let _ = std::fs::create_dir_all(&dir);
1041            dir.join("Microsoft.PowerShell_profile.ps1")
1042        }
1043        None => {
1044            eprintln!("Could not resolve PowerShell profile directory");
1045            return;
1046        }
1047    };
1048
1049    let binary_escaped = binary.replace('\\', "\\\\");
1050    let functions = format!(
1051        r#"
1052# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1053if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED) {{
1054  $LeanCtxBin = "{binary_escaped}"
1055  function _lc {{
1056    if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) {{ & @args; return }}
1057    & $LeanCtxBin -c @args
1058    if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
1059      & @args
1060    }}
1061  }}
1062  function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
1063  if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
1064    function git {{ _lc git @args }}
1065    function cargo {{ _lc cargo @args }}
1066    function docker {{ _lc docker @args }}
1067    function kubectl {{ _lc kubectl @args }}
1068    function gh {{ _lc gh @args }}
1069    function pip {{ _lc pip @args }}
1070    function pip3 {{ _lc pip3 @args }}
1071    function ruff {{ _lc ruff @args }}
1072    function go {{ _lc go @args }}
1073    function curl {{ _lc curl @args }}
1074    function wget {{ _lc wget @args }}
1075    foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
1076      if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
1077        New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc $c @args")) -Force | Out-Null
1078      }}
1079    }}
1080  }}
1081}}
1082"#
1083    );
1084
1085    backup_shell_config(&profile_path);
1086
1087    if let Ok(existing) = std::fs::read_to_string(&profile_path) {
1088        if existing.contains("lean-ctx shell hook") {
1089            let cleaned = remove_lean_ctx_block_ps(&existing);
1090            match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
1091                Ok(()) => {
1092                    qprintln!("Updated lean-ctx functions in {}", profile_path.display());
1093                    qprintln!("  Binary: {binary}");
1094                    return;
1095                }
1096                Err(e) => {
1097                    eprintln!("Error updating {}: {e}", profile_path.display());
1098                    return;
1099                }
1100            }
1101        }
1102    }
1103
1104    match std::fs::OpenOptions::new()
1105        .append(true)
1106        .create(true)
1107        .open(&profile_path)
1108    {
1109        Ok(mut f) => {
1110            use std::io::Write;
1111            let _ = f.write_all(functions.as_bytes());
1112            qprintln!("Added lean-ctx functions to {}", profile_path.display());
1113            qprintln!("  Binary: {binary}");
1114        }
1115        Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
1116    }
1117}
1118
1119fn remove_lean_ctx_block_ps(content: &str) -> String {
1120    let mut result = String::new();
1121    let mut in_block = false;
1122    let mut brace_depth = 0i32;
1123
1124    for line in content.lines() {
1125        if line.contains("lean-ctx shell hook") {
1126            in_block = true;
1127            continue;
1128        }
1129        if in_block {
1130            brace_depth += line.matches('{').count() as i32;
1131            brace_depth -= line.matches('}').count() as i32;
1132            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
1133                if line.trim() == "}" {
1134                    in_block = false;
1135                    brace_depth = 0;
1136                }
1137                continue;
1138            }
1139            continue;
1140        }
1141        result.push_str(line);
1142        result.push('\n');
1143    }
1144    result
1145}
1146
1147pub fn init_fish(binary: &str) {
1148    let config = dirs::home_dir()
1149        .map(|h| h.join(".config/fish/config.fish"))
1150        .unwrap_or_default();
1151
1152    let alias_list = crate::rewrite_registry::shell_alias_list();
1153    let aliases = format!(
1154        "\n# lean-ctx shell hook — smart shell mode (track-by-default)\n\
1155        set -g _lean_ctx_cmds {alias_list}\n\
1156        \n\
1157        function _lc\n\
1158        \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1159        \t\tcommand $argv\n\
1160        \t\treturn\n\
1161        \tend\n\
1162        \t'{binary}' -t $argv\n\
1163        \tset -l _lc_rc $status\n\
1164        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1165        \t\tcommand $argv\n\
1166        \telse\n\
1167        \t\treturn $_lc_rc\n\
1168        \tend\n\
1169        end\n\
1170        \n\
1171        function _lc_compress\n\
1172        \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1173        \t\tcommand $argv\n\
1174        \t\treturn\n\
1175        \tend\n\
1176        \t'{binary}' -c $argv\n\
1177        \tset -l _lc_rc $status\n\
1178        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1179        \t\tcommand $argv\n\
1180        \telse\n\
1181        \t\treturn $_lc_rc\n\
1182        \tend\n\
1183        end\n\
1184        \n\
1185        function lean-ctx-on\n\
1186        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1187        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
1188        \tend\n\
1189        \talias k '_lc kubectl'\n\
1190        \tset -gx LEAN_CTX_ENABLED 1\n\
1191        \techo 'lean-ctx: ON (track mode — full output, stats recorded)'\n\
1192        end\n\
1193        \n\
1194        function lean-ctx-off\n\
1195        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1196        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
1197        \tend\n\
1198        \tfunctions --erase k 2>/dev/null; true\n\
1199        \tset -e LEAN_CTX_ENABLED\n\
1200        \techo 'lean-ctx: OFF'\n\
1201        end\n\
1202        \n\
1203        function lean-ctx-mode\n\
1204        \tswitch $argv[1]\n\
1205        \t\tcase compress\n\
1206        \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
1207        \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
1208        \t\t\t\tend\n\
1209        \t\t\talias k '_lc_compress kubectl'\n\
1210        \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
1211        \t\t\techo 'lean-ctx: COMPRESS mode (all output compressed)'\n\
1212        \t\tcase track\n\
1213        \t\t\tlean-ctx-on\n\
1214        \t\tcase off\n\
1215        \t\t\tlean-ctx-off\n\
1216        \t\tcase '*'\n\
1217        \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
1218        \t\t\techo '  track    — Full output, stats recorded (default)'\n\
1219        \t\t\techo '  compress — Compressed output for all commands'\n\
1220        \t\t\techo '  off      — No aliases, raw shell'\n\
1221        \tend\n\
1222        end\n\
1223        \n\
1224        function lean-ctx-raw\n\
1225        \tset -lx LEAN_CTX_RAW 1\n\
1226        \tcommand $argv\n\
1227        end\n\
1228        \n\
1229        function lean-ctx-status\n\
1230        \tif set -q LEAN_CTX_DISABLED\n\
1231        \t\techo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
1232        \telse if set -q LEAN_CTX_ENABLED\n\
1233        \t\techo 'lean-ctx: ON'\n\
1234        \telse\n\
1235        \t\techo 'lean-ctx: OFF'\n\
1236        \tend\n\
1237        end\n\
1238        \n\
1239        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\
1240        \tif command -q lean-ctx\n\
1241        \t\tlean-ctx-on\n\
1242        \tend\n\
1243        end\n\
1244        # lean-ctx shell hook — end\n"
1245    );
1246
1247    backup_shell_config(&config);
1248
1249    if let Ok(existing) = std::fs::read_to_string(&config) {
1250        if existing.contains("lean-ctx shell hook") {
1251            let cleaned = remove_lean_ctx_block(&existing);
1252            match std::fs::write(&config, format!("{cleaned}{aliases}")) {
1253                Ok(()) => {
1254                    qprintln!("Updated lean-ctx aliases in {}", config.display());
1255                    qprintln!("  Binary: {binary}");
1256                    return;
1257                }
1258                Err(e) => {
1259                    eprintln!("Error updating {}: {e}", config.display());
1260                    return;
1261                }
1262            }
1263        }
1264    }
1265
1266    match std::fs::OpenOptions::new()
1267        .append(true)
1268        .create(true)
1269        .open(&config)
1270    {
1271        Ok(mut f) => {
1272            use std::io::Write;
1273            let _ = f.write_all(aliases.as_bytes());
1274            qprintln!("Added lean-ctx aliases to {}", config.display());
1275            qprintln!("  Binary: {binary}");
1276        }
1277        Err(e) => eprintln!("Error writing {}: {e}", config.display()),
1278    }
1279}
1280
1281pub fn init_posix(is_zsh: bool, binary: &str) {
1282    let rc_file = if is_zsh {
1283        dirs::home_dir()
1284            .map(|h| h.join(".zshrc"))
1285            .unwrap_or_default()
1286    } else {
1287        dirs::home_dir()
1288            .map(|h| h.join(".bashrc"))
1289            .unwrap_or_default()
1290    };
1291
1292    let alias_list = crate::rewrite_registry::shell_alias_list();
1293    let aliases = format!(
1294        r#"
1295# lean-ctx shell hook — smart shell mode (track-by-default)
1296_lean_ctx_cmds=({alias_list})
1297
1298_lc() {{
1299    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1300        command "$@"
1301        return
1302    fi
1303    '{binary}' -t "$@"
1304    local _lc_rc=$?
1305    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1306        command "$@"
1307    else
1308        return "$_lc_rc"
1309    fi
1310}}
1311
1312_lc_compress() {{
1313    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1314        command "$@"
1315        return
1316    fi
1317    '{binary}' -c "$@"
1318    local _lc_rc=$?
1319    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1320        command "$@"
1321    else
1322        return "$_lc_rc"
1323    fi
1324}}
1325
1326lean-ctx-on() {{
1327    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1328        # shellcheck disable=SC2139
1329        alias "$_lc_cmd"='_lc '"$_lc_cmd"
1330    done
1331    alias k='_lc kubectl'
1332    export LEAN_CTX_ENABLED=1
1333    echo "lean-ctx: ON (track mode — full output, stats recorded)"
1334}}
1335
1336lean-ctx-off() {{
1337    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1338        unalias "$_lc_cmd" 2>/dev/null || true
1339    done
1340    unalias k 2>/dev/null || true
1341    unset LEAN_CTX_ENABLED
1342    echo "lean-ctx: OFF"
1343}}
1344
1345lean-ctx-mode() {{
1346    case "${{1:-}}" in
1347        compress)
1348            for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1349                # shellcheck disable=SC2139
1350                alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
1351            done
1352            alias k='_lc_compress kubectl'
1353            export LEAN_CTX_ENABLED=1
1354            echo "lean-ctx: COMPRESS mode (all output compressed)"
1355            ;;
1356        track)
1357            lean-ctx-on
1358            ;;
1359        off)
1360            lean-ctx-off
1361            ;;
1362        *)
1363            echo "Usage: lean-ctx-mode <track|compress|off>"
1364            echo "  track    — Full output, stats recorded (default)"
1365            echo "  compress — Compressed output for all commands"
1366            echo "  off      — No aliases, raw shell"
1367            ;;
1368    esac
1369}}
1370
1371lean-ctx-raw() {{
1372    LEAN_CTX_RAW=1 command "$@"
1373}}
1374
1375lean-ctx-status() {{
1376    if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1377        echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
1378    elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1379        echo "lean-ctx: ON"
1380    else
1381        echo "lean-ctx: OFF"
1382    fi
1383}}
1384
1385if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1386    command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
1387fi
1388# lean-ctx shell hook — end
1389"#
1390    );
1391
1392    backup_shell_config(&rc_file);
1393
1394    if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1395        if existing.contains("lean-ctx shell hook") {
1396            let cleaned = remove_lean_ctx_block(&existing);
1397            match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1398                Ok(()) => {
1399                    qprintln!("Updated lean-ctx aliases in {}", rc_file.display());
1400                    qprintln!("  Binary: {binary}");
1401                    return;
1402                }
1403                Err(e) => {
1404                    eprintln!("Error updating {}: {e}", rc_file.display());
1405                    return;
1406                }
1407            }
1408        }
1409    }
1410
1411    match std::fs::OpenOptions::new()
1412        .append(true)
1413        .create(true)
1414        .open(&rc_file)
1415    {
1416        Ok(mut f) => {
1417            use std::io::Write;
1418            let _ = f.write_all(aliases.as_bytes());
1419            qprintln!("Added lean-ctx aliases to {}", rc_file.display());
1420            qprintln!("  Binary: {binary}");
1421        }
1422        Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1423    }
1424
1425    write_env_sh_for_containers(&aliases);
1426    print_docker_env_hints(is_zsh);
1427}
1428
1429fn write_env_sh_for_containers(aliases: &str) {
1430    let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
1431        Ok(d) => d.join("env.sh"),
1432        Err(_) => return,
1433    };
1434    if let Some(parent) = env_sh.parent() {
1435        let _ = std::fs::create_dir_all(parent);
1436    }
1437    let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
1438    let mut content = sanitized_aliases;
1439    content.push_str(
1440        r#"
1441
1442# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
1443if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
1444  if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
1445    LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
1446  fi
1447fi
1448"#,
1449    );
1450    match std::fs::write(&env_sh, content) {
1451        Ok(()) => qprintln!("  env.sh: {}", env_sh.display()),
1452        Err(e) => eprintln!("  Warning: could not write {}: {e}", env_sh.display()),
1453    }
1454}
1455
1456fn print_docker_env_hints(is_zsh: bool) {
1457    if is_zsh || !crate::shell::is_container() {
1458        return;
1459    }
1460    let env_sh = crate::core::data_dir::lean_ctx_data_dir()
1461        .map(|d| d.join("env.sh").to_string_lossy().to_string())
1462        .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
1463
1464    let has_bash_env = std::env::var("BASH_ENV").is_ok();
1465    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
1466
1467    if has_bash_env && has_claude_env {
1468        return;
1469    }
1470
1471    eprintln!();
1472    eprintln!("  \x1b[33m⚠  Docker detected — environment hints:\x1b[0m");
1473
1474    if !has_bash_env {
1475        eprintln!("  For generic bash -c usage (non-interactive shells):");
1476        eprintln!("    \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
1477    }
1478    if !has_claude_env {
1479        eprintln!("  For Claude Code (sources before each command):");
1480        eprintln!("    \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
1481    }
1482    eprintln!();
1483}
1484
1485fn remove_lean_ctx_block(content: &str) -> String {
1486    // New format uses explicit end marker; old format ends at first top-level `fi`/`end`.
1487    if content.contains("# lean-ctx shell hook — end") {
1488        return remove_lean_ctx_block_by_marker(content);
1489    }
1490    remove_lean_ctx_block_legacy(content)
1491}
1492
1493fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1494    let mut result = String::new();
1495    let mut in_block = false;
1496
1497    for line in content.lines() {
1498        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1499            in_block = true;
1500            continue;
1501        }
1502        if in_block {
1503            if line.trim() == "# lean-ctx shell hook — end" {
1504                in_block = false;
1505            }
1506            continue;
1507        }
1508        result.push_str(line);
1509        result.push('\n');
1510    }
1511    result
1512}
1513
1514fn remove_lean_ctx_block_legacy(content: &str) -> String {
1515    let mut result = String::new();
1516    let mut in_block = false;
1517
1518    for line in content.lines() {
1519        if line.contains("lean-ctx shell hook") {
1520            in_block = true;
1521            continue;
1522        }
1523        if in_block {
1524            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1525                if line.trim() == "fi" || line.trim() == "end" {
1526                    in_block = false;
1527                }
1528                continue;
1529            }
1530            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1531                in_block = false;
1532                result.push_str(line);
1533                result.push('\n');
1534            }
1535            continue;
1536        }
1537        result.push_str(line);
1538        result.push('\n');
1539    }
1540    result
1541}
1542
1543pub fn load_shell_history_pub() -> Vec<String> {
1544    load_shell_history()
1545}
1546
1547fn load_shell_history() -> Vec<String> {
1548    let shell = std::env::var("SHELL").unwrap_or_default();
1549    let home = match dirs::home_dir() {
1550        Some(h) => h,
1551        None => return Vec::new(),
1552    };
1553
1554    let history_file = if shell.contains("zsh") {
1555        home.join(".zsh_history")
1556    } else if shell.contains("fish") {
1557        home.join(".local/share/fish/fish_history")
1558    } else if cfg!(windows) && shell.is_empty() {
1559        home.join("AppData")
1560            .join("Roaming")
1561            .join("Microsoft")
1562            .join("Windows")
1563            .join("PowerShell")
1564            .join("PSReadLine")
1565            .join("ConsoleHost_history.txt")
1566    } else {
1567        home.join(".bash_history")
1568    };
1569
1570    match std::fs::read_to_string(&history_file) {
1571        Ok(content) => content
1572            .lines()
1573            .filter_map(|l| {
1574                let trimmed = l.trim();
1575                if trimmed.starts_with(':') {
1576                    trimmed.split(';').nth(1).map(|s| s.to_string())
1577                } else {
1578                    Some(trimmed.to_string())
1579                }
1580            })
1581            .filter(|l| !l.is_empty())
1582            .collect(),
1583        Err(_) => Vec::new(),
1584    }
1585}
1586
1587fn print_savings(original: usize, sent: usize) {
1588    let saved = original.saturating_sub(sent);
1589    if original > 0 && saved > 0 {
1590        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1591        println!("[{saved} tok saved ({pct}%)]");
1592    }
1593}
1594
1595pub fn cmd_theme(args: &[String]) {
1596    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1597    let r = theme::rst();
1598    let b = theme::bold();
1599    let d = theme::dim();
1600
1601    match sub {
1602        "list" => {
1603            let cfg = config::Config::load();
1604            let active = cfg.theme.as_str();
1605            println!();
1606            println!("  {b}Available themes:{r}");
1607            println!("  {ln}", ln = "─".repeat(40));
1608            for name in theme::PRESET_NAMES {
1609                let marker = if *name == active { " ◀ active" } else { "" };
1610                let t = theme::from_preset(name).unwrap();
1611                let preview = format!(
1612                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1613                    p = t.primary.fg(),
1614                    s = t.secondary.fg(),
1615                    a = t.accent.fg(),
1616                    sc = t.success.fg(),
1617                    w = t.warning.fg(),
1618                );
1619                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1620            }
1621            if let Some(path) = theme::theme_file_path() {
1622                if path.exists() {
1623                    let custom = theme::load_theme("_custom_");
1624                    let preview = format!(
1625                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1626                        p = custom.primary.fg(),
1627                        s = custom.secondary.fg(),
1628                        a = custom.accent.fg(),
1629                        sc = custom.success.fg(),
1630                        w = custom.warning.fg(),
1631                    );
1632                    let marker = if active == "custom" {
1633                        " ◀ active"
1634                    } else {
1635                        ""
1636                    };
1637                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1638                }
1639            }
1640            println!();
1641            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1642            println!();
1643        }
1644        "set" => {
1645            if args.len() < 2 {
1646                eprintln!("Usage: lean-ctx theme set <name>");
1647                std::process::exit(1);
1648            }
1649            let name = &args[1];
1650            if theme::from_preset(name).is_none() && name != "custom" {
1651                eprintln!(
1652                    "Unknown theme '{name}'. Available: {}",
1653                    theme::PRESET_NAMES.join(", ")
1654                );
1655                std::process::exit(1);
1656            }
1657            let mut cfg = config::Config::load();
1658            cfg.theme = name.to_string();
1659            match cfg.save() {
1660                Ok(()) => {
1661                    let t = theme::load_theme(name);
1662                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1663                    let preview = t.gradient_bar(0.75, 30);
1664                    println!("  {preview}");
1665                }
1666                Err(e) => eprintln!("Error: {e}"),
1667            }
1668        }
1669        "export" => {
1670            let cfg = config::Config::load();
1671            let t = theme::load_theme(&cfg.theme);
1672            println!("{}", t.to_toml());
1673        }
1674        "import" => {
1675            if args.len() < 2 {
1676                eprintln!("Usage: lean-ctx theme import <path>");
1677                std::process::exit(1);
1678            }
1679            let path = std::path::Path::new(&args[1]);
1680            if !path.exists() {
1681                eprintln!("File not found: {}", args[1]);
1682                std::process::exit(1);
1683            }
1684            match std::fs::read_to_string(path) {
1685                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1686                    Ok(imported) => match theme::save_theme(&imported) {
1687                        Ok(()) => {
1688                            let mut cfg = config::Config::load();
1689                            cfg.theme = "custom".to_string();
1690                            let _ = cfg.save();
1691                            println!(
1692                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1693                                sc = imported.success.fg(),
1694                                name = imported.name,
1695                            );
1696                            println!("  Config updated: theme = custom");
1697                        }
1698                        Err(e) => eprintln!("Error saving theme: {e}"),
1699                    },
1700                    Err(e) => eprintln!("Invalid theme file: {e}"),
1701                },
1702                Err(e) => eprintln!("Error reading file: {e}"),
1703            }
1704        }
1705        "preview" => {
1706            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1707            let t = match theme::from_preset(name) {
1708                Some(t) => t,
1709                None => {
1710                    eprintln!("Unknown theme: {name}");
1711                    std::process::exit(1);
1712                }
1713            };
1714            println!();
1715            println!(
1716                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1717                icon = t.header_icon(),
1718                title = t.brand_title(),
1719            );
1720            println!("  {ln}", ln = t.border_line(50));
1721            println!();
1722            println!(
1723                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1724                sc = t.success.fg(),
1725                sec = t.secondary.fg(),
1726                wrn = t.warning.fg(),
1727                acc = t.accent.fg(),
1728            );
1729            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1730            println!();
1731            println!(
1732                "  {b}{txt}Gradient Bar{r}      {bar}",
1733                txt = t.text.fg(),
1734                bar = t.gradient_bar(0.85, 30),
1735            );
1736            println!(
1737                "  {b}{txt}Sparkline{r}         {spark}",
1738                txt = t.text.fg(),
1739                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1740            );
1741            println!();
1742            println!("  {top}", top = t.box_top(50));
1743            println!(
1744                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1745                side = t.box_side(),
1746                side_r = t.box_side(),
1747                txt = t.text.fg(),
1748            );
1749            println!("  {bot}", bot = t.box_bottom(50));
1750            println!();
1751        }
1752        _ => {
1753            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1754            std::process::exit(1);
1755        }
1756    }
1757}
1758
1759#[cfg(test)]
1760mod tests {
1761    use super::*;
1762    use tempfile;
1763
1764    #[test]
1765    fn test_remove_lean_ctx_block_posix() {
1766        let input = r#"# existing config
1767export PATH="$HOME/bin:$PATH"
1768
1769# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1770if [ -z "$LEAN_CTX_ACTIVE" ]; then
1771alias git='lean-ctx -c git'
1772alias npm='lean-ctx -c npm'
1773fi
1774
1775# other stuff
1776export EDITOR=vim
1777"#;
1778        let result = remove_lean_ctx_block(input);
1779        assert!(!result.contains("lean-ctx"), "block should be removed");
1780        assert!(result.contains("export PATH"), "other content preserved");
1781        assert!(
1782            result.contains("export EDITOR"),
1783            "trailing content preserved"
1784        );
1785    }
1786
1787    #[test]
1788    fn test_remove_lean_ctx_block_fish() {
1789        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";
1790        let result = remove_lean_ctx_block(input);
1791        assert!(!result.contains("lean-ctx"), "block should be removed");
1792        assert!(result.contains("set -x FOO"), "other content preserved");
1793        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1794    }
1795
1796    #[test]
1797    fn test_remove_lean_ctx_block_ps() {
1798        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";
1799        let result = remove_lean_ctx_block_ps(input);
1800        assert!(
1801            !result.contains("lean-ctx shell hook"),
1802            "block should be removed"
1803        );
1804        assert!(result.contains("$env:FOO"), "other content preserved");
1805        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1806    }
1807
1808    #[test]
1809    fn test_remove_lean_ctx_block_ps_nested() {
1810        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";
1811        let result = remove_lean_ctx_block_ps(input);
1812        assert!(
1813            !result.contains("lean-ctx shell hook"),
1814            "block should be removed"
1815        );
1816        assert!(!result.contains("_lc"), "function should be removed");
1817        assert!(result.contains("$env:FOO"), "other content preserved");
1818        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1819    }
1820
1821    #[test]
1822    fn test_remove_block_no_lean_ctx() {
1823        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1824        let result = remove_lean_ctx_block(input);
1825        assert!(result.contains("export PATH"), "content unchanged");
1826    }
1827
1828    #[test]
1829    fn test_bash_hook_contains_pipe_guard() {
1830        let binary = "/usr/local/bin/lean-ctx";
1831        let hook = format!(
1832            r#"_lc() {{
1833    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1834        command "$@"
1835        return
1836    fi
1837    '{binary}' -t "$@"
1838}}"#
1839        );
1840        assert!(
1841            hook.contains("! -t 1"),
1842            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1843        );
1844        assert!(
1845            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1846            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1847        );
1848    }
1849
1850    #[test]
1851    fn test_lc_uses_track_mode_by_default() {
1852        let binary = "/usr/local/bin/lean-ctx";
1853        let alias_list = crate::rewrite_registry::shell_alias_list();
1854        let aliases = format!(
1855            r#"_lc() {{
1856    '{binary}' -t "$@"
1857}}
1858_lc_compress() {{
1859    '{binary}' -c "$@"
1860}}"#
1861        );
1862        assert!(
1863            aliases.contains("-t \"$@\""),
1864            "_lc must use -t (track mode) by default"
1865        );
1866        assert!(
1867            aliases.contains("-c \"$@\""),
1868            "_lc_compress must use -c (compress mode)"
1869        );
1870        let _ = alias_list;
1871    }
1872
1873    #[test]
1874    fn test_posix_shell_has_lean_ctx_mode() {
1875        let alias_list = crate::rewrite_registry::shell_alias_list();
1876        let aliases = format!(
1877            r#"
1878lean-ctx-mode() {{
1879    case "${{1:-}}" in
1880        compress) echo compress ;;
1881        track) echo track ;;
1882        off) echo off ;;
1883    esac
1884}}
1885"#
1886        );
1887        assert!(
1888            aliases.contains("lean-ctx-mode()"),
1889            "lean-ctx-mode function must exist"
1890        );
1891        assert!(
1892            aliases.contains("compress"),
1893            "compress mode must be available"
1894        );
1895        assert!(aliases.contains("track"), "track mode must be available");
1896        let _ = alias_list;
1897    }
1898
1899    #[test]
1900    fn test_fish_hook_contains_pipe_guard() {
1901        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";
1902        assert!(
1903            hook.contains("isatty stdout"),
1904            "fish hook must contain pipe guard (isatty stdout)"
1905        );
1906    }
1907
1908    #[test]
1909    fn test_powershell_hook_contains_pipe_guard() {
1910        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1911        assert!(
1912            hook.contains("IsOutputRedirected"),
1913            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1914        );
1915    }
1916
1917    #[test]
1918    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1919        let input = r#"# existing config
1920export PATH="$HOME/bin:$PATH"
1921
1922# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1923_lean_ctx_cmds=(git npm pnpm)
1924
1925lean-ctx-on() {
1926    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1927        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1928    done
1929    export LEAN_CTX_ENABLED=1
1930    echo "lean-ctx: ON"
1931}
1932
1933lean-ctx-off() {
1934    unset LEAN_CTX_ENABLED
1935    echo "lean-ctx: OFF"
1936}
1937
1938if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1939    lean-ctx-on
1940fi
1941# lean-ctx shell hook — end
1942
1943# other stuff
1944export EDITOR=vim
1945"#;
1946        let result = remove_lean_ctx_block(input);
1947        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1948        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1949        assert!(result.contains("export PATH"), "other content preserved");
1950        assert!(
1951            result.contains("export EDITOR"),
1952            "trailing content preserved"
1953        );
1954    }
1955
1956    #[test]
1957    fn env_sh_for_containers_includes_self_heal() {
1958        let _g = crate::core::data_dir::test_env_lock();
1959        let tmp = tempfile::tempdir().expect("tempdir");
1960        let data_dir = tmp.path().join("data");
1961        std::fs::create_dir_all(&data_dir).expect("mkdir data");
1962        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1963
1964        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1965        let env_sh = data_dir.join("env.sh");
1966        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1967        assert!(content.contains("lean-ctx docker self-heal"));
1968        assert!(content.contains("claude mcp list"));
1969        assert!(content.contains("lean-ctx init --agent claude"));
1970
1971        std::env::remove_var("LEAN_CTX_DATA_DIR");
1972    }
1973}