Skip to main content

lean_ctx/cli/
mod.rs

1pub mod cloud;
2pub mod dispatch;
3mod shell_init;
4
5pub use dispatch::run;
6pub use shell_init::*;
7
8use std::path::Path;
9
10use crate::core::compressor;
11use crate::core::config;
12use crate::core::deps as dep_extract;
13use crate::core::entropy;
14use crate::core::patterns::deps_cmd;
15use crate::core::protocol;
16use crate::core::signatures;
17use crate::core::stats;
18use crate::core::theme;
19use crate::core::tokens::count_tokens;
20use crate::hooks::to_bash_compatible_path;
21
22pub fn cmd_read(args: &[String]) {
23    if args.is_empty() {
24        eprintln!(
25            "Usage: lean-ctx read <file> [--mode full|map|signatures|aggressive|entropy] [--fresh]"
26        );
27        std::process::exit(1);
28    }
29
30    let path = &args[0];
31    let mode = args
32        .iter()
33        .position(|a| a == "--mode" || a == "-m")
34        .and_then(|i| args.get(i + 1))
35        .map(|s| s.as_str())
36        .unwrap_or("full");
37    let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
38
39    let short = protocol::shorten_path(path);
40
41    if !force_fresh && mode == "full" {
42        use crate::core::cli_cache::{self, CacheResult};
43        match cli_cache::check_and_read(path) {
44            CacheResult::Hit { entry, file_ref } => {
45                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
46                println!("{msg}");
47                stats::record("cli_read", entry.original_tokens, count_tokens(&msg));
48                return;
49            }
50            CacheResult::Miss { content } if content.is_empty() => {
51                eprintln!("Error: could not read {path}");
52                std::process::exit(1);
53            }
54            CacheResult::Miss { content } => {
55                let line_count = content.lines().count();
56                println!("{short} [{line_count}L]");
57                println!("{content}");
58                stats::record("cli_read", count_tokens(&content), count_tokens(&content));
59                return;
60            }
61        }
62    }
63
64    let content = match crate::tools::ctx_read::read_file_lossy(path) {
65        Ok(c) => c,
66        Err(e) => {
67            eprintln!("Error: {e}");
68            std::process::exit(1);
69        }
70    };
71
72    let ext = Path::new(path)
73        .extension()
74        .and_then(|e| e.to_str())
75        .unwrap_or("");
76    let line_count = content.lines().count();
77    let original_tokens = count_tokens(&content);
78
79    let mode = if mode == "auto" {
80        let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
81        let predictor = crate::core::mode_predictor::ModePredictor::new();
82        predictor
83            .predict_best_mode(&sig)
84            .unwrap_or_else(|| "full".to_string())
85    } else {
86        mode.to_string()
87    };
88    let mode = mode.as_str();
89
90    match mode {
91        "map" => {
92            let sigs = signatures::extract_signatures(&content, ext);
93            let dep_info = dep_extract::extract_deps(&content, ext);
94
95            println!("{short} [{line_count}L]");
96            if !dep_info.imports.is_empty() {
97                println!("  deps: {}", dep_info.imports.join(", "));
98            }
99            if !dep_info.exports.is_empty() {
100                println!("  exports: {}", dep_info.exports.join(", "));
101            }
102            let key_sigs: Vec<_> = sigs
103                .iter()
104                .filter(|s| s.is_exported || s.indent == 0)
105                .collect();
106            if !key_sigs.is_empty() {
107                println!("  API:");
108                for sig in &key_sigs {
109                    println!("    {}", sig.to_compact());
110                }
111            }
112            let sent = count_tokens(&short.to_string());
113            print_savings(original_tokens, sent);
114        }
115        "signatures" => {
116            let sigs = signatures::extract_signatures(&content, ext);
117            println!("{short} [{line_count}L]");
118            for sig in &sigs {
119                println!("{}", sig.to_compact());
120            }
121            let sent = count_tokens(&short.to_string());
122            print_savings(original_tokens, sent);
123        }
124        "aggressive" => {
125            let compressed = compressor::aggressive_compress(&content, Some(ext));
126            println!("{short} [{line_count}L]");
127            println!("{compressed}");
128            let sent = count_tokens(&compressed);
129            print_savings(original_tokens, sent);
130        }
131        "entropy" => {
132            let result = entropy::entropy_compress(&content);
133            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
134            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
135            for tech in &result.techniques {
136                println!("{tech}");
137            }
138            println!("{}", result.output);
139            let sent = count_tokens(&result.output);
140            print_savings(original_tokens, sent);
141        }
142        _ => {
143            println!("{short} [{line_count}L]");
144            println!("{content}");
145        }
146    }
147}
148
149pub fn cmd_diff(args: &[String]) {
150    if args.len() < 2 {
151        eprintln!("Usage: lean-ctx diff <file1> <file2>");
152        std::process::exit(1);
153    }
154
155    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
156        Ok(c) => c,
157        Err(e) => {
158            eprintln!("Error reading {}: {e}", args[0]);
159            std::process::exit(1);
160        }
161    };
162
163    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
164        Ok(c) => c,
165        Err(e) => {
166            eprintln!("Error reading {}: {e}", args[1]);
167            std::process::exit(1);
168        }
169    };
170
171    let diff = compressor::diff_content(&content1, &content2);
172    let original = count_tokens(&content1) + count_tokens(&content2);
173    let sent = count_tokens(&diff);
174
175    println!(
176        "diff {} {}",
177        protocol::shorten_path(&args[0]),
178        protocol::shorten_path(&args[1])
179    );
180    println!("{diff}");
181    print_savings(original, sent);
182}
183
184pub fn cmd_grep(args: &[String]) {
185    if args.is_empty() {
186        eprintln!("Usage: lean-ctx grep <pattern> [path]");
187        std::process::exit(1);
188    }
189
190    let pattern = &args[0];
191    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
192
193    let re = match regex::Regex::new(pattern) {
194        Ok(r) => r,
195        Err(e) => {
196            eprintln!("Invalid regex pattern: {e}");
197            std::process::exit(1);
198        }
199    };
200
201    let mut found = false;
202    for entry in ignore::WalkBuilder::new(path)
203        .hidden(true)
204        .git_ignore(true)
205        .git_global(true)
206        .git_exclude(true)
207        .max_depth(Some(10))
208        .build()
209        .flatten()
210    {
211        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
212            continue;
213        }
214        let file_path = entry.path();
215        if let Ok(content) = std::fs::read_to_string(file_path) {
216            for (i, line) in content.lines().enumerate() {
217                if re.is_match(line) {
218                    println!("{}:{}:{}", file_path.display(), i + 1, line);
219                    found = true;
220                }
221            }
222        }
223    }
224
225    if !found {
226        std::process::exit(1);
227    }
228}
229
230pub fn cmd_find(args: &[String]) {
231    if args.is_empty() {
232        eprintln!("Usage: lean-ctx find <pattern> [path]");
233        std::process::exit(1);
234    }
235
236    let raw_pattern = &args[0];
237    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
238
239    let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
240    let glob_matcher = if is_glob {
241        glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
242    } else {
243        None
244    };
245    let substring = raw_pattern.to_lowercase();
246
247    let mut found = false;
248    for entry in ignore::WalkBuilder::new(path)
249        .hidden(true)
250        .git_ignore(true)
251        .git_global(true)
252        .git_exclude(true)
253        .max_depth(Some(10))
254        .build()
255        .flatten()
256    {
257        let name = entry.file_name().to_string_lossy().to_lowercase();
258        let matches = if let Some(ref g) = glob_matcher {
259            g.matches(&name)
260        } else {
261            name.contains(&substring)
262        };
263        if matches {
264            println!("{}", entry.path().display());
265            found = true;
266        }
267    }
268
269    if !found {
270        std::process::exit(1);
271    }
272}
273
274pub fn cmd_ls(args: &[String]) {
275    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
276    let command = if cfg!(windows) {
277        format!("dir {}", path.replace('/', "\\"))
278    } else {
279        format!("ls -la {path}")
280    };
281    let code = crate::shell::exec(&command);
282    std::process::exit(code);
283}
284
285pub fn cmd_deps(args: &[String]) {
286    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
287
288    match deps_cmd::detect_and_compress(path) {
289        Some(result) => println!("{result}"),
290        None => {
291            eprintln!("No dependency file found in {path}");
292            std::process::exit(1);
293        }
294    }
295}
296
297pub fn cmd_discover(_args: &[String]) {
298    let history = load_shell_history();
299    if history.is_empty() {
300        println!("No shell history found.");
301        return;
302    }
303
304    let result = crate::tools::ctx_discover::analyze_history(&history, 20);
305    println!("{}", crate::tools::ctx_discover::format_cli_output(&result));
306}
307
308pub fn cmd_session() {
309    let history = load_shell_history();
310    let gain = stats::load_stats();
311
312    let compressible_commands = [
313        "git ",
314        "npm ",
315        "yarn ",
316        "pnpm ",
317        "cargo ",
318        "docker ",
319        "kubectl ",
320        "gh ",
321        "pip ",
322        "pip3 ",
323        "eslint",
324        "prettier",
325        "ruff ",
326        "go ",
327        "golangci-lint",
328        "curl ",
329        "wget ",
330        "grep ",
331        "rg ",
332        "find ",
333        "ls ",
334    ];
335
336    let mut total = 0u32;
337    let mut via_hook = 0u32;
338
339    for line in &history {
340        let cmd = line.trim().to_lowercase();
341        if cmd.starts_with("lean-ctx") {
342            via_hook += 1;
343            total += 1;
344        } else {
345            for p in &compressible_commands {
346                if cmd.starts_with(p) {
347                    total += 1;
348                    break;
349                }
350            }
351        }
352    }
353
354    let pct = if total > 0 {
355        (via_hook as f64 / total as f64 * 100.0).round() as u32
356    } else {
357        0
358    };
359
360    println!("lean-ctx session statistics\n");
361    println!(
362        "Adoption:    {}% ({}/{} compressible commands)",
363        pct, via_hook, total
364    );
365    println!("Saved:       {} tokens total", gain.total_saved);
366    println!("Calls:       {} compressed", gain.total_calls);
367
368    if total > via_hook {
369        let missed = total - via_hook;
370        let est = missed * 150;
371        println!(
372            "Missed:      {} commands (~{} tokens saveable)",
373            missed, est
374        );
375    }
376
377    println!("\nRun 'lean-ctx discover' for details on missed commands.");
378}
379
380pub fn cmd_wrapped(args: &[String]) {
381    let period = if args.iter().any(|a| a == "--month") {
382        "month"
383    } else if args.iter().any(|a| a == "--all") {
384        "all"
385    } else {
386        "week"
387    };
388
389    let report = crate::core::wrapped::WrappedReport::generate(period);
390    println!("{}", report.format_ascii());
391}
392
393pub fn cmd_sessions(args: &[String]) {
394    use crate::core::session::SessionState;
395
396    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
397
398    match action {
399        "list" | "ls" => {
400            let sessions = SessionState::list_sessions();
401            if sessions.is_empty() {
402                println!("No sessions found.");
403                return;
404            }
405            println!("Sessions ({}):\n", sessions.len());
406            for s in sessions.iter().take(20) {
407                let task = s.task.as_deref().unwrap_or("(no task)");
408                let task_short: String = task.chars().take(50).collect();
409                let date = s.updated_at.format("%Y-%m-%d %H:%M");
410                println!(
411                    "  {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
412                    s.id,
413                    s.version,
414                    s.tool_calls,
415                    format_tokens_cli(s.tokens_saved),
416                    date,
417                    task_short
418                );
419            }
420            if sessions.len() > 20 {
421                println!("  ... +{} more", sessions.len() - 20);
422            }
423        }
424        "show" => {
425            let id = args.get(1);
426            let session = if let Some(id) = id {
427                SessionState::load_by_id(id)
428            } else {
429                SessionState::load_latest()
430            };
431            match session {
432                Some(s) => println!("{}", s.format_compact()),
433                None => println!("Session not found."),
434            }
435        }
436        "cleanup" => {
437            let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
438            let removed = SessionState::cleanup_old_sessions(days);
439            println!("Cleaned up {removed} session(s) older than {days} days.");
440        }
441        _ => {
442            eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
443            std::process::exit(1);
444        }
445    }
446}
447
448pub fn cmd_benchmark(args: &[String]) {
449    use crate::core::benchmark;
450
451    let action = args.first().map(|s| s.as_str()).unwrap_or("run");
452
453    match action {
454        "run" => {
455            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
456            let is_json = args.iter().any(|a| a == "--json");
457
458            let result = benchmark::run_project_benchmark(path);
459            if is_json {
460                println!("{}", benchmark::format_json(&result));
461            } else {
462                println!("{}", benchmark::format_terminal(&result));
463            }
464        }
465        "report" => {
466            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
467            let result = benchmark::run_project_benchmark(path);
468            println!("{}", benchmark::format_markdown(&result));
469        }
470        _ => {
471            if std::path::Path::new(action).exists() {
472                let result = benchmark::run_project_benchmark(action);
473                println!("{}", benchmark::format_terminal(&result));
474            } else {
475                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
476                eprintln!("       lean-ctx benchmark report [path]");
477                std::process::exit(1);
478            }
479        }
480    }
481}
482
483fn format_tokens_cli(tokens: u64) -> String {
484    if tokens >= 1_000_000 {
485        format!("{:.1}M", tokens as f64 / 1_000_000.0)
486    } else if tokens >= 1_000 {
487        format!("{:.1}K", tokens as f64 / 1_000.0)
488    } else {
489        format!("{tokens}")
490    }
491}
492
493pub fn cmd_stats(args: &[String]) {
494    match args.first().map(|s| s.as_str()) {
495        Some("reset-cep") => {
496            crate::core::stats::reset_cep();
497            println!("CEP stats reset. Shell hook data preserved.");
498        }
499        Some("json") => {
500            let store = crate::core::stats::load();
501            println!(
502                "{}",
503                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
504            );
505        }
506        _ => {
507            let store = crate::core::stats::load();
508            let input_saved = store
509                .total_input_tokens
510                .saturating_sub(store.total_output_tokens);
511            let pct = if store.total_input_tokens > 0 {
512                input_saved as f64 / store.total_input_tokens as f64 * 100.0
513            } else {
514                0.0
515            };
516            println!("Commands:    {}", store.total_commands);
517            println!("Input:       {} tokens", store.total_input_tokens);
518            println!("Output:      {} tokens", store.total_output_tokens);
519            println!("Saved:       {} tokens ({:.1}%)", input_saved, pct);
520            println!();
521            println!("CEP sessions:  {}", store.cep.sessions);
522            println!(
523                "CEP tokens:    {} → {}",
524                store.cep.total_tokens_original, store.cep.total_tokens_compressed
525            );
526            println!();
527            println!("Subcommands: stats reset-cep | stats json");
528        }
529    }
530}
531
532pub fn cmd_cache(args: &[String]) {
533    use crate::core::cli_cache;
534    match args.first().map(|s| s.as_str()) {
535        Some("clear") => {
536            let count = cli_cache::clear();
537            println!("Cleared {count} cached entries.");
538        }
539        Some("reset") => {
540            let project_flag = args.get(1).map(|s| s.as_str()) == Some("--project");
541            if project_flag {
542                let root =
543                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
544                match root {
545                    Some(root) => {
546                        let count = cli_cache::clear_project(&root);
547                        println!("Reset {count} cache entries for project: {root}");
548                    }
549                    None => {
550                        eprintln!("No active project root found. Start a session first.");
551                        std::process::exit(1);
552                    }
553                }
554            } else {
555                let count = cli_cache::clear();
556                println!("Reset all {count} cache entries.");
557            }
558        }
559        Some("stats") => {
560            let (hits, reads, entries) = cli_cache::stats();
561            let rate = if reads > 0 {
562                (hits as f64 / reads as f64 * 100.0).round() as u32
563            } else {
564                0
565            };
566            println!("CLI Cache Stats:");
567            println!("  Entries:   {entries}");
568            println!("  Reads:     {reads}");
569            println!("  Hits:      {hits}");
570            println!("  Hit Rate:  {rate}%");
571        }
572        Some("invalidate") => {
573            if args.len() < 2 {
574                eprintln!("Usage: lean-ctx cache invalidate <path>");
575                std::process::exit(1);
576            }
577            cli_cache::invalidate(&args[1]);
578            println!("Invalidated cache for {}", args[1]);
579        }
580        _ => {
581            let (hits, reads, entries) = cli_cache::stats();
582            let rate = if reads > 0 {
583                (hits as f64 / reads as f64 * 100.0).round() as u32
584            } else {
585                0
586            };
587            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
588            println!();
589            println!("Subcommands:");
590            println!("  cache stats       Show detailed stats");
591            println!("  cache clear       Clear all cached entries");
592            println!("  cache reset       Reset all cache (or --project for current project only)");
593            println!("  cache invalidate  Remove specific file from cache");
594        }
595    }
596}
597
598pub fn cmd_config(args: &[String]) {
599    let cfg = config::Config::load();
600
601    if args.is_empty() {
602        println!("{}", cfg.show());
603        return;
604    }
605
606    match args[0].as_str() {
607        "init" | "create" => {
608            let default = config::Config::default();
609            match default.save() {
610                Ok(()) => {
611                    let path = config::Config::path()
612                        .map(|p| p.to_string_lossy().to_string())
613                        .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
614                    println!("Created default config at {path}");
615                }
616                Err(e) => eprintln!("Error: {e}"),
617            }
618        }
619        "set" => {
620            if args.len() < 3 {
621                eprintln!("Usage: lean-ctx config set <key> <value>");
622                std::process::exit(1);
623            }
624            let mut cfg = cfg;
625            let key = &args[1];
626            let val = &args[2];
627            match key.as_str() {
628                "ultra_compact" => cfg.ultra_compact = val == "true",
629                "tee_on_error" | "tee_mode" => {
630                    cfg.tee_mode = match val.as_str() {
631                        "true" | "failures" => config::TeeMode::Failures,
632                        "always" => config::TeeMode::Always,
633                        "false" | "never" => config::TeeMode::Never,
634                        _ => {
635                            eprintln!("Valid tee_mode values: always, failures, never");
636                            std::process::exit(1);
637                        }
638                    };
639                }
640                "checkpoint_interval" => {
641                    cfg.checkpoint_interval = val.parse().unwrap_or(15);
642                }
643                "theme" => {
644                    if theme::from_preset(val).is_some() || val == "custom" {
645                        cfg.theme = val.to_string();
646                    } else {
647                        eprintln!(
648                            "Unknown theme '{val}'. Available: {}",
649                            theme::PRESET_NAMES.join(", ")
650                        );
651                        std::process::exit(1);
652                    }
653                }
654                "slow_command_threshold_ms" => {
655                    cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
656                }
657                "passthrough_urls" => {
658                    cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
659                }
660                "rules_scope" => match val.as_str() {
661                    "global" | "project" | "both" => {
662                        cfg.rules_scope = Some(val.to_string());
663                    }
664                    _ => {
665                        eprintln!("Valid rules_scope values: global, project, both");
666                        std::process::exit(1);
667                    }
668                },
669                _ => {
670                    eprintln!("Unknown config key: {key}");
671                    std::process::exit(1);
672                }
673            }
674            match cfg.save() {
675                Ok(()) => println!("Updated {key} = {val}"),
676                Err(e) => eprintln!("Error saving config: {e}"),
677            }
678        }
679        _ => {
680            eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
681            std::process::exit(1);
682        }
683    }
684}
685
686pub fn cmd_cheatsheet() {
687    let ver = env!("CARGO_PKG_VERSION");
688    let ver_pad = format!("v{ver}");
689    let header = format!(
690        "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
691\x1b[1;36m║\x1b[0m  \x1b[1;37mlean-ctx Workflow Cheat Sheet\x1b[0m                     \x1b[2m{ver_pad:>6}\x1b[0m  \x1b[1;36m║\x1b[0m
692\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m");
693    println!(
694        "{header}
695
696\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
697  ctx_session load               \x1b[2m# restore previous session\x1b[0m
698  ctx_overview task=\"...\"         \x1b[2m# task-aware file map\x1b[0m
699  ctx_graph action=build          \x1b[2m# index project (first time)\x1b[0m
700  ctx_knowledge action=recall     \x1b[2m# check stored project facts\x1b[0m
701
702\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
703  ctx_read mode=full    \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
704  ctx_read mode=map     \x1b[2m# context-only files (~93% saved)\x1b[0m
705  ctx_read mode=diff    \x1b[2m# after editing (~98% saved)\x1b[0m
706  ctx_read mode=sigs    \x1b[2m# API surface of large files (~95%)\x1b[0m
707  ctx_multi_read        \x1b[2m# read multiple files at once\x1b[0m
708  ctx_search            \x1b[2m# search with compressed results (~70%)\x1b[0m
709  ctx_shell             \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
710
711\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
712  ctx_session finding \"...\"       \x1b[2m# record what you discovered\x1b[0m
713  ctx_session decision \"...\"      \x1b[2m# record architectural choices\x1b[0m
714  ctx_knowledge action=remember   \x1b[2m# store permanent project facts\x1b[0m
715  ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
716  ctx_metrics                     \x1b[2m# see session statistics\x1b[0m
717
718\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
719  ctx_agent action=register       \x1b[2m# announce yourself\x1b[0m
720  ctx_agent action=list           \x1b[2m# see other active agents\x1b[0m
721  ctx_agent action=post           \x1b[2m# share findings\x1b[0m
722  ctx_agent action=read           \x1b[2m# check messages\x1b[0m
723
724\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
725  Will edit?  → \x1b[1mfull\x1b[0m (re-reads: 13 tokens)  → after edit: \x1b[1mdiff\x1b[0m
726  API only?   → \x1b[1msignatures\x1b[0m
727  Deps/exports? → \x1b[1mmap\x1b[0m
728  Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
729  Browsing?   → \x1b[1maggressive\x1b[0m (syntax stripped)
730
731\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
732  lean-ctx gain          \x1b[2m# visual savings dashboard\x1b[0m
733  lean-ctx gain --live   \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
734  lean-ctx dashboard     \x1b[2m# web dashboard with charts\x1b[0m
735  lean-ctx wrapped       \x1b[2m# weekly savings report\x1b[0m
736  lean-ctx discover      \x1b[2m# find uncompressed commands\x1b[0m
737  lean-ctx doctor        \x1b[2m# diagnose installation\x1b[0m
738  lean-ctx update        \x1b[2m# self-update to latest\x1b[0m
739
740\x1b[2m  Full guide: https://leanctx.com/docs/workflow\x1b[0m"
741    );
742}
743
744pub fn cmd_slow_log(args: &[String]) {
745    use crate::core::slow_log;
746
747    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
748    match action {
749        "list" | "ls" | "" => println!("{}", slow_log::list()),
750        "clear" | "purge" => println!("{}", slow_log::clear()),
751        _ => {
752            eprintln!("Usage: lean-ctx slow-log [list|clear]");
753            std::process::exit(1);
754        }
755    }
756}
757
758pub fn cmd_tee(args: &[String]) {
759    let tee_dir = match dirs::home_dir() {
760        Some(h) => h.join(".lean-ctx").join("tee"),
761        None => {
762            eprintln!("Cannot determine home directory");
763            std::process::exit(1);
764        }
765    };
766
767    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
768    match action {
769        "list" | "ls" => {
770            if !tee_dir.exists() {
771                println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
772                return;
773            }
774            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
775                .unwrap_or_else(|e| {
776                    eprintln!("Error: {e}");
777                    std::process::exit(1);
778                })
779                .filter_map(|e| e.ok())
780                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
781                .collect();
782            entries.sort_by_key(|e| e.file_name());
783
784            if entries.is_empty() {
785                println!("No tee logs found.");
786                return;
787            }
788
789            println!("Tee logs ({}):\n", entries.len());
790            for entry in &entries {
791                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
792                let name = entry.file_name();
793                let size_str = if size > 1024 {
794                    format!("{}K", size / 1024)
795                } else {
796                    format!("{}B", size)
797                };
798                println!("  {:<60} {}", name.to_string_lossy(), size_str);
799            }
800            println!("\nUse 'lean-ctx tee clear' to delete all logs.");
801        }
802        "clear" | "purge" => {
803            if !tee_dir.exists() {
804                println!("No tee logs to clear.");
805                return;
806            }
807            let mut count = 0u32;
808            if let Ok(entries) = std::fs::read_dir(&tee_dir) {
809                for entry in entries.flatten() {
810                    if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
811                        && std::fs::remove_file(entry.path()).is_ok()
812                    {
813                        count += 1;
814                    }
815                }
816            }
817            println!("Cleared {count} tee log(s) from {}", tee_dir.display());
818        }
819        "show" => {
820            let filename = args.get(1);
821            if filename.is_none() {
822                eprintln!("Usage: lean-ctx tee show <filename>");
823                std::process::exit(1);
824            }
825            let path = tee_dir.join(filename.unwrap());
826            match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
827                Ok(content) => print!("{content}"),
828                Err(e) => {
829                    eprintln!("Error reading {}: {e}", path.display());
830                    std::process::exit(1);
831                }
832            }
833        }
834        "last" => {
835            if !tee_dir.exists() {
836                println!("No tee logs found.");
837                return;
838            }
839            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
840                .ok()
841                .into_iter()
842                .flat_map(|d| d.filter_map(|e| e.ok()))
843                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
844                .collect();
845            entries.sort_by_key(|e| {
846                e.metadata()
847                    .and_then(|m| m.modified())
848                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
849            });
850            match entries.last() {
851                Some(entry) => {
852                    let path = entry.path();
853                    println!(
854                        "--- {} ---\n",
855                        path.file_name().unwrap_or_default().to_string_lossy()
856                    );
857                    match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
858                        Ok(content) => print!("{content}"),
859                        Err(e) => eprintln!("Error: {e}"),
860                    }
861                }
862                None => println!("No tee logs found."),
863            }
864        }
865        _ => {
866            eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
867            std::process::exit(1);
868        }
869    }
870}
871
872pub fn cmd_filter(args: &[String]) {
873    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
874    match action {
875        "list" | "ls" => match crate::core::filters::FilterEngine::load() {
876            Some(engine) => {
877                let rules = engine.list_rules();
878                println!("Loaded {} filter rule(s):\n", rules.len());
879                for rule in &rules {
880                    println!("{rule}");
881                }
882            }
883            None => {
884                println!("No custom filters found.");
885                println!("Create one: lean-ctx filter init");
886            }
887        },
888        "validate" => {
889            let path = args.get(1);
890            if path.is_none() {
891                eprintln!("Usage: lean-ctx filter validate <file.toml>");
892                std::process::exit(1);
893            }
894            match crate::core::filters::validate_filter_file(path.unwrap()) {
895                Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
896                Err(e) => {
897                    eprintln!("Validation failed: {e}");
898                    std::process::exit(1);
899                }
900            }
901        }
902        "init" => match crate::core::filters::create_example_filter() {
903            Ok(path) => {
904                println!("Created example filter: {path}");
905                println!("Edit it to add your custom compression rules.");
906            }
907            Err(e) => {
908                eprintln!("{e}");
909                std::process::exit(1);
910            }
911        },
912        _ => {
913            eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
914            std::process::exit(1);
915        }
916    }
917}
918
919fn quiet_enabled() -> bool {
920    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
921}
922
923macro_rules! qprintln {
924    ($($t:tt)*) => {
925        if !quiet_enabled() {
926            println!($($t)*);
927        }
928    };
929}
930
931pub fn cmd_init(args: &[String]) {
932    let global = args.iter().any(|a| a == "--global" || a == "-g");
933    let dry_run = args.iter().any(|a| a == "--dry-run");
934
935    let agents: Vec<&str> = args
936        .windows(2)
937        .filter(|w| w[0] == "--agent")
938        .map(|w| w[1].as_str())
939        .collect();
940
941    if !agents.is_empty() {
942        for agent_name in &agents {
943            crate::hooks::install_agent_hook(agent_name, global);
944            if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
945                eprintln!("MCP config for '{agent_name}' not updated: {e}");
946            }
947        }
948        if !global {
949            crate::hooks::install_project_rules();
950        }
951        qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
952        return;
953    }
954
955    let shell_name = std::env::var("SHELL").unwrap_or_default();
956    let is_zsh = shell_name.contains("zsh");
957    let is_fish = shell_name.contains("fish");
958    let is_powershell = cfg!(windows) && shell_name.is_empty();
959
960    let binary = std::env::current_exe()
961        .map(|p| p.to_string_lossy().to_string())
962        .unwrap_or_else(|_| "lean-ctx".to_string());
963
964    if dry_run {
965        let rc = if is_powershell {
966            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
967        } else if is_fish {
968            "~/.config/fish/config.fish".to_string()
969        } else if is_zsh {
970            "~/.zshrc".to_string()
971        } else {
972            "~/.bashrc".to_string()
973        };
974        qprintln!("\nlean-ctx init --dry-run\n");
975        qprintln!("  Would modify:  {rc}");
976        qprintln!("  Would backup:  {rc}.lean-ctx.bak");
977        qprintln!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
978        qprintln!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
979        qprintln!("                 curl wget php composer (24 commands + k)");
980        qprintln!("  Would create:  ~/.lean-ctx/");
981        qprintln!("  Binary:        {binary}");
982        qprintln!("\n  Safety: aliases auto-fallback to original command if lean-ctx is removed.");
983        qprintln!("\n  Run without --dry-run to apply.");
984        return;
985    }
986
987    if is_powershell {
988        init_powershell(&binary);
989    } else {
990        let bash_binary = to_bash_compatible_path(&binary);
991        if is_fish {
992            init_fish(&bash_binary);
993        } else {
994            init_posix(is_zsh, &bash_binary);
995        }
996    }
997
998    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
999    if let Some(dir) = lean_dir {
1000        if !dir.exists() {
1001            let _ = std::fs::create_dir_all(&dir);
1002            qprintln!("Created {}", dir.display());
1003        }
1004    }
1005
1006    let rc = if is_powershell {
1007        "$PROFILE"
1008    } else if is_fish {
1009        "config.fish"
1010    } else if is_zsh {
1011        ".zshrc"
1012    } else {
1013        ".bashrc"
1014    };
1015
1016    qprintln!("\nlean-ctx init complete (24 aliases installed)");
1017    qprintln!();
1018    qprintln!("  Disable temporarily:  lean-ctx-off");
1019    qprintln!("  Re-enable:            lean-ctx-on");
1020    qprintln!("  Check status:         lean-ctx-status");
1021    qprintln!("  Full uninstall:       lean-ctx uninstall");
1022    qprintln!("  Diagnose issues:      lean-ctx doctor");
1023    qprintln!("  Preview changes:      lean-ctx init --global --dry-run");
1024    qprintln!();
1025    if is_powershell {
1026        qprintln!("  Restart PowerShell or run: . {rc}");
1027    } else {
1028        qprintln!("  Restart your shell or run: source ~/{rc}");
1029    }
1030    qprintln!();
1031    qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1032    qprintln!("  Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1033    qprintln!("    crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1034    qprintln!("    pi, qwen, roo, sublime, trae, verdent, windsurf");
1035}
1036
1037pub fn cmd_init_quiet(args: &[String]) {
1038    std::env::set_var("LEAN_CTX_QUIET", "1");
1039    cmd_init(args);
1040    std::env::remove_var("LEAN_CTX_QUIET");
1041}
1042
1043pub fn load_shell_history_pub() -> Vec<String> {
1044    load_shell_history()
1045}
1046
1047fn load_shell_history() -> Vec<String> {
1048    let shell = std::env::var("SHELL").unwrap_or_default();
1049    let home = match dirs::home_dir() {
1050        Some(h) => h,
1051        None => return Vec::new(),
1052    };
1053
1054    let history_file = if shell.contains("zsh") {
1055        home.join(".zsh_history")
1056    } else if shell.contains("fish") {
1057        home.join(".local/share/fish/fish_history")
1058    } else if cfg!(windows) && shell.is_empty() {
1059        home.join("AppData")
1060            .join("Roaming")
1061            .join("Microsoft")
1062            .join("Windows")
1063            .join("PowerShell")
1064            .join("PSReadLine")
1065            .join("ConsoleHost_history.txt")
1066    } else {
1067        home.join(".bash_history")
1068    };
1069
1070    match std::fs::read_to_string(&history_file) {
1071        Ok(content) => content
1072            .lines()
1073            .filter_map(|l| {
1074                let trimmed = l.trim();
1075                if trimmed.starts_with(':') {
1076                    trimmed.split(';').nth(1).map(|s| s.to_string())
1077                } else {
1078                    Some(trimmed.to_string())
1079                }
1080            })
1081            .filter(|l| !l.is_empty())
1082            .collect(),
1083        Err(_) => Vec::new(),
1084    }
1085}
1086
1087fn print_savings(original: usize, sent: usize) {
1088    let saved = original.saturating_sub(sent);
1089    if original > 0 && saved > 0 {
1090        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1091        println!("[{saved} tok saved ({pct}%)]");
1092    }
1093}
1094
1095pub fn cmd_theme(args: &[String]) {
1096    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1097    let r = theme::rst();
1098    let b = theme::bold();
1099    let d = theme::dim();
1100
1101    match sub {
1102        "list" => {
1103            let cfg = config::Config::load();
1104            let active = cfg.theme.as_str();
1105            println!();
1106            println!("  {b}Available themes:{r}");
1107            println!("  {ln}", ln = "─".repeat(40));
1108            for name in theme::PRESET_NAMES {
1109                let marker = if *name == active { " ◀ active" } else { "" };
1110                let t = theme::from_preset(name).unwrap();
1111                let preview = format!(
1112                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1113                    p = t.primary.fg(),
1114                    s = t.secondary.fg(),
1115                    a = t.accent.fg(),
1116                    sc = t.success.fg(),
1117                    w = t.warning.fg(),
1118                );
1119                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1120            }
1121            if let Some(path) = theme::theme_file_path() {
1122                if path.exists() {
1123                    let custom = theme::load_theme("_custom_");
1124                    let preview = format!(
1125                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1126                        p = custom.primary.fg(),
1127                        s = custom.secondary.fg(),
1128                        a = custom.accent.fg(),
1129                        sc = custom.success.fg(),
1130                        w = custom.warning.fg(),
1131                    );
1132                    let marker = if active == "custom" {
1133                        " ◀ active"
1134                    } else {
1135                        ""
1136                    };
1137                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1138                }
1139            }
1140            println!();
1141            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1142            println!();
1143        }
1144        "set" => {
1145            if args.len() < 2 {
1146                eprintln!("Usage: lean-ctx theme set <name>");
1147                std::process::exit(1);
1148            }
1149            let name = &args[1];
1150            if theme::from_preset(name).is_none() && name != "custom" {
1151                eprintln!(
1152                    "Unknown theme '{name}'. Available: {}",
1153                    theme::PRESET_NAMES.join(", ")
1154                );
1155                std::process::exit(1);
1156            }
1157            let mut cfg = config::Config::load();
1158            cfg.theme = name.to_string();
1159            match cfg.save() {
1160                Ok(()) => {
1161                    let t = theme::load_theme(name);
1162                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1163                    let preview = t.gradient_bar(0.75, 30);
1164                    println!("  {preview}");
1165                }
1166                Err(e) => eprintln!("Error: {e}"),
1167            }
1168        }
1169        "export" => {
1170            let cfg = config::Config::load();
1171            let t = theme::load_theme(&cfg.theme);
1172            println!("{}", t.to_toml());
1173        }
1174        "import" => {
1175            if args.len() < 2 {
1176                eprintln!("Usage: lean-ctx theme import <path>");
1177                std::process::exit(1);
1178            }
1179            let path = std::path::Path::new(&args[1]);
1180            if !path.exists() {
1181                eprintln!("File not found: {}", args[1]);
1182                std::process::exit(1);
1183            }
1184            match std::fs::read_to_string(path) {
1185                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1186                    Ok(imported) => match theme::save_theme(&imported) {
1187                        Ok(()) => {
1188                            let mut cfg = config::Config::load();
1189                            cfg.theme = "custom".to_string();
1190                            let _ = cfg.save();
1191                            println!(
1192                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1193                                sc = imported.success.fg(),
1194                                name = imported.name,
1195                            );
1196                            println!("  Config updated: theme = custom");
1197                        }
1198                        Err(e) => eprintln!("Error saving theme: {e}"),
1199                    },
1200                    Err(e) => eprintln!("Invalid theme file: {e}"),
1201                },
1202                Err(e) => eprintln!("Error reading file: {e}"),
1203            }
1204        }
1205        "preview" => {
1206            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1207            let t = match theme::from_preset(name) {
1208                Some(t) => t,
1209                None => {
1210                    eprintln!("Unknown theme: {name}");
1211                    std::process::exit(1);
1212                }
1213            };
1214            println!();
1215            println!(
1216                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1217                icon = t.header_icon(),
1218                title = t.brand_title(),
1219            );
1220            println!("  {ln}", ln = t.border_line(50));
1221            println!();
1222            println!(
1223                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1224                sc = t.success.fg(),
1225                sec = t.secondary.fg(),
1226                wrn = t.warning.fg(),
1227                acc = t.accent.fg(),
1228            );
1229            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1230            println!();
1231            println!(
1232                "  {b}{txt}Gradient Bar{r}      {bar}",
1233                txt = t.text.fg(),
1234                bar = t.gradient_bar(0.85, 30),
1235            );
1236            println!(
1237                "  {b}{txt}Sparkline{r}         {spark}",
1238                txt = t.text.fg(),
1239                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1240            );
1241            println!();
1242            println!("  {top}", top = t.box_top(50));
1243            println!(
1244                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1245                side = t.box_side(),
1246                side_r = t.box_side(),
1247                txt = t.text.fg(),
1248            );
1249            println!("  {bot}", bot = t.box_bottom(50));
1250            println!();
1251        }
1252        _ => {
1253            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1254            std::process::exit(1);
1255        }
1256    }
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261    use super::*;
1262    use tempfile;
1263
1264    #[test]
1265    fn test_remove_lean_ctx_block_posix() {
1266        let input = r#"# existing config
1267export PATH="$HOME/bin:$PATH"
1268
1269# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1270if [ -z "$LEAN_CTX_ACTIVE" ]; then
1271alias git='lean-ctx -c git'
1272alias npm='lean-ctx -c npm'
1273fi
1274
1275# other stuff
1276export EDITOR=vim
1277"#;
1278        let result = remove_lean_ctx_block(input);
1279        assert!(!result.contains("lean-ctx"), "block should be removed");
1280        assert!(result.contains("export PATH"), "other content preserved");
1281        assert!(
1282            result.contains("export EDITOR"),
1283            "trailing content preserved"
1284        );
1285    }
1286
1287    #[test]
1288    fn test_remove_lean_ctx_block_fish() {
1289        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";
1290        let result = remove_lean_ctx_block(input);
1291        assert!(!result.contains("lean-ctx"), "block should be removed");
1292        assert!(result.contains("set -x FOO"), "other content preserved");
1293        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1294    }
1295
1296    #[test]
1297    fn test_remove_lean_ctx_block_ps() {
1298        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";
1299        let result = remove_lean_ctx_block_ps(input);
1300        assert!(
1301            !result.contains("lean-ctx shell hook"),
1302            "block should be removed"
1303        );
1304        assert!(result.contains("$env:FOO"), "other content preserved");
1305        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1306    }
1307
1308    #[test]
1309    fn test_remove_lean_ctx_block_ps_nested() {
1310        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";
1311        let result = remove_lean_ctx_block_ps(input);
1312        assert!(
1313            !result.contains("lean-ctx shell hook"),
1314            "block should be removed"
1315        );
1316        assert!(!result.contains("_lc"), "function should be removed");
1317        assert!(result.contains("$env:FOO"), "other content preserved");
1318        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1319    }
1320
1321    #[test]
1322    fn test_remove_block_no_lean_ctx() {
1323        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1324        let result = remove_lean_ctx_block(input);
1325        assert!(result.contains("export PATH"), "content unchanged");
1326    }
1327
1328    #[test]
1329    fn test_bash_hook_contains_pipe_guard() {
1330        let binary = "/usr/local/bin/lean-ctx";
1331        let hook = format!(
1332            r#"_lc() {{
1333    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1334        command "$@"
1335        return
1336    fi
1337    '{binary}' -t "$@"
1338}}"#
1339        );
1340        assert!(
1341            hook.contains("! -t 1"),
1342            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1343        );
1344        assert!(
1345            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1346            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1347        );
1348    }
1349
1350    #[test]
1351    fn test_lc_uses_track_mode_by_default() {
1352        let binary = "/usr/local/bin/lean-ctx";
1353        let alias_list = crate::rewrite_registry::shell_alias_list();
1354        let aliases = format!(
1355            r#"_lc() {{
1356    '{binary}' -t "$@"
1357}}
1358_lc_compress() {{
1359    '{binary}' -c "$@"
1360}}"#
1361        );
1362        assert!(
1363            aliases.contains("-t \"$@\""),
1364            "_lc must use -t (track mode) by default"
1365        );
1366        assert!(
1367            aliases.contains("-c \"$@\""),
1368            "_lc_compress must use -c (compress mode)"
1369        );
1370        let _ = alias_list;
1371    }
1372
1373    #[test]
1374    fn test_posix_shell_has_lean_ctx_mode() {
1375        let alias_list = crate::rewrite_registry::shell_alias_list();
1376        let aliases = r#"
1377lean-ctx-mode() {{
1378    case "${{1:-}}" in
1379        compress) echo compress ;;
1380        track) echo track ;;
1381        off) echo off ;;
1382    esac
1383}}
1384"#
1385        .to_string();
1386        assert!(
1387            aliases.contains("lean-ctx-mode()"),
1388            "lean-ctx-mode function must exist"
1389        );
1390        assert!(
1391            aliases.contains("compress"),
1392            "compress mode must be available"
1393        );
1394        assert!(aliases.contains("track"), "track mode must be available");
1395        let _ = alias_list;
1396    }
1397
1398    #[test]
1399    fn test_fish_hook_contains_pipe_guard() {
1400        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";
1401        assert!(
1402            hook.contains("isatty stdout"),
1403            "fish hook must contain pipe guard (isatty stdout)"
1404        );
1405    }
1406
1407    #[test]
1408    fn test_powershell_hook_contains_pipe_guard() {
1409        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1410        assert!(
1411            hook.contains("IsOutputRedirected"),
1412            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1413        );
1414    }
1415
1416    #[test]
1417    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1418        let input = r#"# existing config
1419export PATH="$HOME/bin:$PATH"
1420
1421# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1422_lean_ctx_cmds=(git npm pnpm)
1423
1424lean-ctx-on() {
1425    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1426        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1427    done
1428    export LEAN_CTX_ENABLED=1
1429    [ -t 1 ] && echo "lean-ctx: ON"
1430}
1431
1432lean-ctx-off() {
1433    unset LEAN_CTX_ENABLED
1434    [ -t 1 ] && echo "lean-ctx: OFF"
1435}
1436
1437if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1438    lean-ctx-on
1439fi
1440# lean-ctx shell hook — end
1441
1442# other stuff
1443export EDITOR=vim
1444"#;
1445        let result = remove_lean_ctx_block(input);
1446        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1447        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1448        assert!(result.contains("export PATH"), "other content preserved");
1449        assert!(
1450            result.contains("export EDITOR"),
1451            "trailing content preserved"
1452        );
1453    }
1454
1455    #[test]
1456    fn env_sh_for_containers_includes_self_heal() {
1457        let _g = crate::core::data_dir::test_env_lock();
1458        let tmp = tempfile::tempdir().expect("tempdir");
1459        let data_dir = tmp.path().join("data");
1460        std::fs::create_dir_all(&data_dir).expect("mkdir data");
1461        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1462
1463        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1464        let env_sh = data_dir.join("env.sh");
1465        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1466        assert!(content.contains("lean-ctx docker self-heal"));
1467        assert!(content.contains("claude mcp list"));
1468        assert!(content.contains("lean-ctx init --agent claude"));
1469
1470        std::env::remove_var("LEAN_CTX_DATA_DIR");
1471    }
1472}