Skip to main content

lean_ctx/
cli.rs

1use std::path::Path;
2
3use crate::core::compressor;
4use crate::core::config;
5use crate::core::deps as dep_extract;
6use crate::core::entropy;
7use crate::core::patterns::deps_cmd;
8use crate::core::protocol;
9use crate::core::signatures;
10use crate::core::stats;
11use crate::core::theme;
12use crate::core::tokens::count_tokens;
13use crate::hooks::to_bash_compatible_path;
14
15pub fn cmd_read(args: &[String]) {
16    if args.is_empty() {
17        eprintln!(
18            "Usage: lean-ctx read <file> [--mode full|map|signatures|aggressive|entropy] [--fresh]"
19        );
20        std::process::exit(1);
21    }
22
23    let path = &args[0];
24    let mode = args
25        .iter()
26        .position(|a| a == "--mode" || a == "-m")
27        .and_then(|i| args.get(i + 1))
28        .map(|s| s.as_str())
29        .unwrap_or("full");
30    let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
31
32    let short = protocol::shorten_path(path);
33
34    if !force_fresh && mode == "full" {
35        use crate::core::cli_cache::{self, CacheResult};
36        match cli_cache::check_and_read(path) {
37            CacheResult::Hit { entry, file_ref } => {
38                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
39                println!("{msg}");
40                stats::record("cli_read", entry.original_tokens, msg.len());
41                return;
42            }
43            CacheResult::Miss { content } if content.is_empty() => {
44                eprintln!("Error: could not read {path}");
45                std::process::exit(1);
46            }
47            CacheResult::Miss { content } => {
48                let line_count = content.lines().count();
49                println!("{short} [{line_count}L]");
50                println!("{content}");
51                stats::record("cli_read", count_tokens(&content), count_tokens(&content));
52                return;
53            }
54        }
55    }
56
57    let content = match crate::tools::ctx_read::read_file_lossy(path) {
58        Ok(c) => c,
59        Err(e) => {
60            eprintln!("Error: {e}");
61            std::process::exit(1);
62        }
63    };
64
65    let ext = Path::new(path)
66        .extension()
67        .and_then(|e| e.to_str())
68        .unwrap_or("");
69    let line_count = content.lines().count();
70    let original_tokens = count_tokens(&content);
71
72    let mode = if mode == "auto" {
73        let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
74        let predictor = crate::core::mode_predictor::ModePredictor::new();
75        predictor
76            .predict_best_mode(&sig)
77            .unwrap_or_else(|| "full".to_string())
78    } else {
79        mode.to_string()
80    };
81    let mode = mode.as_str();
82
83    match mode {
84        "map" => {
85            let sigs = signatures::extract_signatures(&content, ext);
86            let dep_info = dep_extract::extract_deps(&content, ext);
87
88            println!("{short} [{line_count}L]");
89            if !dep_info.imports.is_empty() {
90                println!("  deps: {}", dep_info.imports.join(", "));
91            }
92            if !dep_info.exports.is_empty() {
93                println!("  exports: {}", dep_info.exports.join(", "));
94            }
95            let key_sigs: Vec<_> = sigs
96                .iter()
97                .filter(|s| s.is_exported || s.indent == 0)
98                .collect();
99            if !key_sigs.is_empty() {
100                println!("  API:");
101                for sig in &key_sigs {
102                    println!("    {}", sig.to_compact());
103                }
104            }
105            let sent = count_tokens(&short.to_string());
106            print_savings(original_tokens, sent);
107        }
108        "signatures" => {
109            let sigs = signatures::extract_signatures(&content, ext);
110            println!("{short} [{line_count}L]");
111            for sig in &sigs {
112                println!("{}", sig.to_compact());
113            }
114            let sent = count_tokens(&short.to_string());
115            print_savings(original_tokens, sent);
116        }
117        "aggressive" => {
118            let compressed = compressor::aggressive_compress(&content, Some(ext));
119            println!("{short} [{line_count}L]");
120            println!("{compressed}");
121            let sent = count_tokens(&compressed);
122            print_savings(original_tokens, sent);
123        }
124        "entropy" => {
125            let result = entropy::entropy_compress(&content);
126            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
127            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
128            for tech in &result.techniques {
129                println!("{tech}");
130            }
131            println!("{}", result.output);
132            let sent = count_tokens(&result.output);
133            print_savings(original_tokens, sent);
134        }
135        _ => {
136            println!("{short} [{line_count}L]");
137            println!("{content}");
138        }
139    }
140}
141
142pub fn cmd_diff(args: &[String]) {
143    if args.len() < 2 {
144        eprintln!("Usage: lean-ctx diff <file1> <file2>");
145        std::process::exit(1);
146    }
147
148    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
149        Ok(c) => c,
150        Err(e) => {
151            eprintln!("Error reading {}: {e}", args[0]);
152            std::process::exit(1);
153        }
154    };
155
156    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
157        Ok(c) => c,
158        Err(e) => {
159            eprintln!("Error reading {}: {e}", args[1]);
160            std::process::exit(1);
161        }
162    };
163
164    let diff = compressor::diff_content(&content1, &content2);
165    let original = count_tokens(&content1) + count_tokens(&content2);
166    let sent = count_tokens(&diff);
167
168    println!(
169        "diff {} {}",
170        protocol::shorten_path(&args[0]),
171        protocol::shorten_path(&args[1])
172    );
173    println!("{diff}");
174    print_savings(original, sent);
175}
176
177pub fn cmd_grep(args: &[String]) {
178    if args.is_empty() {
179        eprintln!("Usage: lean-ctx grep <pattern> [path]");
180        std::process::exit(1);
181    }
182
183    let pattern = &args[0];
184    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
185
186    let re = match regex::Regex::new(pattern) {
187        Ok(r) => r,
188        Err(e) => {
189            eprintln!("Invalid regex pattern: {e}");
190            std::process::exit(1);
191        }
192    };
193
194    let mut found = false;
195    for entry in ignore::WalkBuilder::new(path)
196        .hidden(true)
197        .git_ignore(true)
198        .git_global(true)
199        .git_exclude(true)
200        .max_depth(Some(10))
201        .build()
202        .flatten()
203    {
204        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
205            continue;
206        }
207        let file_path = entry.path();
208        if let Ok(content) = std::fs::read_to_string(file_path) {
209            for (i, line) in content.lines().enumerate() {
210                if re.is_match(line) {
211                    println!("{}:{}:{}", file_path.display(), i + 1, line);
212                    found = true;
213                }
214            }
215        }
216    }
217
218    if !found {
219        std::process::exit(1);
220    }
221}
222
223pub fn cmd_find(args: &[String]) {
224    if args.is_empty() {
225        eprintln!("Usage: lean-ctx find <pattern> [path]");
226        std::process::exit(1);
227    }
228
229    let raw_pattern = &args[0];
230    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
231
232    let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
233    let glob_matcher = if is_glob {
234        glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
235    } else {
236        None
237    };
238    let substring = raw_pattern.to_lowercase();
239
240    let mut found = false;
241    for entry in ignore::WalkBuilder::new(path)
242        .hidden(true)
243        .git_ignore(true)
244        .git_global(true)
245        .git_exclude(true)
246        .max_depth(Some(10))
247        .build()
248        .flatten()
249    {
250        let name = entry.file_name().to_string_lossy().to_lowercase();
251        let matches = if let Some(ref g) = glob_matcher {
252            g.matches(&name)
253        } else {
254            name.contains(&substring)
255        };
256        if matches {
257            println!("{}", entry.path().display());
258            found = true;
259        }
260    }
261
262    if !found {
263        std::process::exit(1);
264    }
265}
266
267pub fn cmd_ls(args: &[String]) {
268    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
269    let command = if cfg!(windows) {
270        format!("dir {}", path.replace('/', "\\"))
271    } else {
272        format!("ls -la {path}")
273    };
274    let code = crate::shell::exec(&command);
275    std::process::exit(code);
276}
277
278pub fn cmd_deps(args: &[String]) {
279    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
280
281    match deps_cmd::detect_and_compress(path) {
282        Some(result) => println!("{result}"),
283        None => {
284            eprintln!("No dependency file found in {path}");
285            std::process::exit(1);
286        }
287    }
288}
289
290pub fn cmd_discover(_args: &[String]) {
291    let history = load_shell_history();
292    if history.is_empty() {
293        println!("No shell history found.");
294        return;
295    }
296
297    let result = crate::tools::ctx_discover::analyze_history(&history, 20);
298    println!("{}", crate::tools::ctx_discover::format_cli_output(&result));
299}
300
301pub fn cmd_session() {
302    let history = load_shell_history();
303    let gain = stats::load_stats();
304
305    let compressible_commands = [
306        "git ",
307        "npm ",
308        "yarn ",
309        "pnpm ",
310        "cargo ",
311        "docker ",
312        "kubectl ",
313        "gh ",
314        "pip ",
315        "pip3 ",
316        "eslint",
317        "prettier",
318        "ruff ",
319        "go ",
320        "golangci-lint",
321        "curl ",
322        "wget ",
323        "grep ",
324        "rg ",
325        "find ",
326        "ls ",
327    ];
328
329    let mut total = 0u32;
330    let mut via_hook = 0u32;
331
332    for line in &history {
333        let cmd = line.trim().to_lowercase();
334        if cmd.starts_with("lean-ctx") {
335            via_hook += 1;
336            total += 1;
337        } else {
338            for p in &compressible_commands {
339                if cmd.starts_with(p) {
340                    total += 1;
341                    break;
342                }
343            }
344        }
345    }
346
347    let pct = if total > 0 {
348        (via_hook as f64 / total as f64 * 100.0).round() as u32
349    } else {
350        0
351    };
352
353    println!("lean-ctx session statistics\n");
354    println!(
355        "Adoption:    {}% ({}/{} compressible commands)",
356        pct, via_hook, total
357    );
358    println!("Saved:       {} tokens total", gain.total_saved);
359    println!("Calls:       {} compressed", gain.total_calls);
360
361    if total > via_hook {
362        let missed = total - via_hook;
363        let est = missed * 150;
364        println!(
365            "Missed:      {} commands (~{} tokens saveable)",
366            missed, est
367        );
368    }
369
370    println!("\nRun 'lean-ctx discover' for details on missed commands.");
371}
372
373pub fn cmd_wrapped(args: &[String]) {
374    let period = if args.iter().any(|a| a == "--month") {
375        "month"
376    } else if args.iter().any(|a| a == "--all") {
377        "all"
378    } else {
379        "week"
380    };
381
382    let report = crate::core::wrapped::WrappedReport::generate(period);
383    println!("{}", report.format_ascii());
384}
385
386pub fn cmd_sessions(args: &[String]) {
387    use crate::core::session::SessionState;
388
389    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
390
391    match action {
392        "list" | "ls" => {
393            let sessions = SessionState::list_sessions();
394            if sessions.is_empty() {
395                println!("No sessions found.");
396                return;
397            }
398            println!("Sessions ({}):\n", sessions.len());
399            for s in sessions.iter().take(20) {
400                let task = s.task.as_deref().unwrap_or("(no task)");
401                let task_short: String = task.chars().take(50).collect();
402                let date = s.updated_at.format("%Y-%m-%d %H:%M");
403                println!(
404                    "  {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
405                    s.id,
406                    s.version,
407                    s.tool_calls,
408                    format_tokens_cli(s.tokens_saved),
409                    date,
410                    task_short
411                );
412            }
413            if sessions.len() > 20 {
414                println!("  ... +{} more", sessions.len() - 20);
415            }
416        }
417        "show" => {
418            let id = args.get(1);
419            let session = if let Some(id) = id {
420                SessionState::load_by_id(id)
421            } else {
422                SessionState::load_latest()
423            };
424            match session {
425                Some(s) => println!("{}", s.format_compact()),
426                None => println!("Session not found."),
427            }
428        }
429        "cleanup" => {
430            let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
431            let removed = SessionState::cleanup_old_sessions(days);
432            println!("Cleaned up {removed} session(s) older than {days} days.");
433        }
434        _ => {
435            eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
436            std::process::exit(1);
437        }
438    }
439}
440
441pub fn cmd_benchmark(args: &[String]) {
442    use crate::core::benchmark;
443
444    let action = args.first().map(|s| s.as_str()).unwrap_or("run");
445
446    match action {
447        "run" => {
448            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
449            let is_json = args.iter().any(|a| a == "--json");
450
451            let result = benchmark::run_project_benchmark(path);
452            if is_json {
453                println!("{}", benchmark::format_json(&result));
454            } else {
455                println!("{}", benchmark::format_terminal(&result));
456            }
457        }
458        "report" => {
459            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
460            let result = benchmark::run_project_benchmark(path);
461            println!("{}", benchmark::format_markdown(&result));
462        }
463        _ => {
464            if std::path::Path::new(action).exists() {
465                let result = benchmark::run_project_benchmark(action);
466                println!("{}", benchmark::format_terminal(&result));
467            } else {
468                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
469                eprintln!("       lean-ctx benchmark report [path]");
470                std::process::exit(1);
471            }
472        }
473    }
474}
475
476fn format_tokens_cli(tokens: u64) -> String {
477    if tokens >= 1_000_000 {
478        format!("{:.1}M", tokens as f64 / 1_000_000.0)
479    } else if tokens >= 1_000 {
480        format!("{:.1}K", tokens as f64 / 1_000.0)
481    } else {
482        format!("{tokens}")
483    }
484}
485
486pub fn cmd_stats(args: &[String]) {
487    match args.first().map(|s| s.as_str()) {
488        Some("reset-cep") => {
489            crate::core::stats::reset_cep();
490            println!("CEP stats reset. Shell hook data preserved.");
491        }
492        Some("json") => {
493            let store = crate::core::stats::load();
494            println!(
495                "{}",
496                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
497            );
498        }
499        _ => {
500            let store = crate::core::stats::load();
501            let input_saved = store
502                .total_input_tokens
503                .saturating_sub(store.total_output_tokens);
504            let pct = if store.total_input_tokens > 0 {
505                input_saved as f64 / store.total_input_tokens as f64 * 100.0
506            } else {
507                0.0
508            };
509            println!("Commands:    {}", store.total_commands);
510            println!("Input:       {} tokens", store.total_input_tokens);
511            println!("Output:      {} tokens", store.total_output_tokens);
512            println!("Saved:       {} tokens ({:.1}%)", input_saved, pct);
513            println!();
514            println!("CEP sessions:  {}", store.cep.sessions);
515            println!(
516                "CEP tokens:    {} → {}",
517                store.cep.total_tokens_original, store.cep.total_tokens_compressed
518            );
519            println!();
520            println!("Subcommands: stats reset-cep | stats json");
521        }
522    }
523}
524
525pub fn cmd_cache(args: &[String]) {
526    use crate::core::cli_cache;
527    match args.first().map(|s| s.as_str()) {
528        Some("clear") => {
529            let count = cli_cache::clear();
530            println!("Cleared {count} cached entries.");
531        }
532        Some("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!("                 ls find grep 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 aliases = format!(
1153        "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
1154        set -g _lean_ctx_cmds git npm pnpm yarn cargo docker docker-compose kubectl gh pip pip3 ruff go golangci-lint eslint prettier tsc ls find grep curl wget\n\
1155        \n\
1156        function _lc\n\
1157        \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1158        \t\tcommand $argv\n\
1159        \t\treturn\n\
1160        \tend\n\
1161        \t'{binary}' -c $argv\n\
1162        \tset -l _lc_rc $status\n\
1163        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1164        \t\tcommand $argv\n\
1165        \telse\n\
1166        \t\treturn $_lc_rc\n\
1167        \tend\n\
1168        end\n\
1169        \n\
1170        function lean-ctx-on\n\
1171        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1172        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
1173        \tend\n\
1174        \talias k '_lc kubectl'\n\
1175        \tset -gx LEAN_CTX_ENABLED 1\n\
1176        \techo 'lean-ctx: ON'\n\
1177        end\n\
1178        \n\
1179        function lean-ctx-off\n\
1180        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1181        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
1182        \tend\n\
1183        \tfunctions --erase k 2>/dev/null; true\n\
1184        \tset -e LEAN_CTX_ENABLED\n\
1185        \techo 'lean-ctx: OFF'\n\
1186        end\n\
1187        \n\
1188        function lean-ctx-raw\n\
1189        \tset -lx LEAN_CTX_RAW 1\n\
1190        \tcommand $argv\n\
1191        end\n\
1192        \n\
1193        function lean-ctx-status\n\
1194        \tif set -q LEAN_CTX_DISABLED\n\
1195        \t\techo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
1196        \telse if set -q LEAN_CTX_ENABLED\n\
1197        \t\techo 'lean-ctx: ON'\n\
1198        \telse\n\
1199        \t\techo 'lean-ctx: OFF'\n\
1200        \tend\n\
1201        end\n\
1202        \n\
1203        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\
1204        \tif command -q lean-ctx\n\
1205        \t\tlean-ctx-on\n\
1206        \tend\n\
1207        end\n\
1208        # lean-ctx shell hook — end\n"
1209    );
1210
1211    backup_shell_config(&config);
1212
1213    if let Ok(existing) = std::fs::read_to_string(&config) {
1214        if existing.contains("lean-ctx shell hook") {
1215            let cleaned = remove_lean_ctx_block(&existing);
1216            match std::fs::write(&config, format!("{cleaned}{aliases}")) {
1217                Ok(()) => {
1218                    qprintln!("Updated lean-ctx aliases in {}", config.display());
1219                    qprintln!("  Binary: {binary}");
1220                    return;
1221                }
1222                Err(e) => {
1223                    eprintln!("Error updating {}: {e}", config.display());
1224                    return;
1225                }
1226            }
1227        }
1228    }
1229
1230    match std::fs::OpenOptions::new()
1231        .append(true)
1232        .create(true)
1233        .open(&config)
1234    {
1235        Ok(mut f) => {
1236            use std::io::Write;
1237            let _ = f.write_all(aliases.as_bytes());
1238            qprintln!("Added lean-ctx aliases to {}", config.display());
1239            qprintln!("  Binary: {binary}");
1240        }
1241        Err(e) => eprintln!("Error writing {}: {e}", config.display()),
1242    }
1243}
1244
1245pub fn init_posix(is_zsh: bool, binary: &str) {
1246    let rc_file = if is_zsh {
1247        dirs::home_dir()
1248            .map(|h| h.join(".zshrc"))
1249            .unwrap_or_default()
1250    } else {
1251        dirs::home_dir()
1252            .map(|h| h.join(".bashrc"))
1253            .unwrap_or_default()
1254    };
1255
1256    let aliases = format!(
1257        r#"
1258# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1259_lean_ctx_cmds=(git npm pnpm yarn cargo docker docker-compose kubectl gh pip pip3 ruff go golangci-lint eslint prettier tsc ls find grep curl wget php composer)
1260
1261_lc() {{
1262    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1263        command "$@"
1264        return
1265    fi
1266    '{binary}' -c "$@"
1267    local _lc_rc=$?
1268    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1269        command "$@"
1270    else
1271        return "$_lc_rc"
1272    fi
1273}}
1274
1275lean-ctx-on() {{
1276    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1277        # shellcheck disable=SC2139
1278        alias "$_lc_cmd"='_lc '"$_lc_cmd"
1279    done
1280    alias k='_lc kubectl'
1281    export LEAN_CTX_ENABLED=1
1282    echo "lean-ctx: ON"
1283}}
1284
1285lean-ctx-off() {{
1286    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1287        unalias "$_lc_cmd" 2>/dev/null || true
1288    done
1289    unalias k 2>/dev/null || true
1290    unset LEAN_CTX_ENABLED
1291    echo "lean-ctx: OFF"
1292}}
1293
1294lean-ctx-raw() {{
1295    LEAN_CTX_RAW=1 command "$@"
1296}}
1297
1298lean-ctx-status() {{
1299    if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1300        echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
1301    elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1302        echo "lean-ctx: ON"
1303    else
1304        echo "lean-ctx: OFF"
1305    fi
1306}}
1307
1308if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1309    command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
1310fi
1311# lean-ctx shell hook — end
1312"#
1313    );
1314
1315    backup_shell_config(&rc_file);
1316
1317    if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1318        if existing.contains("lean-ctx shell hook") {
1319            let cleaned = remove_lean_ctx_block(&existing);
1320            match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1321                Ok(()) => {
1322                    qprintln!("Updated lean-ctx aliases in {}", rc_file.display());
1323                    qprintln!("  Binary: {binary}");
1324                    return;
1325                }
1326                Err(e) => {
1327                    eprintln!("Error updating {}: {e}", rc_file.display());
1328                    return;
1329                }
1330            }
1331        }
1332    }
1333
1334    match std::fs::OpenOptions::new()
1335        .append(true)
1336        .create(true)
1337        .open(&rc_file)
1338    {
1339        Ok(mut f) => {
1340            use std::io::Write;
1341            let _ = f.write_all(aliases.as_bytes());
1342            qprintln!("Added lean-ctx aliases to {}", rc_file.display());
1343            qprintln!("  Binary: {binary}");
1344        }
1345        Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1346    }
1347
1348    write_env_sh_for_containers(&aliases);
1349    print_docker_env_hints(is_zsh);
1350}
1351
1352fn write_env_sh_for_containers(aliases: &str) {
1353    let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
1354        Ok(d) => d.join("env.sh"),
1355        Err(_) => return,
1356    };
1357    if let Some(parent) = env_sh.parent() {
1358        let _ = std::fs::create_dir_all(parent);
1359    }
1360    let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
1361    let mut content = sanitized_aliases;
1362    content.push_str(
1363        r#"
1364
1365# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
1366if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
1367  if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
1368    LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
1369  fi
1370fi
1371"#,
1372    );
1373    match std::fs::write(&env_sh, content) {
1374        Ok(()) => qprintln!("  env.sh: {}", env_sh.display()),
1375        Err(e) => eprintln!("  Warning: could not write {}: {e}", env_sh.display()),
1376    }
1377}
1378
1379fn print_docker_env_hints(is_zsh: bool) {
1380    if is_zsh || !crate::shell::is_container() {
1381        return;
1382    }
1383    let env_sh = crate::core::data_dir::lean_ctx_data_dir()
1384        .map(|d| d.join("env.sh").to_string_lossy().to_string())
1385        .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
1386
1387    let has_bash_env = std::env::var("BASH_ENV").is_ok();
1388    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
1389
1390    if has_bash_env && has_claude_env {
1391        return;
1392    }
1393
1394    eprintln!();
1395    eprintln!("  \x1b[33m⚠  Docker detected — environment hints:\x1b[0m");
1396
1397    if !has_bash_env {
1398        eprintln!("  For generic bash -c usage (non-interactive shells):");
1399        eprintln!("    \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
1400    }
1401    if !has_claude_env {
1402        eprintln!("  For Claude Code (sources before each command):");
1403        eprintln!("    \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
1404    }
1405    eprintln!();
1406}
1407
1408fn remove_lean_ctx_block(content: &str) -> String {
1409    // New format uses explicit end marker; old format ends at first top-level `fi`/`end`.
1410    if content.contains("# lean-ctx shell hook — end") {
1411        return remove_lean_ctx_block_by_marker(content);
1412    }
1413    remove_lean_ctx_block_legacy(content)
1414}
1415
1416fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1417    let mut result = String::new();
1418    let mut in_block = false;
1419
1420    for line in content.lines() {
1421        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1422            in_block = true;
1423            continue;
1424        }
1425        if in_block {
1426            if line.trim() == "# lean-ctx shell hook — end" {
1427                in_block = false;
1428            }
1429            continue;
1430        }
1431        result.push_str(line);
1432        result.push('\n');
1433    }
1434    result
1435}
1436
1437fn remove_lean_ctx_block_legacy(content: &str) -> String {
1438    let mut result = String::new();
1439    let mut in_block = false;
1440
1441    for line in content.lines() {
1442        if line.contains("lean-ctx shell hook") {
1443            in_block = true;
1444            continue;
1445        }
1446        if in_block {
1447            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1448                if line.trim() == "fi" || line.trim() == "end" {
1449                    in_block = false;
1450                }
1451                continue;
1452            }
1453            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1454                in_block = false;
1455                result.push_str(line);
1456                result.push('\n');
1457            }
1458            continue;
1459        }
1460        result.push_str(line);
1461        result.push('\n');
1462    }
1463    result
1464}
1465
1466pub fn load_shell_history_pub() -> Vec<String> {
1467    load_shell_history()
1468}
1469
1470fn load_shell_history() -> Vec<String> {
1471    let shell = std::env::var("SHELL").unwrap_or_default();
1472    let home = match dirs::home_dir() {
1473        Some(h) => h,
1474        None => return Vec::new(),
1475    };
1476
1477    let history_file = if shell.contains("zsh") {
1478        home.join(".zsh_history")
1479    } else if shell.contains("fish") {
1480        home.join(".local/share/fish/fish_history")
1481    } else if cfg!(windows) && shell.is_empty() {
1482        home.join("AppData")
1483            .join("Roaming")
1484            .join("Microsoft")
1485            .join("Windows")
1486            .join("PowerShell")
1487            .join("PSReadLine")
1488            .join("ConsoleHost_history.txt")
1489    } else {
1490        home.join(".bash_history")
1491    };
1492
1493    match std::fs::read_to_string(&history_file) {
1494        Ok(content) => content
1495            .lines()
1496            .filter_map(|l| {
1497                let trimmed = l.trim();
1498                if trimmed.starts_with(':') {
1499                    trimmed.split(';').nth(1).map(|s| s.to_string())
1500                } else {
1501                    Some(trimmed.to_string())
1502                }
1503            })
1504            .filter(|l| !l.is_empty())
1505            .collect(),
1506        Err(_) => Vec::new(),
1507    }
1508}
1509
1510fn print_savings(original: usize, sent: usize) {
1511    let saved = original.saturating_sub(sent);
1512    if original > 0 && saved > 0 {
1513        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1514        println!("[{saved} tok saved ({pct}%)]");
1515    }
1516}
1517
1518pub fn cmd_theme(args: &[String]) {
1519    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1520    let r = theme::rst();
1521    let b = theme::bold();
1522    let d = theme::dim();
1523
1524    match sub {
1525        "list" => {
1526            let cfg = config::Config::load();
1527            let active = cfg.theme.as_str();
1528            println!();
1529            println!("  {b}Available themes:{r}");
1530            println!("  {ln}", ln = "─".repeat(40));
1531            for name in theme::PRESET_NAMES {
1532                let marker = if *name == active { " ◀ active" } else { "" };
1533                let t = theme::from_preset(name).unwrap();
1534                let preview = format!(
1535                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1536                    p = t.primary.fg(),
1537                    s = t.secondary.fg(),
1538                    a = t.accent.fg(),
1539                    sc = t.success.fg(),
1540                    w = t.warning.fg(),
1541                );
1542                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1543            }
1544            if let Some(path) = theme::theme_file_path() {
1545                if path.exists() {
1546                    let custom = theme::load_theme("_custom_");
1547                    let preview = format!(
1548                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1549                        p = custom.primary.fg(),
1550                        s = custom.secondary.fg(),
1551                        a = custom.accent.fg(),
1552                        sc = custom.success.fg(),
1553                        w = custom.warning.fg(),
1554                    );
1555                    let marker = if active == "custom" {
1556                        " ◀ active"
1557                    } else {
1558                        ""
1559                    };
1560                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1561                }
1562            }
1563            println!();
1564            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1565            println!();
1566        }
1567        "set" => {
1568            if args.len() < 2 {
1569                eprintln!("Usage: lean-ctx theme set <name>");
1570                std::process::exit(1);
1571            }
1572            let name = &args[1];
1573            if theme::from_preset(name).is_none() && name != "custom" {
1574                eprintln!(
1575                    "Unknown theme '{name}'. Available: {}",
1576                    theme::PRESET_NAMES.join(", ")
1577                );
1578                std::process::exit(1);
1579            }
1580            let mut cfg = config::Config::load();
1581            cfg.theme = name.to_string();
1582            match cfg.save() {
1583                Ok(()) => {
1584                    let t = theme::load_theme(name);
1585                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1586                    let preview = t.gradient_bar(0.75, 30);
1587                    println!("  {preview}");
1588                }
1589                Err(e) => eprintln!("Error: {e}"),
1590            }
1591        }
1592        "export" => {
1593            let cfg = config::Config::load();
1594            let t = theme::load_theme(&cfg.theme);
1595            println!("{}", t.to_toml());
1596        }
1597        "import" => {
1598            if args.len() < 2 {
1599                eprintln!("Usage: lean-ctx theme import <path>");
1600                std::process::exit(1);
1601            }
1602            let path = std::path::Path::new(&args[1]);
1603            if !path.exists() {
1604                eprintln!("File not found: {}", args[1]);
1605                std::process::exit(1);
1606            }
1607            match std::fs::read_to_string(path) {
1608                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1609                    Ok(imported) => match theme::save_theme(&imported) {
1610                        Ok(()) => {
1611                            let mut cfg = config::Config::load();
1612                            cfg.theme = "custom".to_string();
1613                            let _ = cfg.save();
1614                            println!(
1615                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1616                                sc = imported.success.fg(),
1617                                name = imported.name,
1618                            );
1619                            println!("  Config updated: theme = custom");
1620                        }
1621                        Err(e) => eprintln!("Error saving theme: {e}"),
1622                    },
1623                    Err(e) => eprintln!("Invalid theme file: {e}"),
1624                },
1625                Err(e) => eprintln!("Error reading file: {e}"),
1626            }
1627        }
1628        "preview" => {
1629            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1630            let t = match theme::from_preset(name) {
1631                Some(t) => t,
1632                None => {
1633                    eprintln!("Unknown theme: {name}");
1634                    std::process::exit(1);
1635                }
1636            };
1637            println!();
1638            println!(
1639                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1640                icon = t.header_icon(),
1641                title = t.brand_title(),
1642            );
1643            println!("  {ln}", ln = t.border_line(50));
1644            println!();
1645            println!(
1646                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1647                sc = t.success.fg(),
1648                sec = t.secondary.fg(),
1649                wrn = t.warning.fg(),
1650                acc = t.accent.fg(),
1651            );
1652            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1653            println!();
1654            println!(
1655                "  {b}{txt}Gradient Bar{r}      {bar}",
1656                txt = t.text.fg(),
1657                bar = t.gradient_bar(0.85, 30),
1658            );
1659            println!(
1660                "  {b}{txt}Sparkline{r}         {spark}",
1661                txt = t.text.fg(),
1662                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1663            );
1664            println!();
1665            println!("  {top}", top = t.box_top(50));
1666            println!(
1667                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1668                side = t.box_side(),
1669                side_r = t.box_side(),
1670                txt = t.text.fg(),
1671            );
1672            println!("  {bot}", bot = t.box_bottom(50));
1673            println!();
1674        }
1675        _ => {
1676            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1677            std::process::exit(1);
1678        }
1679    }
1680}
1681
1682#[cfg(test)]
1683mod tests {
1684    use super::*;
1685    use tempfile;
1686
1687    #[test]
1688    fn test_remove_lean_ctx_block_posix() {
1689        let input = r#"# existing config
1690export PATH="$HOME/bin:$PATH"
1691
1692# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1693if [ -z "$LEAN_CTX_ACTIVE" ]; then
1694alias git='lean-ctx -c git'
1695alias npm='lean-ctx -c npm'
1696fi
1697
1698# other stuff
1699export EDITOR=vim
1700"#;
1701        let result = remove_lean_ctx_block(input);
1702        assert!(!result.contains("lean-ctx"), "block should be removed");
1703        assert!(result.contains("export PATH"), "other content preserved");
1704        assert!(
1705            result.contains("export EDITOR"),
1706            "trailing content preserved"
1707        );
1708    }
1709
1710    #[test]
1711    fn test_remove_lean_ctx_block_fish() {
1712        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";
1713        let result = remove_lean_ctx_block(input);
1714        assert!(!result.contains("lean-ctx"), "block should be removed");
1715        assert!(result.contains("set -x FOO"), "other content preserved");
1716        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1717    }
1718
1719    #[test]
1720    fn test_remove_lean_ctx_block_ps() {
1721        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";
1722        let result = remove_lean_ctx_block_ps(input);
1723        assert!(
1724            !result.contains("lean-ctx shell hook"),
1725            "block should be removed"
1726        );
1727        assert!(result.contains("$env:FOO"), "other content preserved");
1728        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1729    }
1730
1731    #[test]
1732    fn test_remove_lean_ctx_block_ps_nested() {
1733        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";
1734        let result = remove_lean_ctx_block_ps(input);
1735        assert!(
1736            !result.contains("lean-ctx shell hook"),
1737            "block should be removed"
1738        );
1739        assert!(!result.contains("_lc"), "function should be removed");
1740        assert!(result.contains("$env:FOO"), "other content preserved");
1741        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1742    }
1743
1744    #[test]
1745    fn test_remove_block_no_lean_ctx() {
1746        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1747        let result = remove_lean_ctx_block(input);
1748        assert!(result.contains("export PATH"), "content unchanged");
1749    }
1750
1751    #[test]
1752    fn test_bash_hook_contains_pipe_guard() {
1753        let binary = "/usr/local/bin/lean-ctx";
1754        let hook = format!(
1755            r#"_lc() {{
1756    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1757        command "$@"
1758        return
1759    fi
1760    '{binary}' -c "$@"
1761}}"#
1762        );
1763        assert!(
1764            hook.contains("! -t 1"),
1765            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1766        );
1767        assert!(
1768            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1769            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1770        );
1771    }
1772
1773    #[test]
1774    fn test_fish_hook_contains_pipe_guard() {
1775        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";
1776        assert!(
1777            hook.contains("isatty stdout"),
1778            "fish hook must contain pipe guard (isatty stdout)"
1779        );
1780    }
1781
1782    #[test]
1783    fn test_powershell_hook_contains_pipe_guard() {
1784        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1785        assert!(
1786            hook.contains("IsOutputRedirected"),
1787            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1788        );
1789    }
1790
1791    #[test]
1792    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1793        let input = r#"# existing config
1794export PATH="$HOME/bin:$PATH"
1795
1796# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1797_lean_ctx_cmds=(git npm pnpm)
1798
1799lean-ctx-on() {
1800    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1801        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1802    done
1803    export LEAN_CTX_ENABLED=1
1804    echo "lean-ctx: ON"
1805}
1806
1807lean-ctx-off() {
1808    unset LEAN_CTX_ENABLED
1809    echo "lean-ctx: OFF"
1810}
1811
1812if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1813    lean-ctx-on
1814fi
1815# lean-ctx shell hook — end
1816
1817# other stuff
1818export EDITOR=vim
1819"#;
1820        let result = remove_lean_ctx_block(input);
1821        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1822        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1823        assert!(result.contains("export PATH"), "other content preserved");
1824        assert!(
1825            result.contains("export EDITOR"),
1826            "trailing content preserved"
1827        );
1828    }
1829
1830    #[test]
1831    fn env_sh_for_containers_includes_self_heal() {
1832        let _g = crate::core::data_dir::test_env_lock();
1833        let tmp = tempfile::tempdir().expect("tempdir");
1834        let data_dir = tmp.path().join("data");
1835        std::fs::create_dir_all(&data_dir).expect("mkdir data");
1836        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1837
1838        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1839        let env_sh = data_dir.join("env.sh");
1840        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1841        assert!(content.contains("lean-ctx docker self-heal"));
1842        assert!(content.contains("claude mcp list"));
1843        assert!(content.contains("lean-ctx init --agent claude"));
1844
1845        std::env::remove_var("LEAN_CTX_DATA_DIR");
1846    }
1847}