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_bash_env_hint(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_bash_env_hint(is_zsh: bool) {
1328    if is_zsh || !crate::shell::is_container() {
1329        return;
1330    }
1331    if std::env::var("BASH_ENV").is_ok() {
1332        return;
1333    }
1334    let env_sh = dirs::home_dir()
1335        .map(|h| {
1336            h.join(".lean-ctx")
1337                .join("env.sh")
1338                .to_string_lossy()
1339                .to_string()
1340        })
1341        .unwrap_or_else(|| "/root/.lean-ctx/env.sh".to_string());
1342    eprintln!();
1343    eprintln!("  \x1b[33m⚠  Docker detected — BASH_ENV is not set\x1b[0m");
1344    eprintln!("  AI agents run commands via bash -c (non-interactive),");
1345    eprintln!("  which skips ~/.bashrc. Add this to your Dockerfile:");
1346    eprintln!();
1347    eprintln!("    \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
1348    eprintln!();
1349}
1350
1351fn remove_lean_ctx_block(content: &str) -> String {
1352    // New format uses explicit end marker; old format ends at first top-level `fi`/`end`.
1353    if content.contains("# lean-ctx shell hook — end") {
1354        return remove_lean_ctx_block_by_marker(content);
1355    }
1356    remove_lean_ctx_block_legacy(content)
1357}
1358
1359fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1360    let mut result = String::new();
1361    let mut in_block = false;
1362
1363    for line in content.lines() {
1364        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1365            in_block = true;
1366            continue;
1367        }
1368        if in_block {
1369            if line.trim() == "# lean-ctx shell hook — end" {
1370                in_block = false;
1371            }
1372            continue;
1373        }
1374        result.push_str(line);
1375        result.push('\n');
1376    }
1377    result
1378}
1379
1380fn remove_lean_ctx_block_legacy(content: &str) -> String {
1381    let mut result = String::new();
1382    let mut in_block = false;
1383
1384    for line in content.lines() {
1385        if line.contains("lean-ctx shell hook") {
1386            in_block = true;
1387            continue;
1388        }
1389        if in_block {
1390            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1391                if line.trim() == "fi" || line.trim() == "end" {
1392                    in_block = false;
1393                }
1394                continue;
1395            }
1396            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1397                in_block = false;
1398                result.push_str(line);
1399                result.push('\n');
1400            }
1401            continue;
1402        }
1403        result.push_str(line);
1404        result.push('\n');
1405    }
1406    result
1407}
1408
1409pub fn load_shell_history_pub() -> Vec<String> {
1410    load_shell_history()
1411}
1412
1413fn load_shell_history() -> Vec<String> {
1414    let shell = std::env::var("SHELL").unwrap_or_default();
1415    let home = match dirs::home_dir() {
1416        Some(h) => h,
1417        None => return Vec::new(),
1418    };
1419
1420    let history_file = if shell.contains("zsh") {
1421        home.join(".zsh_history")
1422    } else if shell.contains("fish") {
1423        home.join(".local/share/fish/fish_history")
1424    } else if cfg!(windows) && shell.is_empty() {
1425        home.join("AppData")
1426            .join("Roaming")
1427            .join("Microsoft")
1428            .join("Windows")
1429            .join("PowerShell")
1430            .join("PSReadLine")
1431            .join("ConsoleHost_history.txt")
1432    } else {
1433        home.join(".bash_history")
1434    };
1435
1436    match std::fs::read_to_string(&history_file) {
1437        Ok(content) => content
1438            .lines()
1439            .filter_map(|l| {
1440                let trimmed = l.trim();
1441                if trimmed.starts_with(':') {
1442                    trimmed.split(';').nth(1).map(|s| s.to_string())
1443                } else {
1444                    Some(trimmed.to_string())
1445                }
1446            })
1447            .filter(|l| !l.is_empty())
1448            .collect(),
1449        Err(_) => Vec::new(),
1450    }
1451}
1452
1453fn print_savings(original: usize, sent: usize) {
1454    let saved = original.saturating_sub(sent);
1455    if original > 0 && saved > 0 {
1456        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1457        println!("[{saved} tok saved ({pct}%)]");
1458    }
1459}
1460
1461pub fn cmd_theme(args: &[String]) {
1462    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1463    let r = theme::rst();
1464    let b = theme::bold();
1465    let d = theme::dim();
1466
1467    match sub {
1468        "list" => {
1469            let cfg = config::Config::load();
1470            let active = cfg.theme.as_str();
1471            println!();
1472            println!("  {b}Available themes:{r}");
1473            println!("  {ln}", ln = "─".repeat(40));
1474            for name in theme::PRESET_NAMES {
1475                let marker = if *name == active { " ◀ active" } else { "" };
1476                let t = theme::from_preset(name).unwrap();
1477                let preview = format!(
1478                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1479                    p = t.primary.fg(),
1480                    s = t.secondary.fg(),
1481                    a = t.accent.fg(),
1482                    sc = t.success.fg(),
1483                    w = t.warning.fg(),
1484                );
1485                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1486            }
1487            if let Some(path) = theme::theme_file_path() {
1488                if path.exists() {
1489                    let custom = theme::load_theme("_custom_");
1490                    let preview = format!(
1491                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1492                        p = custom.primary.fg(),
1493                        s = custom.secondary.fg(),
1494                        a = custom.accent.fg(),
1495                        sc = custom.success.fg(),
1496                        w = custom.warning.fg(),
1497                    );
1498                    let marker = if active == "custom" {
1499                        " ◀ active"
1500                    } else {
1501                        ""
1502                    };
1503                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1504                }
1505            }
1506            println!();
1507            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1508            println!();
1509        }
1510        "set" => {
1511            if args.len() < 2 {
1512                eprintln!("Usage: lean-ctx theme set <name>");
1513                std::process::exit(1);
1514            }
1515            let name = &args[1];
1516            if theme::from_preset(name).is_none() && name != "custom" {
1517                eprintln!(
1518                    "Unknown theme '{name}'. Available: {}",
1519                    theme::PRESET_NAMES.join(", ")
1520                );
1521                std::process::exit(1);
1522            }
1523            let mut cfg = config::Config::load();
1524            cfg.theme = name.to_string();
1525            match cfg.save() {
1526                Ok(()) => {
1527                    let t = theme::load_theme(name);
1528                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1529                    let preview = t.gradient_bar(0.75, 30);
1530                    println!("  {preview}");
1531                }
1532                Err(e) => eprintln!("Error: {e}"),
1533            }
1534        }
1535        "export" => {
1536            let cfg = config::Config::load();
1537            let t = theme::load_theme(&cfg.theme);
1538            println!("{}", t.to_toml());
1539        }
1540        "import" => {
1541            if args.len() < 2 {
1542                eprintln!("Usage: lean-ctx theme import <path>");
1543                std::process::exit(1);
1544            }
1545            let path = std::path::Path::new(&args[1]);
1546            if !path.exists() {
1547                eprintln!("File not found: {}", args[1]);
1548                std::process::exit(1);
1549            }
1550            match std::fs::read_to_string(path) {
1551                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1552                    Ok(imported) => match theme::save_theme(&imported) {
1553                        Ok(()) => {
1554                            let mut cfg = config::Config::load();
1555                            cfg.theme = "custom".to_string();
1556                            let _ = cfg.save();
1557                            println!(
1558                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1559                                sc = imported.success.fg(),
1560                                name = imported.name,
1561                            );
1562                            println!("  Config updated: theme = custom");
1563                        }
1564                        Err(e) => eprintln!("Error saving theme: {e}"),
1565                    },
1566                    Err(e) => eprintln!("Invalid theme file: {e}"),
1567                },
1568                Err(e) => eprintln!("Error reading file: {e}"),
1569            }
1570        }
1571        "preview" => {
1572            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1573            let t = match theme::from_preset(name) {
1574                Some(t) => t,
1575                None => {
1576                    eprintln!("Unknown theme: {name}");
1577                    std::process::exit(1);
1578                }
1579            };
1580            println!();
1581            println!(
1582                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1583                icon = t.header_icon(),
1584                title = t.brand_title(),
1585            );
1586            println!("  {ln}", ln = t.border_line(50));
1587            println!();
1588            println!(
1589                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1590                sc = t.success.fg(),
1591                sec = t.secondary.fg(),
1592                wrn = t.warning.fg(),
1593                acc = t.accent.fg(),
1594            );
1595            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1596            println!();
1597            println!(
1598                "  {b}{txt}Gradient Bar{r}      {bar}",
1599                txt = t.text.fg(),
1600                bar = t.gradient_bar(0.85, 30),
1601            );
1602            println!(
1603                "  {b}{txt}Sparkline{r}         {spark}",
1604                txt = t.text.fg(),
1605                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1606            );
1607            println!();
1608            println!("  {top}", top = t.box_top(50));
1609            println!(
1610                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1611                side = t.box_side(),
1612                side_r = t.box_side(),
1613                txt = t.text.fg(),
1614            );
1615            println!("  {bot}", bot = t.box_bottom(50));
1616            println!();
1617        }
1618        _ => {
1619            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1620            std::process::exit(1);
1621        }
1622    }
1623}
1624
1625#[cfg(test)]
1626mod tests {
1627    use super::*;
1628
1629    #[test]
1630    fn test_remove_lean_ctx_block_posix() {
1631        let input = r#"# existing config
1632export PATH="$HOME/bin:$PATH"
1633
1634# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1635if [ -z "$LEAN_CTX_ACTIVE" ]; then
1636alias git='lean-ctx -c git'
1637alias npm='lean-ctx -c npm'
1638fi
1639
1640# other stuff
1641export EDITOR=vim
1642"#;
1643        let result = remove_lean_ctx_block(input);
1644        assert!(!result.contains("lean-ctx"), "block should be removed");
1645        assert!(result.contains("export PATH"), "other content preserved");
1646        assert!(
1647            result.contains("export EDITOR"),
1648            "trailing content preserved"
1649        );
1650    }
1651
1652    #[test]
1653    fn test_remove_lean_ctx_block_fish() {
1654        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";
1655        let result = remove_lean_ctx_block(input);
1656        assert!(!result.contains("lean-ctx"), "block should be removed");
1657        assert!(result.contains("set -x FOO"), "other content preserved");
1658        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1659    }
1660
1661    #[test]
1662    fn test_remove_lean_ctx_block_ps() {
1663        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";
1664        let result = remove_lean_ctx_block_ps(input);
1665        assert!(
1666            !result.contains("lean-ctx shell hook"),
1667            "block should be removed"
1668        );
1669        assert!(result.contains("$env:FOO"), "other content preserved");
1670        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1671    }
1672
1673    #[test]
1674    fn test_remove_lean_ctx_block_ps_nested() {
1675        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";
1676        let result = remove_lean_ctx_block_ps(input);
1677        assert!(
1678            !result.contains("lean-ctx shell hook"),
1679            "block should be removed"
1680        );
1681        assert!(!result.contains("_lc"), "function should be removed");
1682        assert!(result.contains("$env:FOO"), "other content preserved");
1683        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1684    }
1685
1686    #[test]
1687    fn test_remove_block_no_lean_ctx() {
1688        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1689        let result = remove_lean_ctx_block(input);
1690        assert!(result.contains("export PATH"), "content unchanged");
1691    }
1692
1693    #[test]
1694    fn test_bash_hook_contains_pipe_guard() {
1695        let binary = "/usr/local/bin/lean-ctx";
1696        let hook = format!(
1697            r#"_lc() {{
1698    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1699        command "$@"
1700        return
1701    fi
1702    '{binary}' -c "$@"
1703}}"#
1704        );
1705        assert!(
1706            hook.contains("! -t 1"),
1707            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1708        );
1709        assert!(
1710            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1711            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1712        );
1713    }
1714
1715    #[test]
1716    fn test_fish_hook_contains_pipe_guard() {
1717        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";
1718        assert!(
1719            hook.contains("isatty stdout"),
1720            "fish hook must contain pipe guard (isatty stdout)"
1721        );
1722    }
1723
1724    #[test]
1725    fn test_powershell_hook_contains_pipe_guard() {
1726        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1727        assert!(
1728            hook.contains("IsOutputRedirected"),
1729            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1730        );
1731    }
1732
1733    #[test]
1734    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1735        let input = r#"# existing config
1736export PATH="$HOME/bin:$PATH"
1737
1738# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1739_lean_ctx_cmds=(git npm pnpm)
1740
1741lean-ctx-on() {
1742    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1743        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1744    done
1745    export LEAN_CTX_ENABLED=1
1746    echo "lean-ctx: ON"
1747}
1748
1749lean-ctx-off() {
1750    unset LEAN_CTX_ENABLED
1751    echo "lean-ctx: OFF"
1752}
1753
1754if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1755    lean-ctx-on
1756fi
1757# lean-ctx shell hook — end
1758
1759# other stuff
1760export EDITOR=vim
1761"#;
1762        let result = remove_lean_ctx_block(input);
1763        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1764        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1765        assert!(result.contains("export PATH"), "other content preserved");
1766        assert!(
1767            result.contains("export EDITOR"),
1768            "trailing content preserved"
1769        );
1770    }
1771}