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