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_terse(args: &[String]) {
745    use crate::core::config::{Config, TerseAgent};
746
747    let action = args.first().map(|s| s.as_str());
748    match action {
749        Some("off" | "lite" | "full" | "ultra") => {
750            let level = action.unwrap();
751            let mut cfg = Config::load();
752            cfg.terse_agent = match level {
753                "lite" => TerseAgent::Lite,
754                "full" => TerseAgent::Full,
755                "ultra" => TerseAgent::Ultra,
756                _ => TerseAgent::Off,
757            };
758            if let Err(e) = cfg.save() {
759                eprintln!("Error saving config: {e}");
760                std::process::exit(1);
761            }
762            let desc = match level {
763                "lite" => "concise responses, bullet points over paragraphs",
764                "full" => "maximum density, diff-only code, 1-sentence explanations",
765                "ultra" => "expert pair-programmer mode, minimal narration",
766                _ => "normal verbose output",
767            };
768            println!("Terse agent mode: {level} ({desc})");
769            println!("Restart your agent/IDE for changes to take effect.");
770        }
771        _ => {
772            let cfg = Config::load();
773            let effective = TerseAgent::effective(&cfg.terse_agent);
774            let name = match &effective {
775                TerseAgent::Off => "off",
776                TerseAgent::Lite => "lite",
777                TerseAgent::Full => "full",
778                TerseAgent::Ultra => "ultra",
779            };
780            println!("Terse agent mode: {name}");
781            println!();
782            println!("Usage: lean-ctx terse <off|lite|full|ultra>");
783            println!("  off   — Normal verbose output (default)");
784            println!("  lite  — Concise: bullet points, skip narration");
785            println!("  full  — Dense: diff-only, 1-sentence max");
786            println!("  ultra — Expert: minimal narration, code speaks");
787            println!();
788            println!("Override per session: LEAN_CTX_TERSE_AGENT=full");
789            println!("Override per project: terse_agent = \"full\" in .lean-ctx.toml");
790        }
791    }
792}
793
794pub fn cmd_slow_log(args: &[String]) {
795    use crate::core::slow_log;
796
797    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
798    match action {
799        "list" | "ls" | "" => println!("{}", slow_log::list()),
800        "clear" | "purge" => println!("{}", slow_log::clear()),
801        _ => {
802            eprintln!("Usage: lean-ctx slow-log [list|clear]");
803            std::process::exit(1);
804        }
805    }
806}
807
808pub fn cmd_tee(args: &[String]) {
809    let tee_dir = match dirs::home_dir() {
810        Some(h) => h.join(".lean-ctx").join("tee"),
811        None => {
812            eprintln!("Cannot determine home directory");
813            std::process::exit(1);
814        }
815    };
816
817    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
818    match action {
819        "list" | "ls" => {
820            if !tee_dir.exists() {
821                println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
822                return;
823            }
824            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
825                .unwrap_or_else(|e| {
826                    eprintln!("Error: {e}");
827                    std::process::exit(1);
828                })
829                .filter_map(|e| e.ok())
830                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
831                .collect();
832            entries.sort_by_key(|e| e.file_name());
833
834            if entries.is_empty() {
835                println!("No tee logs found.");
836                return;
837            }
838
839            println!("Tee logs ({}):\n", entries.len());
840            for entry in &entries {
841                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
842                let name = entry.file_name();
843                let size_str = if size > 1024 {
844                    format!("{}K", size / 1024)
845                } else {
846                    format!("{}B", size)
847                };
848                println!("  {:<60} {}", name.to_string_lossy(), size_str);
849            }
850            println!("\nUse 'lean-ctx tee clear' to delete all logs.");
851        }
852        "clear" | "purge" => {
853            if !tee_dir.exists() {
854                println!("No tee logs to clear.");
855                return;
856            }
857            let mut count = 0u32;
858            if let Ok(entries) = std::fs::read_dir(&tee_dir) {
859                for entry in entries.flatten() {
860                    if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
861                        && std::fs::remove_file(entry.path()).is_ok()
862                    {
863                        count += 1;
864                    }
865                }
866            }
867            println!("Cleared {count} tee log(s) from {}", tee_dir.display());
868        }
869        "show" => {
870            let filename = args.get(1);
871            if filename.is_none() {
872                eprintln!("Usage: lean-ctx tee show <filename>");
873                std::process::exit(1);
874            }
875            let path = tee_dir.join(filename.unwrap());
876            match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
877                Ok(content) => print!("{content}"),
878                Err(e) => {
879                    eprintln!("Error reading {}: {e}", path.display());
880                    std::process::exit(1);
881                }
882            }
883        }
884        "last" => {
885            if !tee_dir.exists() {
886                println!("No tee logs found.");
887                return;
888            }
889            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
890                .ok()
891                .into_iter()
892                .flat_map(|d| d.filter_map(|e| e.ok()))
893                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
894                .collect();
895            entries.sort_by_key(|e| {
896                e.metadata()
897                    .and_then(|m| m.modified())
898                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
899            });
900            match entries.last() {
901                Some(entry) => {
902                    let path = entry.path();
903                    println!(
904                        "--- {} ---\n",
905                        path.file_name().unwrap_or_default().to_string_lossy()
906                    );
907                    match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
908                        Ok(content) => print!("{content}"),
909                        Err(e) => eprintln!("Error: {e}"),
910                    }
911                }
912                None => println!("No tee logs found."),
913            }
914        }
915        _ => {
916            eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
917            std::process::exit(1);
918        }
919    }
920}
921
922pub fn cmd_filter(args: &[String]) {
923    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
924    match action {
925        "list" | "ls" => match crate::core::filters::FilterEngine::load() {
926            Some(engine) => {
927                let rules = engine.list_rules();
928                println!("Loaded {} filter rule(s):\n", rules.len());
929                for rule in &rules {
930                    println!("{rule}");
931                }
932            }
933            None => {
934                println!("No custom filters found.");
935                println!("Create one: lean-ctx filter init");
936            }
937        },
938        "validate" => {
939            let path = args.get(1);
940            if path.is_none() {
941                eprintln!("Usage: lean-ctx filter validate <file.toml>");
942                std::process::exit(1);
943            }
944            match crate::core::filters::validate_filter_file(path.unwrap()) {
945                Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
946                Err(e) => {
947                    eprintln!("Validation failed: {e}");
948                    std::process::exit(1);
949                }
950            }
951        }
952        "init" => match crate::core::filters::create_example_filter() {
953            Ok(path) => {
954                println!("Created example filter: {path}");
955                println!("Edit it to add your custom compression rules.");
956            }
957            Err(e) => {
958                eprintln!("{e}");
959                std::process::exit(1);
960            }
961        },
962        _ => {
963            eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
964            std::process::exit(1);
965        }
966    }
967}
968
969fn quiet_enabled() -> bool {
970    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
971}
972
973macro_rules! qprintln {
974    ($($t:tt)*) => {
975        if !quiet_enabled() {
976            println!($($t)*);
977        }
978    };
979}
980
981pub fn cmd_init(args: &[String]) {
982    let global = args.iter().any(|a| a == "--global" || a == "-g");
983    let dry_run = args.iter().any(|a| a == "--dry-run");
984
985    let agents: Vec<&str> = args
986        .windows(2)
987        .filter(|w| w[0] == "--agent")
988        .map(|w| w[1].as_str())
989        .collect();
990
991    if !agents.is_empty() {
992        for agent_name in &agents {
993            crate::hooks::install_agent_hook(agent_name, global);
994            if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
995                eprintln!("MCP config for '{agent_name}' not updated: {e}");
996            }
997        }
998        if !global {
999            crate::hooks::install_project_rules();
1000        }
1001        qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
1002        return;
1003    }
1004
1005    let eval_shell = args
1006        .iter()
1007        .find(|a| matches!(a.as_str(), "bash" | "zsh" | "fish" | "powershell" | "pwsh"));
1008    if let Some(shell) = eval_shell {
1009        if !global {
1010            shell_init::print_hook_stdout(shell);
1011            return;
1012        }
1013    }
1014
1015    let shell_name = std::env::var("SHELL").unwrap_or_default();
1016    let is_zsh = shell_name.contains("zsh");
1017    let is_fish = shell_name.contains("fish");
1018    let is_powershell = cfg!(windows) && shell_name.is_empty();
1019
1020    let binary = crate::core::portable_binary::resolve_portable_binary();
1021
1022    if dry_run {
1023        let rc = if is_powershell {
1024            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
1025        } else if is_fish {
1026            "~/.config/fish/config.fish".to_string()
1027        } else if is_zsh {
1028            "~/.zshrc".to_string()
1029        } else {
1030            "~/.bashrc".to_string()
1031        };
1032        qprintln!("\nlean-ctx init --dry-run\n");
1033        qprintln!("  Would modify:  {rc}");
1034        qprintln!("  Would backup:  {rc}.lean-ctx.bak");
1035        qprintln!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
1036        qprintln!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
1037        qprintln!("                 curl wget php composer (24 commands + k)");
1038        qprintln!("  Would create:  ~/.lean-ctx/");
1039        qprintln!("  Binary:        {binary}");
1040        qprintln!("\n  Safety: aliases auto-fallback to original command if lean-ctx is removed.");
1041        qprintln!("\n  Run without --dry-run to apply.");
1042        return;
1043    }
1044
1045    if is_powershell {
1046        init_powershell(&binary);
1047    } else {
1048        let bash_binary = to_bash_compatible_path(&binary);
1049        if is_fish {
1050            init_fish(&bash_binary);
1051        } else {
1052            init_posix(is_zsh, &bash_binary);
1053        }
1054    }
1055
1056    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
1057    if let Some(dir) = lean_dir {
1058        if !dir.exists() {
1059            let _ = std::fs::create_dir_all(&dir);
1060            qprintln!("Created {}", dir.display());
1061        }
1062    }
1063
1064    let rc = if is_powershell {
1065        "$PROFILE"
1066    } else if is_fish {
1067        "config.fish"
1068    } else if is_zsh {
1069        ".zshrc"
1070    } else {
1071        ".bashrc"
1072    };
1073
1074    qprintln!("\nlean-ctx init complete (24 aliases installed)");
1075    qprintln!();
1076    qprintln!("  Disable temporarily:  lean-ctx-off");
1077    qprintln!("  Re-enable:            lean-ctx-on");
1078    qprintln!("  Check status:         lean-ctx-status");
1079    qprintln!("  Full uninstall:       lean-ctx uninstall");
1080    qprintln!("  Diagnose issues:      lean-ctx doctor");
1081    qprintln!("  Preview changes:      lean-ctx init --global --dry-run");
1082    qprintln!();
1083    if is_powershell {
1084        qprintln!("  Restart PowerShell or run: . {rc}");
1085    } else {
1086        qprintln!("  Restart your shell or run: source ~/{rc}");
1087    }
1088    qprintln!();
1089    qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1090    qprintln!("  Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1091    qprintln!("    crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1092    qprintln!("    pi, qwen, roo, sublime, trae, verdent, windsurf");
1093}
1094
1095pub fn cmd_init_quiet(args: &[String]) {
1096    std::env::set_var("LEAN_CTX_QUIET", "1");
1097    cmd_init(args);
1098    std::env::remove_var("LEAN_CTX_QUIET");
1099}
1100
1101pub fn load_shell_history_pub() -> Vec<String> {
1102    load_shell_history()
1103}
1104
1105fn load_shell_history() -> Vec<String> {
1106    let shell = std::env::var("SHELL").unwrap_or_default();
1107    let home = match dirs::home_dir() {
1108        Some(h) => h,
1109        None => return Vec::new(),
1110    };
1111
1112    let history_file = if shell.contains("zsh") {
1113        home.join(".zsh_history")
1114    } else if shell.contains("fish") {
1115        home.join(".local/share/fish/fish_history")
1116    } else if cfg!(windows) && shell.is_empty() {
1117        home.join("AppData")
1118            .join("Roaming")
1119            .join("Microsoft")
1120            .join("Windows")
1121            .join("PowerShell")
1122            .join("PSReadLine")
1123            .join("ConsoleHost_history.txt")
1124    } else {
1125        home.join(".bash_history")
1126    };
1127
1128    match std::fs::read_to_string(&history_file) {
1129        Ok(content) => content
1130            .lines()
1131            .filter_map(|l| {
1132                let trimmed = l.trim();
1133                if trimmed.starts_with(':') {
1134                    trimmed.split(';').nth(1).map(|s| s.to_string())
1135                } else {
1136                    Some(trimmed.to_string())
1137                }
1138            })
1139            .filter(|l| !l.is_empty())
1140            .collect(),
1141        Err(_) => Vec::new(),
1142    }
1143}
1144
1145fn print_savings(original: usize, sent: usize) {
1146    let saved = original.saturating_sub(sent);
1147    if original > 0 && saved > 0 {
1148        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1149        println!("[{saved} tok saved ({pct}%)]");
1150    }
1151}
1152
1153pub fn cmd_theme(args: &[String]) {
1154    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1155    let r = theme::rst();
1156    let b = theme::bold();
1157    let d = theme::dim();
1158
1159    match sub {
1160        "list" => {
1161            let cfg = config::Config::load();
1162            let active = cfg.theme.as_str();
1163            println!();
1164            println!("  {b}Available themes:{r}");
1165            println!("  {ln}", ln = "─".repeat(40));
1166            for name in theme::PRESET_NAMES {
1167                let marker = if *name == active { " ◀ active" } else { "" };
1168                let t = theme::from_preset(name).unwrap();
1169                let preview = format!(
1170                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1171                    p = t.primary.fg(),
1172                    s = t.secondary.fg(),
1173                    a = t.accent.fg(),
1174                    sc = t.success.fg(),
1175                    w = t.warning.fg(),
1176                );
1177                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1178            }
1179            if let Some(path) = theme::theme_file_path() {
1180                if path.exists() {
1181                    let custom = theme::load_theme("_custom_");
1182                    let preview = format!(
1183                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1184                        p = custom.primary.fg(),
1185                        s = custom.secondary.fg(),
1186                        a = custom.accent.fg(),
1187                        sc = custom.success.fg(),
1188                        w = custom.warning.fg(),
1189                    );
1190                    let marker = if active == "custom" {
1191                        " ◀ active"
1192                    } else {
1193                        ""
1194                    };
1195                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1196                }
1197            }
1198            println!();
1199            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1200            println!();
1201        }
1202        "set" => {
1203            if args.len() < 2 {
1204                eprintln!("Usage: lean-ctx theme set <name>");
1205                std::process::exit(1);
1206            }
1207            let name = &args[1];
1208            if theme::from_preset(name).is_none() && name != "custom" {
1209                eprintln!(
1210                    "Unknown theme '{name}'. Available: {}",
1211                    theme::PRESET_NAMES.join(", ")
1212                );
1213                std::process::exit(1);
1214            }
1215            let mut cfg = config::Config::load();
1216            cfg.theme = name.to_string();
1217            match cfg.save() {
1218                Ok(()) => {
1219                    let t = theme::load_theme(name);
1220                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1221                    let preview = t.gradient_bar(0.75, 30);
1222                    println!("  {preview}");
1223                }
1224                Err(e) => eprintln!("Error: {e}"),
1225            }
1226        }
1227        "export" => {
1228            let cfg = config::Config::load();
1229            let t = theme::load_theme(&cfg.theme);
1230            println!("{}", t.to_toml());
1231        }
1232        "import" => {
1233            if args.len() < 2 {
1234                eprintln!("Usage: lean-ctx theme import <path>");
1235                std::process::exit(1);
1236            }
1237            let path = std::path::Path::new(&args[1]);
1238            if !path.exists() {
1239                eprintln!("File not found: {}", args[1]);
1240                std::process::exit(1);
1241            }
1242            match std::fs::read_to_string(path) {
1243                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1244                    Ok(imported) => match theme::save_theme(&imported) {
1245                        Ok(()) => {
1246                            let mut cfg = config::Config::load();
1247                            cfg.theme = "custom".to_string();
1248                            let _ = cfg.save();
1249                            println!(
1250                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1251                                sc = imported.success.fg(),
1252                                name = imported.name,
1253                            );
1254                            println!("  Config updated: theme = custom");
1255                        }
1256                        Err(e) => eprintln!("Error saving theme: {e}"),
1257                    },
1258                    Err(e) => eprintln!("Invalid theme file: {e}"),
1259                },
1260                Err(e) => eprintln!("Error reading file: {e}"),
1261            }
1262        }
1263        "preview" => {
1264            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1265            let t = match theme::from_preset(name) {
1266                Some(t) => t,
1267                None => {
1268                    eprintln!("Unknown theme: {name}");
1269                    std::process::exit(1);
1270                }
1271            };
1272            println!();
1273            println!(
1274                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1275                icon = t.header_icon(),
1276                title = t.brand_title(),
1277            );
1278            println!("  {ln}", ln = t.border_line(50));
1279            println!();
1280            println!(
1281                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1282                sc = t.success.fg(),
1283                sec = t.secondary.fg(),
1284                wrn = t.warning.fg(),
1285                acc = t.accent.fg(),
1286            );
1287            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1288            println!();
1289            println!(
1290                "  {b}{txt}Gradient Bar{r}      {bar}",
1291                txt = t.text.fg(),
1292                bar = t.gradient_bar(0.85, 30),
1293            );
1294            println!(
1295                "  {b}{txt}Sparkline{r}         {spark}",
1296                txt = t.text.fg(),
1297                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1298            );
1299            println!();
1300            println!("  {top}", top = t.box_top(50));
1301            println!(
1302                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1303                side = t.box_side(),
1304                side_r = t.box_side(),
1305                txt = t.text.fg(),
1306            );
1307            println!("  {bot}", bot = t.box_bottom(50));
1308            println!();
1309        }
1310        _ => {
1311            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1312            std::process::exit(1);
1313        }
1314    }
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319    use super::*;
1320    use tempfile;
1321
1322    #[test]
1323    fn test_remove_lean_ctx_block_posix() {
1324        let input = r#"# existing config
1325export PATH="$HOME/bin:$PATH"
1326
1327# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1328if [ -z "$LEAN_CTX_ACTIVE" ]; then
1329alias git='lean-ctx -c git'
1330alias npm='lean-ctx -c npm'
1331fi
1332
1333# other stuff
1334export EDITOR=vim
1335"#;
1336        let result = remove_lean_ctx_block(input);
1337        assert!(!result.contains("lean-ctx"), "block should be removed");
1338        assert!(result.contains("export PATH"), "other content preserved");
1339        assert!(
1340            result.contains("export EDITOR"),
1341            "trailing content preserved"
1342        );
1343    }
1344
1345    #[test]
1346    fn test_remove_lean_ctx_block_fish() {
1347        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";
1348        let result = remove_lean_ctx_block(input);
1349        assert!(!result.contains("lean-ctx"), "block should be removed");
1350        assert!(result.contains("set -x FOO"), "other content preserved");
1351        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1352    }
1353
1354    #[test]
1355    fn test_remove_lean_ctx_block_ps() {
1356        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";
1357        let result = remove_lean_ctx_block_ps(input);
1358        assert!(
1359            !result.contains("lean-ctx shell hook"),
1360            "block should be removed"
1361        );
1362        assert!(result.contains("$env:FOO"), "other content preserved");
1363        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1364    }
1365
1366    #[test]
1367    fn test_remove_lean_ctx_block_ps_nested() {
1368        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";
1369        let result = remove_lean_ctx_block_ps(input);
1370        assert!(
1371            !result.contains("lean-ctx shell hook"),
1372            "block should be removed"
1373        );
1374        assert!(!result.contains("_lc"), "function should be removed");
1375        assert!(result.contains("$env:FOO"), "other content preserved");
1376        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1377    }
1378
1379    #[test]
1380    fn test_remove_block_no_lean_ctx() {
1381        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1382        let result = remove_lean_ctx_block(input);
1383        assert!(result.contains("export PATH"), "content unchanged");
1384    }
1385
1386    #[test]
1387    fn test_bash_hook_contains_pipe_guard() {
1388        let binary = "/usr/local/bin/lean-ctx";
1389        let hook = format!(
1390            r#"_lc() {{
1391    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1392        command "$@"
1393        return
1394    fi
1395    '{binary}' -t "$@"
1396}}"#
1397        );
1398        assert!(
1399            hook.contains("! -t 1"),
1400            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1401        );
1402        assert!(
1403            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1404            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1405        );
1406    }
1407
1408    #[test]
1409    fn test_lc_uses_track_mode_by_default() {
1410        let binary = "/usr/local/bin/lean-ctx";
1411        let alias_list = crate::rewrite_registry::shell_alias_list();
1412        let aliases = format!(
1413            r#"_lc() {{
1414    '{binary}' -t "$@"
1415}}
1416_lc_compress() {{
1417    '{binary}' -c "$@"
1418}}"#
1419        );
1420        assert!(
1421            aliases.contains("-t \"$@\""),
1422            "_lc must use -t (track mode) by default"
1423        );
1424        assert!(
1425            aliases.contains("-c \"$@\""),
1426            "_lc_compress must use -c (compress mode)"
1427        );
1428        let _ = alias_list;
1429    }
1430
1431    #[test]
1432    fn test_posix_shell_has_lean_ctx_mode() {
1433        let alias_list = crate::rewrite_registry::shell_alias_list();
1434        let aliases = r#"
1435lean-ctx-mode() {{
1436    case "${{1:-}}" in
1437        compress) echo compress ;;
1438        track) echo track ;;
1439        off) echo off ;;
1440    esac
1441}}
1442"#
1443        .to_string();
1444        assert!(
1445            aliases.contains("lean-ctx-mode()"),
1446            "lean-ctx-mode function must exist"
1447        );
1448        assert!(
1449            aliases.contains("compress"),
1450            "compress mode must be available"
1451        );
1452        assert!(aliases.contains("track"), "track mode must be available");
1453        let _ = alias_list;
1454    }
1455
1456    #[test]
1457    fn test_fish_hook_contains_pipe_guard() {
1458        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";
1459        assert!(
1460            hook.contains("isatty stdout"),
1461            "fish hook must contain pipe guard (isatty stdout)"
1462        );
1463    }
1464
1465    #[test]
1466    fn test_powershell_hook_contains_pipe_guard() {
1467        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1468        assert!(
1469            hook.contains("IsOutputRedirected"),
1470            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1471        );
1472    }
1473
1474    #[test]
1475    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1476        let input = r#"# existing config
1477export PATH="$HOME/bin:$PATH"
1478
1479# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1480_lean_ctx_cmds=(git npm pnpm)
1481
1482lean-ctx-on() {
1483    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1484        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1485    done
1486    export LEAN_CTX_ENABLED=1
1487    [ -t 1 ] && echo "lean-ctx: ON"
1488}
1489
1490lean-ctx-off() {
1491    unset LEAN_CTX_ENABLED
1492    [ -t 1 ] && echo "lean-ctx: OFF"
1493}
1494
1495if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1496    lean-ctx-on
1497fi
1498# lean-ctx shell hook — end
1499
1500# other stuff
1501export EDITOR=vim
1502"#;
1503        let result = remove_lean_ctx_block(input);
1504        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1505        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1506        assert!(result.contains("export PATH"), "other content preserved");
1507        assert!(
1508            result.contains("export EDITOR"),
1509            "trailing content preserved"
1510        );
1511    }
1512
1513    #[test]
1514    fn env_sh_for_containers_includes_self_heal() {
1515        let _g = crate::core::data_dir::test_env_lock();
1516        let tmp = tempfile::tempdir().expect("tempdir");
1517        let data_dir = tmp.path().join("data");
1518        std::fs::create_dir_all(&data_dir).expect("mkdir data");
1519        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1520
1521        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1522        let env_sh = data_dir.join("env.sh");
1523        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1524        assert!(content.contains("lean-ctx docker self-heal"));
1525        assert!(content.contains("claude mcp list"));
1526        assert!(content.contains("lean-ctx init --agent claude"));
1527
1528        std::env::remove_var("LEAN_CTX_DATA_DIR");
1529    }
1530}