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 eval_shell = args
956        .iter()
957        .find(|a| matches!(a.as_str(), "bash" | "zsh" | "fish" | "powershell" | "pwsh"));
958    if let Some(shell) = eval_shell {
959        if !global {
960            shell_init::print_hook_stdout(shell);
961            return;
962        }
963    }
964
965    let shell_name = std::env::var("SHELL").unwrap_or_default();
966    let is_zsh = shell_name.contains("zsh");
967    let is_fish = shell_name.contains("fish");
968    let is_powershell = cfg!(windows) && shell_name.is_empty();
969
970    let binary = crate::core::portable_binary::resolve_portable_binary();
971
972    if dry_run {
973        let rc = if is_powershell {
974            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
975        } else if is_fish {
976            "~/.config/fish/config.fish".to_string()
977        } else if is_zsh {
978            "~/.zshrc".to_string()
979        } else {
980            "~/.bashrc".to_string()
981        };
982        qprintln!("\nlean-ctx init --dry-run\n");
983        qprintln!("  Would modify:  {rc}");
984        qprintln!("  Would backup:  {rc}.lean-ctx.bak");
985        qprintln!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
986        qprintln!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
987        qprintln!("                 curl wget php composer (24 commands + k)");
988        qprintln!("  Would create:  ~/.lean-ctx/");
989        qprintln!("  Binary:        {binary}");
990        qprintln!("\n  Safety: aliases auto-fallback to original command if lean-ctx is removed.");
991        qprintln!("\n  Run without --dry-run to apply.");
992        return;
993    }
994
995    if is_powershell {
996        init_powershell(&binary);
997    } else {
998        let bash_binary = to_bash_compatible_path(&binary);
999        if is_fish {
1000            init_fish(&bash_binary);
1001        } else {
1002            init_posix(is_zsh, &bash_binary);
1003        }
1004    }
1005
1006    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
1007    if let Some(dir) = lean_dir {
1008        if !dir.exists() {
1009            let _ = std::fs::create_dir_all(&dir);
1010            qprintln!("Created {}", dir.display());
1011        }
1012    }
1013
1014    let rc = if is_powershell {
1015        "$PROFILE"
1016    } else if is_fish {
1017        "config.fish"
1018    } else if is_zsh {
1019        ".zshrc"
1020    } else {
1021        ".bashrc"
1022    };
1023
1024    qprintln!("\nlean-ctx init complete (24 aliases installed)");
1025    qprintln!();
1026    qprintln!("  Disable temporarily:  lean-ctx-off");
1027    qprintln!("  Re-enable:            lean-ctx-on");
1028    qprintln!("  Check status:         lean-ctx-status");
1029    qprintln!("  Full uninstall:       lean-ctx uninstall");
1030    qprintln!("  Diagnose issues:      lean-ctx doctor");
1031    qprintln!("  Preview changes:      lean-ctx init --global --dry-run");
1032    qprintln!();
1033    if is_powershell {
1034        qprintln!("  Restart PowerShell or run: . {rc}");
1035    } else {
1036        qprintln!("  Restart your shell or run: source ~/{rc}");
1037    }
1038    qprintln!();
1039    qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1040    qprintln!("  Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1041    qprintln!("    crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1042    qprintln!("    pi, qwen, roo, sublime, trae, verdent, windsurf");
1043}
1044
1045pub fn cmd_init_quiet(args: &[String]) {
1046    std::env::set_var("LEAN_CTX_QUIET", "1");
1047    cmd_init(args);
1048    std::env::remove_var("LEAN_CTX_QUIET");
1049}
1050
1051pub fn load_shell_history_pub() -> Vec<String> {
1052    load_shell_history()
1053}
1054
1055fn load_shell_history() -> Vec<String> {
1056    let shell = std::env::var("SHELL").unwrap_or_default();
1057    let home = match dirs::home_dir() {
1058        Some(h) => h,
1059        None => return Vec::new(),
1060    };
1061
1062    let history_file = if shell.contains("zsh") {
1063        home.join(".zsh_history")
1064    } else if shell.contains("fish") {
1065        home.join(".local/share/fish/fish_history")
1066    } else if cfg!(windows) && shell.is_empty() {
1067        home.join("AppData")
1068            .join("Roaming")
1069            .join("Microsoft")
1070            .join("Windows")
1071            .join("PowerShell")
1072            .join("PSReadLine")
1073            .join("ConsoleHost_history.txt")
1074    } else {
1075        home.join(".bash_history")
1076    };
1077
1078    match std::fs::read_to_string(&history_file) {
1079        Ok(content) => content
1080            .lines()
1081            .filter_map(|l| {
1082                let trimmed = l.trim();
1083                if trimmed.starts_with(':') {
1084                    trimmed.split(';').nth(1).map(|s| s.to_string())
1085                } else {
1086                    Some(trimmed.to_string())
1087                }
1088            })
1089            .filter(|l| !l.is_empty())
1090            .collect(),
1091        Err(_) => Vec::new(),
1092    }
1093}
1094
1095fn print_savings(original: usize, sent: usize) {
1096    let saved = original.saturating_sub(sent);
1097    if original > 0 && saved > 0 {
1098        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1099        println!("[{saved} tok saved ({pct}%)]");
1100    }
1101}
1102
1103pub fn cmd_theme(args: &[String]) {
1104    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1105    let r = theme::rst();
1106    let b = theme::bold();
1107    let d = theme::dim();
1108
1109    match sub {
1110        "list" => {
1111            let cfg = config::Config::load();
1112            let active = cfg.theme.as_str();
1113            println!();
1114            println!("  {b}Available themes:{r}");
1115            println!("  {ln}", ln = "─".repeat(40));
1116            for name in theme::PRESET_NAMES {
1117                let marker = if *name == active { " ◀ active" } else { "" };
1118                let t = theme::from_preset(name).unwrap();
1119                let preview = format!(
1120                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1121                    p = t.primary.fg(),
1122                    s = t.secondary.fg(),
1123                    a = t.accent.fg(),
1124                    sc = t.success.fg(),
1125                    w = t.warning.fg(),
1126                );
1127                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1128            }
1129            if let Some(path) = theme::theme_file_path() {
1130                if path.exists() {
1131                    let custom = theme::load_theme("_custom_");
1132                    let preview = format!(
1133                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1134                        p = custom.primary.fg(),
1135                        s = custom.secondary.fg(),
1136                        a = custom.accent.fg(),
1137                        sc = custom.success.fg(),
1138                        w = custom.warning.fg(),
1139                    );
1140                    let marker = if active == "custom" {
1141                        " ◀ active"
1142                    } else {
1143                        ""
1144                    };
1145                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1146                }
1147            }
1148            println!();
1149            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1150            println!();
1151        }
1152        "set" => {
1153            if args.len() < 2 {
1154                eprintln!("Usage: lean-ctx theme set <name>");
1155                std::process::exit(1);
1156            }
1157            let name = &args[1];
1158            if theme::from_preset(name).is_none() && name != "custom" {
1159                eprintln!(
1160                    "Unknown theme '{name}'. Available: {}",
1161                    theme::PRESET_NAMES.join(", ")
1162                );
1163                std::process::exit(1);
1164            }
1165            let mut cfg = config::Config::load();
1166            cfg.theme = name.to_string();
1167            match cfg.save() {
1168                Ok(()) => {
1169                    let t = theme::load_theme(name);
1170                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1171                    let preview = t.gradient_bar(0.75, 30);
1172                    println!("  {preview}");
1173                }
1174                Err(e) => eprintln!("Error: {e}"),
1175            }
1176        }
1177        "export" => {
1178            let cfg = config::Config::load();
1179            let t = theme::load_theme(&cfg.theme);
1180            println!("{}", t.to_toml());
1181        }
1182        "import" => {
1183            if args.len() < 2 {
1184                eprintln!("Usage: lean-ctx theme import <path>");
1185                std::process::exit(1);
1186            }
1187            let path = std::path::Path::new(&args[1]);
1188            if !path.exists() {
1189                eprintln!("File not found: {}", args[1]);
1190                std::process::exit(1);
1191            }
1192            match std::fs::read_to_string(path) {
1193                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1194                    Ok(imported) => match theme::save_theme(&imported) {
1195                        Ok(()) => {
1196                            let mut cfg = config::Config::load();
1197                            cfg.theme = "custom".to_string();
1198                            let _ = cfg.save();
1199                            println!(
1200                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1201                                sc = imported.success.fg(),
1202                                name = imported.name,
1203                            );
1204                            println!("  Config updated: theme = custom");
1205                        }
1206                        Err(e) => eprintln!("Error saving theme: {e}"),
1207                    },
1208                    Err(e) => eprintln!("Invalid theme file: {e}"),
1209                },
1210                Err(e) => eprintln!("Error reading file: {e}"),
1211            }
1212        }
1213        "preview" => {
1214            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1215            let t = match theme::from_preset(name) {
1216                Some(t) => t,
1217                None => {
1218                    eprintln!("Unknown theme: {name}");
1219                    std::process::exit(1);
1220                }
1221            };
1222            println!();
1223            println!(
1224                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1225                icon = t.header_icon(),
1226                title = t.brand_title(),
1227            );
1228            println!("  {ln}", ln = t.border_line(50));
1229            println!();
1230            println!(
1231                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1232                sc = t.success.fg(),
1233                sec = t.secondary.fg(),
1234                wrn = t.warning.fg(),
1235                acc = t.accent.fg(),
1236            );
1237            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1238            println!();
1239            println!(
1240                "  {b}{txt}Gradient Bar{r}      {bar}",
1241                txt = t.text.fg(),
1242                bar = t.gradient_bar(0.85, 30),
1243            );
1244            println!(
1245                "  {b}{txt}Sparkline{r}         {spark}",
1246                txt = t.text.fg(),
1247                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1248            );
1249            println!();
1250            println!("  {top}", top = t.box_top(50));
1251            println!(
1252                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1253                side = t.box_side(),
1254                side_r = t.box_side(),
1255                txt = t.text.fg(),
1256            );
1257            println!("  {bot}", bot = t.box_bottom(50));
1258            println!();
1259        }
1260        _ => {
1261            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1262            std::process::exit(1);
1263        }
1264    }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269    use super::*;
1270    use tempfile;
1271
1272    #[test]
1273    fn test_remove_lean_ctx_block_posix() {
1274        let input = r#"# existing config
1275export PATH="$HOME/bin:$PATH"
1276
1277# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1278if [ -z "$LEAN_CTX_ACTIVE" ]; then
1279alias git='lean-ctx -c git'
1280alias npm='lean-ctx -c npm'
1281fi
1282
1283# other stuff
1284export EDITOR=vim
1285"#;
1286        let result = remove_lean_ctx_block(input);
1287        assert!(!result.contains("lean-ctx"), "block should be removed");
1288        assert!(result.contains("export PATH"), "other content preserved");
1289        assert!(
1290            result.contains("export EDITOR"),
1291            "trailing content preserved"
1292        );
1293    }
1294
1295    #[test]
1296    fn test_remove_lean_ctx_block_fish() {
1297        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";
1298        let result = remove_lean_ctx_block(input);
1299        assert!(!result.contains("lean-ctx"), "block should be removed");
1300        assert!(result.contains("set -x FOO"), "other content preserved");
1301        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1302    }
1303
1304    #[test]
1305    fn test_remove_lean_ctx_block_ps() {
1306        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";
1307        let result = remove_lean_ctx_block_ps(input);
1308        assert!(
1309            !result.contains("lean-ctx shell hook"),
1310            "block should be removed"
1311        );
1312        assert!(result.contains("$env:FOO"), "other content preserved");
1313        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1314    }
1315
1316    #[test]
1317    fn test_remove_lean_ctx_block_ps_nested() {
1318        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";
1319        let result = remove_lean_ctx_block_ps(input);
1320        assert!(
1321            !result.contains("lean-ctx shell hook"),
1322            "block should be removed"
1323        );
1324        assert!(!result.contains("_lc"), "function should be removed");
1325        assert!(result.contains("$env:FOO"), "other content preserved");
1326        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1327    }
1328
1329    #[test]
1330    fn test_remove_block_no_lean_ctx() {
1331        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1332        let result = remove_lean_ctx_block(input);
1333        assert!(result.contains("export PATH"), "content unchanged");
1334    }
1335
1336    #[test]
1337    fn test_bash_hook_contains_pipe_guard() {
1338        let binary = "/usr/local/bin/lean-ctx";
1339        let hook = format!(
1340            r#"_lc() {{
1341    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1342        command "$@"
1343        return
1344    fi
1345    '{binary}' -t "$@"
1346}}"#
1347        );
1348        assert!(
1349            hook.contains("! -t 1"),
1350            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1351        );
1352        assert!(
1353            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1354            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1355        );
1356    }
1357
1358    #[test]
1359    fn test_lc_uses_track_mode_by_default() {
1360        let binary = "/usr/local/bin/lean-ctx";
1361        let alias_list = crate::rewrite_registry::shell_alias_list();
1362        let aliases = format!(
1363            r#"_lc() {{
1364    '{binary}' -t "$@"
1365}}
1366_lc_compress() {{
1367    '{binary}' -c "$@"
1368}}"#
1369        );
1370        assert!(
1371            aliases.contains("-t \"$@\""),
1372            "_lc must use -t (track mode) by default"
1373        );
1374        assert!(
1375            aliases.contains("-c \"$@\""),
1376            "_lc_compress must use -c (compress mode)"
1377        );
1378        let _ = alias_list;
1379    }
1380
1381    #[test]
1382    fn test_posix_shell_has_lean_ctx_mode() {
1383        let alias_list = crate::rewrite_registry::shell_alias_list();
1384        let aliases = r#"
1385lean-ctx-mode() {{
1386    case "${{1:-}}" in
1387        compress) echo compress ;;
1388        track) echo track ;;
1389        off) echo off ;;
1390    esac
1391}}
1392"#
1393        .to_string();
1394        assert!(
1395            aliases.contains("lean-ctx-mode()"),
1396            "lean-ctx-mode function must exist"
1397        );
1398        assert!(
1399            aliases.contains("compress"),
1400            "compress mode must be available"
1401        );
1402        assert!(aliases.contains("track"), "track mode must be available");
1403        let _ = alias_list;
1404    }
1405
1406    #[test]
1407    fn test_fish_hook_contains_pipe_guard() {
1408        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";
1409        assert!(
1410            hook.contains("isatty stdout"),
1411            "fish hook must contain pipe guard (isatty stdout)"
1412        );
1413    }
1414
1415    #[test]
1416    fn test_powershell_hook_contains_pipe_guard() {
1417        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1418        assert!(
1419            hook.contains("IsOutputRedirected"),
1420            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1426        let input = r#"# existing config
1427export PATH="$HOME/bin:$PATH"
1428
1429# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1430_lean_ctx_cmds=(git npm pnpm)
1431
1432lean-ctx-on() {
1433    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1434        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1435    done
1436    export LEAN_CTX_ENABLED=1
1437    [ -t 1 ] && echo "lean-ctx: ON"
1438}
1439
1440lean-ctx-off() {
1441    unset LEAN_CTX_ENABLED
1442    [ -t 1 ] && echo "lean-ctx: OFF"
1443}
1444
1445if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1446    lean-ctx-on
1447fi
1448# lean-ctx shell hook — end
1449
1450# other stuff
1451export EDITOR=vim
1452"#;
1453        let result = remove_lean_ctx_block(input);
1454        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1455        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1456        assert!(result.contains("export PATH"), "other content preserved");
1457        assert!(
1458            result.contains("export EDITOR"),
1459            "trailing content preserved"
1460        );
1461    }
1462
1463    #[test]
1464    fn env_sh_for_containers_includes_self_heal() {
1465        let _g = crate::core::data_dir::test_env_lock();
1466        let tmp = tempfile::tempdir().expect("tempdir");
1467        let data_dir = tmp.path().join("data");
1468        std::fs::create_dir_all(&data_dir).expect("mkdir data");
1469        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1470
1471        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1472        let env_sh = data_dir.join("env.sh");
1473        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1474        assert!(content.contains("lean-ctx docker self-heal"));
1475        assert!(content.contains("claude mcp list"));
1476        assert!(content.contains("lean-ctx init --agent claude"));
1477
1478        std::env::remove_var("LEAN_CTX_DATA_DIR");
1479    }
1480}