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