Skip to main content

lean_ctx/
cli.rs

1use std::path::Path;
2
3use crate::core::compressor;
4use crate::core::config;
5use crate::core::deps as dep_extract;
6use crate::core::entropy;
7use crate::core::patterns::deps_cmd;
8use crate::core::protocol;
9use crate::core::signatures;
10use crate::core::stats;
11use crate::core::theme;
12use crate::core::tokens::count_tokens;
13use crate::hooks::to_bash_compatible_path;
14
15pub fn cmd_read(args: &[String]) {
16    if args.is_empty() {
17        eprintln!(
18            "Usage: lean-ctx read <file> [--mode full|map|signatures|aggressive|entropy] [--fresh]"
19        );
20        std::process::exit(1);
21    }
22
23    let path = &args[0];
24    let mode = args
25        .iter()
26        .position(|a| a == "--mode" || a == "-m")
27        .and_then(|i| args.get(i + 1))
28        .map(|s| s.as_str())
29        .unwrap_or("full");
30    let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
31
32    let short = protocol::shorten_path(path);
33
34    if !force_fresh && mode == "full" {
35        use crate::core::cli_cache::{self, CacheResult};
36        match cli_cache::check_and_read(path) {
37            CacheResult::Hit { entry, file_ref } => {
38                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
39                println!("{msg}");
40                stats::record("cli_read", entry.original_tokens, count_tokens(&msg));
41                return;
42            }
43            CacheResult::Miss { content } if content.is_empty() => {
44                eprintln!("Error: could not read {path}");
45                std::process::exit(1);
46            }
47            CacheResult::Miss { content } => {
48                let line_count = content.lines().count();
49                println!("{short} [{line_count}L]");
50                println!("{content}");
51                stats::record("cli_read", count_tokens(&content), count_tokens(&content));
52                return;
53            }
54        }
55    }
56
57    let content = match crate::tools::ctx_read::read_file_lossy(path) {
58        Ok(c) => c,
59        Err(e) => {
60            eprintln!("Error: {e}");
61            std::process::exit(1);
62        }
63    };
64
65    let ext = Path::new(path)
66        .extension()
67        .and_then(|e| e.to_str())
68        .unwrap_or("");
69    let line_count = content.lines().count();
70    let original_tokens = count_tokens(&content);
71
72    let mode = if mode == "auto" {
73        let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
74        let predictor = crate::core::mode_predictor::ModePredictor::new();
75        predictor
76            .predict_best_mode(&sig)
77            .unwrap_or_else(|| "full".to_string())
78    } else {
79        mode.to_string()
80    };
81    let mode = mode.as_str();
82
83    match mode {
84        "map" => {
85            let sigs = signatures::extract_signatures(&content, ext);
86            let dep_info = dep_extract::extract_deps(&content, ext);
87
88            println!("{short} [{line_count}L]");
89            if !dep_info.imports.is_empty() {
90                println!("  deps: {}", dep_info.imports.join(", "));
91            }
92            if !dep_info.exports.is_empty() {
93                println!("  exports: {}", dep_info.exports.join(", "));
94            }
95            let key_sigs: Vec<_> = sigs
96                .iter()
97                .filter(|s| s.is_exported || s.indent == 0)
98                .collect();
99            if !key_sigs.is_empty() {
100                println!("  API:");
101                for sig in &key_sigs {
102                    println!("    {}", sig.to_compact());
103                }
104            }
105            let sent = count_tokens(&short.to_string());
106            print_savings(original_tokens, sent);
107        }
108        "signatures" => {
109            let sigs = signatures::extract_signatures(&content, ext);
110            println!("{short} [{line_count}L]");
111            for sig in &sigs {
112                println!("{}", sig.to_compact());
113            }
114            let sent = count_tokens(&short.to_string());
115            print_savings(original_tokens, sent);
116        }
117        "aggressive" => {
118            let compressed = compressor::aggressive_compress(&content, Some(ext));
119            println!("{short} [{line_count}L]");
120            println!("{compressed}");
121            let sent = count_tokens(&compressed);
122            print_savings(original_tokens, sent);
123        }
124        "entropy" => {
125            let result = entropy::entropy_compress(&content);
126            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
127            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
128            for tech in &result.techniques {
129                println!("{tech}");
130            }
131            println!("{}", result.output);
132            let sent = count_tokens(&result.output);
133            print_savings(original_tokens, sent);
134        }
135        _ => {
136            println!("{short} [{line_count}L]");
137            println!("{content}");
138        }
139    }
140}
141
142pub fn cmd_diff(args: &[String]) {
143    if args.len() < 2 {
144        eprintln!("Usage: lean-ctx diff <file1> <file2>");
145        std::process::exit(1);
146    }
147
148    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
149        Ok(c) => c,
150        Err(e) => {
151            eprintln!("Error reading {}: {e}", args[0]);
152            std::process::exit(1);
153        }
154    };
155
156    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
157        Ok(c) => c,
158        Err(e) => {
159            eprintln!("Error reading {}: {e}", args[1]);
160            std::process::exit(1);
161        }
162    };
163
164    let diff = compressor::diff_content(&content1, &content2);
165    let original = count_tokens(&content1) + count_tokens(&content2);
166    let sent = count_tokens(&diff);
167
168    println!(
169        "diff {} {}",
170        protocol::shorten_path(&args[0]),
171        protocol::shorten_path(&args[1])
172    );
173    println!("{diff}");
174    print_savings(original, sent);
175}
176
177pub fn cmd_grep(args: &[String]) {
178    if args.is_empty() {
179        eprintln!("Usage: lean-ctx grep <pattern> [path]");
180        std::process::exit(1);
181    }
182
183    let pattern = &args[0];
184    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
185
186    let re = match regex::Regex::new(pattern) {
187        Ok(r) => r,
188        Err(e) => {
189            eprintln!("Invalid regex pattern: {e}");
190            std::process::exit(1);
191        }
192    };
193
194    let mut found = false;
195    for entry in ignore::WalkBuilder::new(path)
196        .hidden(true)
197        .git_ignore(true)
198        .git_global(true)
199        .git_exclude(true)
200        .max_depth(Some(10))
201        .build()
202        .flatten()
203    {
204        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
205            continue;
206        }
207        let file_path = entry.path();
208        if let Ok(content) = std::fs::read_to_string(file_path) {
209            for (i, line) in content.lines().enumerate() {
210                if re.is_match(line) {
211                    println!("{}:{}:{}", file_path.display(), i + 1, line);
212                    found = true;
213                }
214            }
215        }
216    }
217
218    if !found {
219        std::process::exit(1);
220    }
221}
222
223pub fn cmd_find(args: &[String]) {
224    if args.is_empty() {
225        eprintln!("Usage: lean-ctx find <pattern> [path]");
226        std::process::exit(1);
227    }
228
229    let raw_pattern = &args[0];
230    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
231
232    let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
233    let glob_matcher = if is_glob {
234        glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
235    } else {
236        None
237    };
238    let substring = raw_pattern.to_lowercase();
239
240    let mut found = false;
241    for entry in ignore::WalkBuilder::new(path)
242        .hidden(true)
243        .git_ignore(true)
244        .git_global(true)
245        .git_exclude(true)
246        .max_depth(Some(10))
247        .build()
248        .flatten()
249    {
250        let name = entry.file_name().to_string_lossy().to_lowercase();
251        let matches = if let Some(ref g) = glob_matcher {
252            g.matches(&name)
253        } else {
254            name.contains(&substring)
255        };
256        if matches {
257            println!("{}", entry.path().display());
258            found = true;
259        }
260    }
261
262    if !found {
263        std::process::exit(1);
264    }
265}
266
267pub fn cmd_ls(args: &[String]) {
268    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
269    let command = if cfg!(windows) {
270        format!("dir {}", path.replace('/', "\\"))
271    } else {
272        format!("ls -la {path}")
273    };
274    let code = crate::shell::exec(&command);
275    std::process::exit(code);
276}
277
278pub fn cmd_deps(args: &[String]) {
279    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
280
281    match deps_cmd::detect_and_compress(path) {
282        Some(result) => println!("{result}"),
283        None => {
284            eprintln!("No dependency file found in {path}");
285            std::process::exit(1);
286        }
287    }
288}
289
290pub fn cmd_discover(_args: &[String]) {
291    let history = load_shell_history();
292    if history.is_empty() {
293        println!("No shell history found.");
294        return;
295    }
296
297    let result = crate::tools::ctx_discover::analyze_history(&history, 20);
298    println!("{}", crate::tools::ctx_discover::format_cli_output(&result));
299}
300
301pub fn cmd_session() {
302    let history = load_shell_history();
303    let gain = stats::load_stats();
304
305    let compressible_commands = [
306        "git ",
307        "npm ",
308        "yarn ",
309        "pnpm ",
310        "cargo ",
311        "docker ",
312        "kubectl ",
313        "gh ",
314        "pip ",
315        "pip3 ",
316        "eslint",
317        "prettier",
318        "ruff ",
319        "go ",
320        "golangci-lint",
321        "curl ",
322        "wget ",
323        "grep ",
324        "rg ",
325        "find ",
326        "ls ",
327    ];
328
329    let mut total = 0u32;
330    let mut via_hook = 0u32;
331
332    for line in &history {
333        let cmd = line.trim().to_lowercase();
334        if cmd.starts_with("lean-ctx") {
335            via_hook += 1;
336            total += 1;
337        } else {
338            for p in &compressible_commands {
339                if cmd.starts_with(p) {
340                    total += 1;
341                    break;
342                }
343            }
344        }
345    }
346
347    let pct = if total > 0 {
348        (via_hook as f64 / total as f64 * 100.0).round() as u32
349    } else {
350        0
351    };
352
353    println!("lean-ctx session statistics\n");
354    println!(
355        "Adoption:    {}% ({}/{} compressible commands)",
356        pct, via_hook, total
357    );
358    println!("Saved:       {} tokens total", gain.total_saved);
359    println!("Calls:       {} compressed", gain.total_calls);
360
361    if total > via_hook {
362        let missed = total - via_hook;
363        let est = missed * 150;
364        println!(
365            "Missed:      {} commands (~{} tokens saveable)",
366            missed, est
367        );
368    }
369
370    println!("\nRun 'lean-ctx discover' for details on missed commands.");
371}
372
373pub fn cmd_wrapped(args: &[String]) {
374    let period = if args.iter().any(|a| a == "--month") {
375        "month"
376    } else if args.iter().any(|a| a == "--all") {
377        "all"
378    } else {
379        "week"
380    };
381
382    let report = crate::core::wrapped::WrappedReport::generate(period);
383    println!("{}", report.format_ascii());
384}
385
386pub fn cmd_sessions(args: &[String]) {
387    use crate::core::session::SessionState;
388
389    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
390
391    match action {
392        "list" | "ls" => {
393            let sessions = SessionState::list_sessions();
394            if sessions.is_empty() {
395                println!("No sessions found.");
396                return;
397            }
398            println!("Sessions ({}):\n", sessions.len());
399            for s in sessions.iter().take(20) {
400                let task = s.task.as_deref().unwrap_or("(no task)");
401                let task_short: String = task.chars().take(50).collect();
402                let date = s.updated_at.format("%Y-%m-%d %H:%M");
403                println!(
404                    "  {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
405                    s.id,
406                    s.version,
407                    s.tool_calls,
408                    format_tokens_cli(s.tokens_saved),
409                    date,
410                    task_short
411                );
412            }
413            if sessions.len() > 20 {
414                println!("  ... +{} more", sessions.len() - 20);
415            }
416        }
417        "show" => {
418            let id = args.get(1);
419            let session = if let Some(id) = id {
420                SessionState::load_by_id(id)
421            } else {
422                SessionState::load_latest()
423            };
424            match session {
425                Some(s) => println!("{}", s.format_compact()),
426                None => println!("Session not found."),
427            }
428        }
429        "cleanup" => {
430            let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
431            let removed = SessionState::cleanup_old_sessions(days);
432            println!("Cleaned up {removed} session(s) older than {days} days.");
433        }
434        _ => {
435            eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
436            std::process::exit(1);
437        }
438    }
439}
440
441pub fn cmd_benchmark(args: &[String]) {
442    use crate::core::benchmark;
443
444    let action = args.first().map(|s| s.as_str()).unwrap_or("run");
445
446    match action {
447        "run" => {
448            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
449            let is_json = args.iter().any(|a| a == "--json");
450
451            let result = benchmark::run_project_benchmark(path);
452            if is_json {
453                println!("{}", benchmark::format_json(&result));
454            } else {
455                println!("{}", benchmark::format_terminal(&result));
456            }
457        }
458        "report" => {
459            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
460            let result = benchmark::run_project_benchmark(path);
461            println!("{}", benchmark::format_markdown(&result));
462        }
463        _ => {
464            if std::path::Path::new(action).exists() {
465                let result = benchmark::run_project_benchmark(action);
466                println!("{}", benchmark::format_terminal(&result));
467            } else {
468                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
469                eprintln!("       lean-ctx benchmark report [path]");
470                std::process::exit(1);
471            }
472        }
473    }
474}
475
476fn format_tokens_cli(tokens: u64) -> String {
477    if tokens >= 1_000_000 {
478        format!("{:.1}M", tokens as f64 / 1_000_000.0)
479    } else if tokens >= 1_000 {
480        format!("{:.1}K", tokens as f64 / 1_000.0)
481    } else {
482        format!("{tokens}")
483    }
484}
485
486pub fn cmd_stats(args: &[String]) {
487    match args.first().map(|s| s.as_str()) {
488        Some("reset-cep") => {
489            crate::core::stats::reset_cep();
490            println!("CEP stats reset. Shell hook data preserved.");
491        }
492        Some("json") => {
493            let store = crate::core::stats::load();
494            println!(
495                "{}",
496                serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
497            );
498        }
499        _ => {
500            let store = crate::core::stats::load();
501            let input_saved = store
502                .total_input_tokens
503                .saturating_sub(store.total_output_tokens);
504            let pct = if store.total_input_tokens > 0 {
505                input_saved as f64 / store.total_input_tokens as f64 * 100.0
506            } else {
507                0.0
508            };
509            println!("Commands:    {}", store.total_commands);
510            println!("Input:       {} tokens", store.total_input_tokens);
511            println!("Output:      {} tokens", store.total_output_tokens);
512            println!("Saved:       {} tokens ({:.1}%)", input_saved, pct);
513            println!();
514            println!("CEP sessions:  {}", store.cep.sessions);
515            println!(
516                "CEP tokens:    {} → {}",
517                store.cep.total_tokens_original, store.cep.total_tokens_compressed
518            );
519            println!();
520            println!("Subcommands: stats reset-cep | stats json");
521        }
522    }
523}
524
525pub fn cmd_cache(args: &[String]) {
526    use crate::core::cli_cache;
527    match args.first().map(|s| s.as_str()) {
528        Some("clear") => {
529            let count = cli_cache::clear();
530            println!("Cleared {count} cached entries.");
531        }
532        Some("reset") => {
533            let project_flag = args.get(1).map(|s| s.as_str()) == Some("--project");
534            if project_flag {
535                let root =
536                    crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
537                match root {
538                    Some(root) => {
539                        let count = cli_cache::clear_project(&root);
540                        println!("Reset {count} cache entries for project: {root}");
541                    }
542                    None => {
543                        eprintln!("No active project root found. Start a session first.");
544                        std::process::exit(1);
545                    }
546                }
547            } else {
548                let count = cli_cache::clear();
549                println!("Reset all {count} cache entries.");
550            }
551        }
552        Some("stats") => {
553            let (hits, reads, entries) = cli_cache::stats();
554            let rate = if reads > 0 {
555                (hits as f64 / reads as f64 * 100.0).round() as u32
556            } else {
557                0
558            };
559            println!("CLI Cache Stats:");
560            println!("  Entries:   {entries}");
561            println!("  Reads:     {reads}");
562            println!("  Hits:      {hits}");
563            println!("  Hit Rate:  {rate}%");
564        }
565        Some("invalidate") => {
566            if args.len() < 2 {
567                eprintln!("Usage: lean-ctx cache invalidate <path>");
568                std::process::exit(1);
569            }
570            cli_cache::invalidate(&args[1]);
571            println!("Invalidated cache for {}", args[1]);
572        }
573        _ => {
574            let (hits, reads, entries) = cli_cache::stats();
575            let rate = if reads > 0 {
576                (hits as f64 / reads as f64 * 100.0).round() as u32
577            } else {
578                0
579            };
580            println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
581            println!();
582            println!("Subcommands:");
583            println!("  cache stats       Show detailed stats");
584            println!("  cache clear       Clear all cached entries");
585            println!("  cache reset       Reset all cache (or --project for current project only)");
586            println!("  cache invalidate  Remove specific file from cache");
587        }
588    }
589}
590
591pub fn cmd_config(args: &[String]) {
592    let cfg = config::Config::load();
593
594    if args.is_empty() {
595        println!("{}", cfg.show());
596        return;
597    }
598
599    match args[0].as_str() {
600        "init" | "create" => {
601            let default = config::Config::default();
602            match default.save() {
603                Ok(()) => {
604                    let path = config::Config::path()
605                        .map(|p| p.to_string_lossy().to_string())
606                        .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
607                    println!("Created default config at {path}");
608                }
609                Err(e) => eprintln!("Error: {e}"),
610            }
611        }
612        "set" => {
613            if args.len() < 3 {
614                eprintln!("Usage: lean-ctx config set <key> <value>");
615                std::process::exit(1);
616            }
617            let mut cfg = cfg;
618            let key = &args[1];
619            let val = &args[2];
620            match key.as_str() {
621                "ultra_compact" => cfg.ultra_compact = val == "true",
622                "tee_on_error" | "tee_mode" => {
623                    cfg.tee_mode = match val.as_str() {
624                        "true" | "failures" => config::TeeMode::Failures,
625                        "always" => config::TeeMode::Always,
626                        "false" | "never" => config::TeeMode::Never,
627                        _ => {
628                            eprintln!("Valid tee_mode values: always, failures, never");
629                            std::process::exit(1);
630                        }
631                    };
632                }
633                "checkpoint_interval" => {
634                    cfg.checkpoint_interval = val.parse().unwrap_or(15);
635                }
636                "theme" => {
637                    if theme::from_preset(val).is_some() || val == "custom" {
638                        cfg.theme = val.to_string();
639                    } else {
640                        eprintln!(
641                            "Unknown theme '{val}'. Available: {}",
642                            theme::PRESET_NAMES.join(", ")
643                        );
644                        std::process::exit(1);
645                    }
646                }
647                "slow_command_threshold_ms" => {
648                    cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
649                }
650                "passthrough_urls" => {
651                    cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
652                }
653                "rules_scope" => match val.as_str() {
654                    "global" | "project" | "both" => {
655                        cfg.rules_scope = Some(val.to_string());
656                    }
657                    _ => {
658                        eprintln!("Valid rules_scope values: global, project, both");
659                        std::process::exit(1);
660                    }
661                },
662                _ => {
663                    eprintln!("Unknown config key: {key}");
664                    std::process::exit(1);
665                }
666            }
667            match cfg.save() {
668                Ok(()) => println!("Updated {key} = {val}"),
669                Err(e) => eprintln!("Error saving config: {e}"),
670            }
671        }
672        _ => {
673            eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
674            std::process::exit(1);
675        }
676    }
677}
678
679pub fn cmd_cheatsheet() {
680    let ver = env!("CARGO_PKG_VERSION");
681    let ver_pad = format!("v{ver}");
682    let header = format!(
683        "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
684\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
685\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m");
686    println!(
687        "{header}
688
689\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
690  ctx_session load               \x1b[2m# restore previous session\x1b[0m
691  ctx_overview task=\"...\"         \x1b[2m# task-aware file map\x1b[0m
692  ctx_graph action=build          \x1b[2m# index project (first time)\x1b[0m
693  ctx_knowledge action=recall     \x1b[2m# check stored project facts\x1b[0m
694
695\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
696  ctx_read mode=full    \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
697  ctx_read mode=map     \x1b[2m# context-only files (~93% saved)\x1b[0m
698  ctx_read mode=diff    \x1b[2m# after editing (~98% saved)\x1b[0m
699  ctx_read mode=sigs    \x1b[2m# API surface of large files (~95%)\x1b[0m
700  ctx_multi_read        \x1b[2m# read multiple files at once\x1b[0m
701  ctx_search            \x1b[2m# search with compressed results (~70%)\x1b[0m
702  ctx_shell             \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
703
704\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
705  ctx_session finding \"...\"       \x1b[2m# record what you discovered\x1b[0m
706  ctx_session decision \"...\"      \x1b[2m# record architectural choices\x1b[0m
707  ctx_knowledge action=remember   \x1b[2m# store permanent project facts\x1b[0m
708  ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
709  ctx_metrics                     \x1b[2m# see session statistics\x1b[0m
710
711\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
712  ctx_agent action=register       \x1b[2m# announce yourself\x1b[0m
713  ctx_agent action=list           \x1b[2m# see other active agents\x1b[0m
714  ctx_agent action=post           \x1b[2m# share findings\x1b[0m
715  ctx_agent action=read           \x1b[2m# check messages\x1b[0m
716
717\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
718  Will edit?  → \x1b[1mfull\x1b[0m (re-reads: 13 tokens)  → after edit: \x1b[1mdiff\x1b[0m
719  API only?   → \x1b[1msignatures\x1b[0m
720  Deps/exports? → \x1b[1mmap\x1b[0m
721  Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
722  Browsing?   → \x1b[1maggressive\x1b[0m (syntax stripped)
723
724\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
725  lean-ctx gain          \x1b[2m# visual savings dashboard\x1b[0m
726  lean-ctx gain --live   \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
727  lean-ctx dashboard     \x1b[2m# web dashboard with charts\x1b[0m
728  lean-ctx wrapped       \x1b[2m# weekly savings report\x1b[0m
729  lean-ctx discover      \x1b[2m# find uncompressed commands\x1b[0m
730  lean-ctx doctor        \x1b[2m# diagnose installation\x1b[0m
731  lean-ctx update        \x1b[2m# self-update to latest\x1b[0m
732
733\x1b[2m  Full guide: https://leanctx.com/docs/workflow\x1b[0m"
734    );
735}
736
737pub fn cmd_slow_log(args: &[String]) {
738    use crate::core::slow_log;
739
740    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
741    match action {
742        "list" | "ls" | "" => println!("{}", slow_log::list()),
743        "clear" | "purge" => println!("{}", slow_log::clear()),
744        _ => {
745            eprintln!("Usage: lean-ctx slow-log [list|clear]");
746            std::process::exit(1);
747        }
748    }
749}
750
751pub fn cmd_tee(args: &[String]) {
752    let tee_dir = match dirs::home_dir() {
753        Some(h) => h.join(".lean-ctx").join("tee"),
754        None => {
755            eprintln!("Cannot determine home directory");
756            std::process::exit(1);
757        }
758    };
759
760    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
761    match action {
762        "list" | "ls" => {
763            if !tee_dir.exists() {
764                println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
765                return;
766            }
767            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
768                .unwrap_or_else(|e| {
769                    eprintln!("Error: {e}");
770                    std::process::exit(1);
771                })
772                .filter_map(|e| e.ok())
773                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
774                .collect();
775            entries.sort_by_key(|e| e.file_name());
776
777            if entries.is_empty() {
778                println!("No tee logs found.");
779                return;
780            }
781
782            println!("Tee logs ({}):\n", entries.len());
783            for entry in &entries {
784                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
785                let name = entry.file_name();
786                let size_str = if size > 1024 {
787                    format!("{}K", size / 1024)
788                } else {
789                    format!("{}B", size)
790                };
791                println!("  {:<60} {}", name.to_string_lossy(), size_str);
792            }
793            println!("\nUse 'lean-ctx tee clear' to delete all logs.");
794        }
795        "clear" | "purge" => {
796            if !tee_dir.exists() {
797                println!("No tee logs to clear.");
798                return;
799            }
800            let mut count = 0u32;
801            if let Ok(entries) = std::fs::read_dir(&tee_dir) {
802                for entry in entries.flatten() {
803                    if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
804                        && std::fs::remove_file(entry.path()).is_ok()
805                    {
806                        count += 1;
807                    }
808                }
809            }
810            println!("Cleared {count} tee log(s) from {}", tee_dir.display());
811        }
812        "show" => {
813            let filename = args.get(1);
814            if filename.is_none() {
815                eprintln!("Usage: lean-ctx tee show <filename>");
816                std::process::exit(1);
817            }
818            let path = tee_dir.join(filename.unwrap());
819            match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
820                Ok(content) => print!("{content}"),
821                Err(e) => {
822                    eprintln!("Error reading {}: {e}", path.display());
823                    std::process::exit(1);
824                }
825            }
826        }
827        "last" => {
828            if !tee_dir.exists() {
829                println!("No tee logs found.");
830                return;
831            }
832            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
833                .ok()
834                .into_iter()
835                .flat_map(|d| d.filter_map(|e| e.ok()))
836                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
837                .collect();
838            entries.sort_by_key(|e| {
839                e.metadata()
840                    .and_then(|m| m.modified())
841                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
842            });
843            match entries.last() {
844                Some(entry) => {
845                    let path = entry.path();
846                    println!(
847                        "--- {} ---\n",
848                        path.file_name().unwrap_or_default().to_string_lossy()
849                    );
850                    match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
851                        Ok(content) => print!("{content}"),
852                        Err(e) => eprintln!("Error: {e}"),
853                    }
854                }
855                None => println!("No tee logs found."),
856            }
857        }
858        _ => {
859            eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
860            std::process::exit(1);
861        }
862    }
863}
864
865pub fn cmd_filter(args: &[String]) {
866    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
867    match action {
868        "list" | "ls" => match crate::core::filters::FilterEngine::load() {
869            Some(engine) => {
870                let rules = engine.list_rules();
871                println!("Loaded {} filter rule(s):\n", rules.len());
872                for rule in &rules {
873                    println!("{rule}");
874                }
875            }
876            None => {
877                println!("No custom filters found.");
878                println!("Create one: lean-ctx filter init");
879            }
880        },
881        "validate" => {
882            let path = args.get(1);
883            if path.is_none() {
884                eprintln!("Usage: lean-ctx filter validate <file.toml>");
885                std::process::exit(1);
886            }
887            match crate::core::filters::validate_filter_file(path.unwrap()) {
888                Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
889                Err(e) => {
890                    eprintln!("Validation failed: {e}");
891                    std::process::exit(1);
892                }
893            }
894        }
895        "init" => match crate::core::filters::create_example_filter() {
896            Ok(path) => {
897                println!("Created example filter: {path}");
898                println!("Edit it to add your custom compression rules.");
899            }
900            Err(e) => {
901                eprintln!("{e}");
902                std::process::exit(1);
903            }
904        },
905        _ => {
906            eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
907            std::process::exit(1);
908        }
909    }
910}
911
912fn quiet_enabled() -> bool {
913    matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
914}
915
916macro_rules! qprintln {
917    ($($t:tt)*) => {
918        if !quiet_enabled() {
919            println!($($t)*);
920        }
921    };
922}
923
924pub fn cmd_init(args: &[String]) {
925    let global = args.iter().any(|a| a == "--global" || a == "-g");
926    let dry_run = args.iter().any(|a| a == "--dry-run");
927
928    let agents: Vec<&str> = args
929        .windows(2)
930        .filter(|w| w[0] == "--agent")
931        .map(|w| w[1].as_str())
932        .collect();
933
934    if !agents.is_empty() {
935        for agent_name in &agents {
936            crate::hooks::install_agent_hook(agent_name, global);
937            if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
938                eprintln!("MCP config for '{agent_name}' not updated: {e}");
939            }
940        }
941        if !global {
942            crate::hooks::install_project_rules();
943        }
944        qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
945        return;
946    }
947
948    let shell_name = std::env::var("SHELL").unwrap_or_default();
949    let is_zsh = shell_name.contains("zsh");
950    let is_fish = shell_name.contains("fish");
951    let is_powershell = cfg!(windows) && shell_name.is_empty();
952
953    let binary = std::env::current_exe()
954        .map(|p| p.to_string_lossy().to_string())
955        .unwrap_or_else(|_| "lean-ctx".to_string());
956
957    if dry_run {
958        let rc = if is_powershell {
959            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
960        } else if is_fish {
961            "~/.config/fish/config.fish".to_string()
962        } else if is_zsh {
963            "~/.zshrc".to_string()
964        } else {
965            "~/.bashrc".to_string()
966        };
967        qprintln!("\nlean-ctx init --dry-run\n");
968        qprintln!("  Would modify:  {rc}");
969        qprintln!("  Would backup:  {rc}.lean-ctx.bak");
970        qprintln!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
971        qprintln!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
972        qprintln!("                 curl wget php composer (24 commands + k)");
973        qprintln!("  Would create:  ~/.lean-ctx/");
974        qprintln!("  Binary:        {binary}");
975        qprintln!("\n  Safety: aliases auto-fallback to original command if lean-ctx is removed.");
976        qprintln!("\n  Run without --dry-run to apply.");
977        return;
978    }
979
980    if is_powershell {
981        init_powershell(&binary);
982    } else {
983        let bash_binary = to_bash_compatible_path(&binary);
984        if is_fish {
985            init_fish(&bash_binary);
986        } else {
987            init_posix(is_zsh, &bash_binary);
988        }
989    }
990
991    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
992    if let Some(dir) = lean_dir {
993        if !dir.exists() {
994            let _ = std::fs::create_dir_all(&dir);
995            qprintln!("Created {}", dir.display());
996        }
997    }
998
999    let rc = if is_powershell {
1000        "$PROFILE"
1001    } else if is_fish {
1002        "config.fish"
1003    } else if is_zsh {
1004        ".zshrc"
1005    } else {
1006        ".bashrc"
1007    };
1008
1009    qprintln!("\nlean-ctx init complete (24 aliases installed)");
1010    qprintln!();
1011    qprintln!("  Disable temporarily:  lean-ctx-off");
1012    qprintln!("  Re-enable:            lean-ctx-on");
1013    qprintln!("  Check status:         lean-ctx-status");
1014    qprintln!("  Full uninstall:       lean-ctx uninstall");
1015    qprintln!("  Diagnose issues:      lean-ctx doctor");
1016    qprintln!("  Preview changes:      lean-ctx init --global --dry-run");
1017    qprintln!();
1018    if is_powershell {
1019        qprintln!("  Restart PowerShell or run: . {rc}");
1020    } else {
1021        qprintln!("  Restart your shell or run: source ~/{rc}");
1022    }
1023    qprintln!();
1024    qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1025    qprintln!("  Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1026    qprintln!("    crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1027    qprintln!("    pi, qwen, roo, sublime, trae, verdent, windsurf");
1028}
1029
1030pub fn cmd_init_quiet(args: &[String]) {
1031    std::env::set_var("LEAN_CTX_QUIET", "1");
1032    cmd_init(args);
1033    std::env::remove_var("LEAN_CTX_QUIET");
1034}
1035
1036fn backup_shell_config(path: &std::path::Path) {
1037    if !path.exists() {
1038        return;
1039    }
1040    let bak = path.with_extension("lean-ctx.bak");
1041    if std::fs::copy(path, &bak).is_ok() {
1042        qprintln!(
1043            "  Backup: {}",
1044            bak.file_name()
1045                .map(|n| format!("~/{}", n.to_string_lossy()))
1046                .unwrap_or_else(|| bak.display().to_string())
1047        );
1048    }
1049}
1050
1051pub fn init_powershell(binary: &str) {
1052    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
1053    let profile_path = match profile_dir {
1054        Some(dir) => {
1055            let _ = std::fs::create_dir_all(&dir);
1056            dir.join("Microsoft.PowerShell_profile.ps1")
1057        }
1058        None => {
1059            eprintln!("Could not resolve PowerShell profile directory");
1060            return;
1061        }
1062    };
1063
1064    let binary_escaped = binary.replace('\\', "\\\\");
1065    let functions = format!(
1066        r#"
1067# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1068if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED) {{
1069  $LeanCtxBin = "{binary_escaped}"
1070  function _lc {{
1071    if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) {{ & @args; return }}
1072    & $LeanCtxBin -c @args
1073    if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
1074      & @args
1075    }}
1076  }}
1077  function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
1078  if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
1079    function git {{ _lc git @args }}
1080    function cargo {{ _lc cargo @args }}
1081    function docker {{ _lc docker @args }}
1082    function kubectl {{ _lc kubectl @args }}
1083    function gh {{ _lc gh @args }}
1084    function pip {{ _lc pip @args }}
1085    function pip3 {{ _lc pip3 @args }}
1086    function ruff {{ _lc ruff @args }}
1087    function go {{ _lc go @args }}
1088    function curl {{ _lc curl @args }}
1089    function wget {{ _lc wget @args }}
1090    foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
1091      if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
1092        New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc $c @args")) -Force | Out-Null
1093      }}
1094    }}
1095  }}
1096}}
1097"#
1098    );
1099
1100    backup_shell_config(&profile_path);
1101
1102    if let Ok(existing) = std::fs::read_to_string(&profile_path) {
1103        if existing.contains("lean-ctx shell hook") {
1104            let cleaned = remove_lean_ctx_block_ps(&existing);
1105            match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
1106                Ok(()) => {
1107                    qprintln!("Updated lean-ctx functions in {}", profile_path.display());
1108                    qprintln!("  Binary: {binary}");
1109                    return;
1110                }
1111                Err(e) => {
1112                    eprintln!("Error updating {}: {e}", profile_path.display());
1113                    return;
1114                }
1115            }
1116        }
1117    }
1118
1119    match std::fs::OpenOptions::new()
1120        .append(true)
1121        .create(true)
1122        .open(&profile_path)
1123    {
1124        Ok(mut f) => {
1125            use std::io::Write;
1126            let _ = f.write_all(functions.as_bytes());
1127            qprintln!("Added lean-ctx functions to {}", profile_path.display());
1128            qprintln!("  Binary: {binary}");
1129        }
1130        Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
1131    }
1132}
1133
1134fn remove_lean_ctx_block_ps(content: &str) -> String {
1135    let mut result = String::new();
1136    let mut in_block = false;
1137    let mut brace_depth = 0i32;
1138
1139    for line in content.lines() {
1140        if line.contains("lean-ctx shell hook") {
1141            in_block = true;
1142            continue;
1143        }
1144        if in_block {
1145            brace_depth += line.matches('{').count() as i32;
1146            brace_depth -= line.matches('}').count() as i32;
1147            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
1148                if line.trim() == "}" {
1149                    in_block = false;
1150                    brace_depth = 0;
1151                }
1152                continue;
1153            }
1154            continue;
1155        }
1156        result.push_str(line);
1157        result.push('\n');
1158    }
1159    result
1160}
1161
1162pub fn init_fish(binary: &str) {
1163    let config = dirs::home_dir()
1164        .map(|h| h.join(".config/fish/config.fish"))
1165        .unwrap_or_default();
1166
1167    let alias_list = crate::rewrite_registry::shell_alias_list();
1168    let aliases = format!(
1169        "\n# lean-ctx shell hook — smart shell mode (track-by-default)\n\
1170        set -g _lean_ctx_cmds {alias_list}\n\
1171        \n\
1172        function _lc\n\
1173        \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1174        \t\tcommand $argv\n\
1175        \t\treturn\n\
1176        \tend\n\
1177        \t'{binary}' -t $argv\n\
1178        \tset -l _lc_rc $status\n\
1179        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1180        \t\tcommand $argv\n\
1181        \telse\n\
1182        \t\treturn $_lc_rc\n\
1183        \tend\n\
1184        end\n\
1185        \n\
1186        function _lc_compress\n\
1187        \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1188        \t\tcommand $argv\n\
1189        \t\treturn\n\
1190        \tend\n\
1191        \t'{binary}' -c $argv\n\
1192        \tset -l _lc_rc $status\n\
1193        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1194        \t\tcommand $argv\n\
1195        \telse\n\
1196        \t\treturn $_lc_rc\n\
1197        \tend\n\
1198        end\n\
1199        \n\
1200        function lean-ctx-on\n\
1201        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1202        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
1203        \tend\n\
1204        \talias k '_lc kubectl'\n\
1205        \tset -gx LEAN_CTX_ENABLED 1\n\
1206        \techo 'lean-ctx: ON (track mode — full output, stats recorded)'\n\
1207        end\n\
1208        \n\
1209        function lean-ctx-off\n\
1210        \tfor _lc_cmd in $_lean_ctx_cmds\n\
1211        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
1212        \tend\n\
1213        \tfunctions --erase k 2>/dev/null; true\n\
1214        \tset -e LEAN_CTX_ENABLED\n\
1215        \techo 'lean-ctx: OFF'\n\
1216        end\n\
1217        \n\
1218        function lean-ctx-mode\n\
1219        \tswitch $argv[1]\n\
1220        \t\tcase compress\n\
1221        \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
1222        \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
1223        \t\t\t\tend\n\
1224        \t\t\talias k '_lc_compress kubectl'\n\
1225        \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
1226        \t\t\techo 'lean-ctx: COMPRESS mode (all output compressed)'\n\
1227        \t\tcase track\n\
1228        \t\t\tlean-ctx-on\n\
1229        \t\tcase off\n\
1230        \t\t\tlean-ctx-off\n\
1231        \t\tcase '*'\n\
1232        \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
1233        \t\t\techo '  track    — Full output, stats recorded (default)'\n\
1234        \t\t\techo '  compress — Compressed output for all commands'\n\
1235        \t\t\techo '  off      — No aliases, raw shell'\n\
1236        \tend\n\
1237        end\n\
1238        \n\
1239        function lean-ctx-raw\n\
1240        \tset -lx LEAN_CTX_RAW 1\n\
1241        \tcommand $argv\n\
1242        end\n\
1243        \n\
1244        function lean-ctx-status\n\
1245        \tif set -q LEAN_CTX_DISABLED\n\
1246        \t\techo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
1247        \telse if set -q LEAN_CTX_ENABLED\n\
1248        \t\techo 'lean-ctx: ON'\n\
1249        \telse\n\
1250        \t\techo 'lean-ctx: OFF'\n\
1251        \tend\n\
1252        end\n\
1253        \n\
1254        if not set -q LEAN_CTX_ACTIVE; and not set -q LEAN_CTX_DISABLED; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
1255        \tif command -q lean-ctx\n\
1256        \t\tlean-ctx-on\n\
1257        \tend\n\
1258        end\n\
1259        # lean-ctx shell hook — end\n"
1260    );
1261
1262    backup_shell_config(&config);
1263
1264    if let Ok(existing) = std::fs::read_to_string(&config) {
1265        if existing.contains("lean-ctx shell hook") {
1266            let cleaned = remove_lean_ctx_block(&existing);
1267            match std::fs::write(&config, format!("{cleaned}{aliases}")) {
1268                Ok(()) => {
1269                    qprintln!("Updated lean-ctx aliases in {}", config.display());
1270                    qprintln!("  Binary: {binary}");
1271                    return;
1272                }
1273                Err(e) => {
1274                    eprintln!("Error updating {}: {e}", config.display());
1275                    return;
1276                }
1277            }
1278        }
1279    }
1280
1281    match std::fs::OpenOptions::new()
1282        .append(true)
1283        .create(true)
1284        .open(&config)
1285    {
1286        Ok(mut f) => {
1287            use std::io::Write;
1288            let _ = f.write_all(aliases.as_bytes());
1289            qprintln!("Added lean-ctx aliases to {}", config.display());
1290            qprintln!("  Binary: {binary}");
1291        }
1292        Err(e) => eprintln!("Error writing {}: {e}", config.display()),
1293    }
1294}
1295
1296pub fn init_posix(is_zsh: bool, binary: &str) {
1297    let rc_file = if is_zsh {
1298        dirs::home_dir()
1299            .map(|h| h.join(".zshrc"))
1300            .unwrap_or_default()
1301    } else {
1302        dirs::home_dir()
1303            .map(|h| h.join(".bashrc"))
1304            .unwrap_or_default()
1305    };
1306
1307    let alias_list = crate::rewrite_registry::shell_alias_list();
1308    let aliases = format!(
1309        r#"
1310# lean-ctx shell hook — smart shell mode (track-by-default)
1311_lean_ctx_cmds=({alias_list})
1312
1313_lc() {{
1314    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1315        command "$@"
1316        return
1317    fi
1318    '{binary}' -t "$@"
1319    local _lc_rc=$?
1320    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1321        command "$@"
1322    else
1323        return "$_lc_rc"
1324    fi
1325}}
1326
1327_lc_compress() {{
1328    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1329        command "$@"
1330        return
1331    fi
1332    '{binary}' -c "$@"
1333    local _lc_rc=$?
1334    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1335        command "$@"
1336    else
1337        return "$_lc_rc"
1338    fi
1339}}
1340
1341lean-ctx-on() {{
1342    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1343        # shellcheck disable=SC2139
1344        alias "$_lc_cmd"='_lc '"$_lc_cmd"
1345    done
1346    alias k='_lc kubectl'
1347    export LEAN_CTX_ENABLED=1
1348    echo "lean-ctx: ON (track mode — full output, stats recorded)"
1349}}
1350
1351lean-ctx-off() {{
1352    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1353        unalias "$_lc_cmd" 2>/dev/null || true
1354    done
1355    unalias k 2>/dev/null || true
1356    unset LEAN_CTX_ENABLED
1357    echo "lean-ctx: OFF"
1358}}
1359
1360lean-ctx-mode() {{
1361    case "${{1:-}}" in
1362        compress)
1363            for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1364                # shellcheck disable=SC2139
1365                alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
1366            done
1367            alias k='_lc_compress kubectl'
1368            export LEAN_CTX_ENABLED=1
1369            echo "lean-ctx: COMPRESS mode (all output compressed)"
1370            ;;
1371        track)
1372            lean-ctx-on
1373            ;;
1374        off)
1375            lean-ctx-off
1376            ;;
1377        *)
1378            echo "Usage: lean-ctx-mode <track|compress|off>"
1379            echo "  track    — Full output, stats recorded (default)"
1380            echo "  compress — Compressed output for all commands"
1381            echo "  off      — No aliases, raw shell"
1382            ;;
1383    esac
1384}}
1385
1386lean-ctx-raw() {{
1387    LEAN_CTX_RAW=1 command "$@"
1388}}
1389
1390lean-ctx-status() {{
1391    if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1392        echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
1393    elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1394        echo "lean-ctx: ON"
1395    else
1396        echo "lean-ctx: OFF"
1397    fi
1398}}
1399
1400if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1401    command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
1402fi
1403# lean-ctx shell hook — end
1404"#
1405    );
1406
1407    backup_shell_config(&rc_file);
1408
1409    if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1410        if existing.contains("lean-ctx shell hook") {
1411            let cleaned = remove_lean_ctx_block(&existing);
1412            match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1413                Ok(()) => {
1414                    qprintln!("Updated lean-ctx aliases in {}", rc_file.display());
1415                    qprintln!("  Binary: {binary}");
1416                    return;
1417                }
1418                Err(e) => {
1419                    eprintln!("Error updating {}: {e}", rc_file.display());
1420                    return;
1421                }
1422            }
1423        }
1424    }
1425
1426    match std::fs::OpenOptions::new()
1427        .append(true)
1428        .create(true)
1429        .open(&rc_file)
1430    {
1431        Ok(mut f) => {
1432            use std::io::Write;
1433            let _ = f.write_all(aliases.as_bytes());
1434            qprintln!("Added lean-ctx aliases to {}", rc_file.display());
1435            qprintln!("  Binary: {binary}");
1436        }
1437        Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1438    }
1439
1440    write_env_sh_for_containers(&aliases);
1441    print_docker_env_hints(is_zsh);
1442}
1443
1444fn write_env_sh_for_containers(aliases: &str) {
1445    let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
1446        Ok(d) => d.join("env.sh"),
1447        Err(_) => return,
1448    };
1449    if let Some(parent) = env_sh.parent() {
1450        let _ = std::fs::create_dir_all(parent);
1451    }
1452    let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
1453    let mut content = sanitized_aliases;
1454    content.push_str(
1455        r#"
1456
1457# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
1458if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
1459  if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
1460    LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
1461  fi
1462fi
1463"#,
1464    );
1465    match std::fs::write(&env_sh, content) {
1466        Ok(()) => qprintln!("  env.sh: {}", env_sh.display()),
1467        Err(e) => eprintln!("  Warning: could not write {}: {e}", env_sh.display()),
1468    }
1469}
1470
1471fn print_docker_env_hints(is_zsh: bool) {
1472    if is_zsh || !crate::shell::is_container() {
1473        return;
1474    }
1475    let env_sh = crate::core::data_dir::lean_ctx_data_dir()
1476        .map(|d| d.join("env.sh").to_string_lossy().to_string())
1477        .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
1478
1479    let has_bash_env = std::env::var("BASH_ENV").is_ok();
1480    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
1481
1482    if has_bash_env && has_claude_env {
1483        return;
1484    }
1485
1486    eprintln!();
1487    eprintln!("  \x1b[33m⚠  Docker detected — environment hints:\x1b[0m");
1488
1489    if !has_bash_env {
1490        eprintln!("  For generic bash -c usage (non-interactive shells):");
1491        eprintln!("    \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
1492    }
1493    if !has_claude_env {
1494        eprintln!("  For Claude Code (sources before each command):");
1495        eprintln!("    \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
1496    }
1497    eprintln!();
1498}
1499
1500fn remove_lean_ctx_block(content: &str) -> String {
1501    // New format uses explicit end marker; old format ends at first top-level `fi`/`end`.
1502    if content.contains("# lean-ctx shell hook — end") {
1503        return remove_lean_ctx_block_by_marker(content);
1504    }
1505    remove_lean_ctx_block_legacy(content)
1506}
1507
1508fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1509    let mut result = String::new();
1510    let mut in_block = false;
1511
1512    for line in content.lines() {
1513        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1514            in_block = true;
1515            continue;
1516        }
1517        if in_block {
1518            if line.trim() == "# lean-ctx shell hook — end" {
1519                in_block = false;
1520            }
1521            continue;
1522        }
1523        result.push_str(line);
1524        result.push('\n');
1525    }
1526    result
1527}
1528
1529fn remove_lean_ctx_block_legacy(content: &str) -> String {
1530    let mut result = String::new();
1531    let mut in_block = false;
1532
1533    for line in content.lines() {
1534        if line.contains("lean-ctx shell hook") {
1535            in_block = true;
1536            continue;
1537        }
1538        if in_block {
1539            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1540                if line.trim() == "fi" || line.trim() == "end" {
1541                    in_block = false;
1542                }
1543                continue;
1544            }
1545            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1546                in_block = false;
1547                result.push_str(line);
1548                result.push('\n');
1549            }
1550            continue;
1551        }
1552        result.push_str(line);
1553        result.push('\n');
1554    }
1555    result
1556}
1557
1558pub fn load_shell_history_pub() -> Vec<String> {
1559    load_shell_history()
1560}
1561
1562fn load_shell_history() -> Vec<String> {
1563    let shell = std::env::var("SHELL").unwrap_or_default();
1564    let home = match dirs::home_dir() {
1565        Some(h) => h,
1566        None => return Vec::new(),
1567    };
1568
1569    let history_file = if shell.contains("zsh") {
1570        home.join(".zsh_history")
1571    } else if shell.contains("fish") {
1572        home.join(".local/share/fish/fish_history")
1573    } else if cfg!(windows) && shell.is_empty() {
1574        home.join("AppData")
1575            .join("Roaming")
1576            .join("Microsoft")
1577            .join("Windows")
1578            .join("PowerShell")
1579            .join("PSReadLine")
1580            .join("ConsoleHost_history.txt")
1581    } else {
1582        home.join(".bash_history")
1583    };
1584
1585    match std::fs::read_to_string(&history_file) {
1586        Ok(content) => content
1587            .lines()
1588            .filter_map(|l| {
1589                let trimmed = l.trim();
1590                if trimmed.starts_with(':') {
1591                    trimmed.split(';').nth(1).map(|s| s.to_string())
1592                } else {
1593                    Some(trimmed.to_string())
1594                }
1595            })
1596            .filter(|l| !l.is_empty())
1597            .collect(),
1598        Err(_) => Vec::new(),
1599    }
1600}
1601
1602fn print_savings(original: usize, sent: usize) {
1603    let saved = original.saturating_sub(sent);
1604    if original > 0 && saved > 0 {
1605        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1606        println!("[{saved} tok saved ({pct}%)]");
1607    }
1608}
1609
1610pub fn cmd_theme(args: &[String]) {
1611    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1612    let r = theme::rst();
1613    let b = theme::bold();
1614    let d = theme::dim();
1615
1616    match sub {
1617        "list" => {
1618            let cfg = config::Config::load();
1619            let active = cfg.theme.as_str();
1620            println!();
1621            println!("  {b}Available themes:{r}");
1622            println!("  {ln}", ln = "─".repeat(40));
1623            for name in theme::PRESET_NAMES {
1624                let marker = if *name == active { " ◀ active" } else { "" };
1625                let t = theme::from_preset(name).unwrap();
1626                let preview = format!(
1627                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1628                    p = t.primary.fg(),
1629                    s = t.secondary.fg(),
1630                    a = t.accent.fg(),
1631                    sc = t.success.fg(),
1632                    w = t.warning.fg(),
1633                );
1634                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1635            }
1636            if let Some(path) = theme::theme_file_path() {
1637                if path.exists() {
1638                    let custom = theme::load_theme("_custom_");
1639                    let preview = format!(
1640                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1641                        p = custom.primary.fg(),
1642                        s = custom.secondary.fg(),
1643                        a = custom.accent.fg(),
1644                        sc = custom.success.fg(),
1645                        w = custom.warning.fg(),
1646                    );
1647                    let marker = if active == "custom" {
1648                        " ◀ active"
1649                    } else {
1650                        ""
1651                    };
1652                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1653                }
1654            }
1655            println!();
1656            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1657            println!();
1658        }
1659        "set" => {
1660            if args.len() < 2 {
1661                eprintln!("Usage: lean-ctx theme set <name>");
1662                std::process::exit(1);
1663            }
1664            let name = &args[1];
1665            if theme::from_preset(name).is_none() && name != "custom" {
1666                eprintln!(
1667                    "Unknown theme '{name}'. Available: {}",
1668                    theme::PRESET_NAMES.join(", ")
1669                );
1670                std::process::exit(1);
1671            }
1672            let mut cfg = config::Config::load();
1673            cfg.theme = name.to_string();
1674            match cfg.save() {
1675                Ok(()) => {
1676                    let t = theme::load_theme(name);
1677                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1678                    let preview = t.gradient_bar(0.75, 30);
1679                    println!("  {preview}");
1680                }
1681                Err(e) => eprintln!("Error: {e}"),
1682            }
1683        }
1684        "export" => {
1685            let cfg = config::Config::load();
1686            let t = theme::load_theme(&cfg.theme);
1687            println!("{}", t.to_toml());
1688        }
1689        "import" => {
1690            if args.len() < 2 {
1691                eprintln!("Usage: lean-ctx theme import <path>");
1692                std::process::exit(1);
1693            }
1694            let path = std::path::Path::new(&args[1]);
1695            if !path.exists() {
1696                eprintln!("File not found: {}", args[1]);
1697                std::process::exit(1);
1698            }
1699            match std::fs::read_to_string(path) {
1700                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1701                    Ok(imported) => match theme::save_theme(&imported) {
1702                        Ok(()) => {
1703                            let mut cfg = config::Config::load();
1704                            cfg.theme = "custom".to_string();
1705                            let _ = cfg.save();
1706                            println!(
1707                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1708                                sc = imported.success.fg(),
1709                                name = imported.name,
1710                            );
1711                            println!("  Config updated: theme = custom");
1712                        }
1713                        Err(e) => eprintln!("Error saving theme: {e}"),
1714                    },
1715                    Err(e) => eprintln!("Invalid theme file: {e}"),
1716                },
1717                Err(e) => eprintln!("Error reading file: {e}"),
1718            }
1719        }
1720        "preview" => {
1721            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1722            let t = match theme::from_preset(name) {
1723                Some(t) => t,
1724                None => {
1725                    eprintln!("Unknown theme: {name}");
1726                    std::process::exit(1);
1727                }
1728            };
1729            println!();
1730            println!(
1731                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1732                icon = t.header_icon(),
1733                title = t.brand_title(),
1734            );
1735            println!("  {ln}", ln = t.border_line(50));
1736            println!();
1737            println!(
1738                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1739                sc = t.success.fg(),
1740                sec = t.secondary.fg(),
1741                wrn = t.warning.fg(),
1742                acc = t.accent.fg(),
1743            );
1744            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1745            println!();
1746            println!(
1747                "  {b}{txt}Gradient Bar{r}      {bar}",
1748                txt = t.text.fg(),
1749                bar = t.gradient_bar(0.85, 30),
1750            );
1751            println!(
1752                "  {b}{txt}Sparkline{r}         {spark}",
1753                txt = t.text.fg(),
1754                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1755            );
1756            println!();
1757            println!("  {top}", top = t.box_top(50));
1758            println!(
1759                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1760                side = t.box_side(),
1761                side_r = t.box_side(),
1762                txt = t.text.fg(),
1763            );
1764            println!("  {bot}", bot = t.box_bottom(50));
1765            println!();
1766        }
1767        _ => {
1768            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1769            std::process::exit(1);
1770        }
1771    }
1772}
1773
1774#[cfg(test)]
1775mod tests {
1776    use super::*;
1777    use tempfile;
1778
1779    #[test]
1780    fn test_remove_lean_ctx_block_posix() {
1781        let input = r#"# existing config
1782export PATH="$HOME/bin:$PATH"
1783
1784# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1785if [ -z "$LEAN_CTX_ACTIVE" ]; then
1786alias git='lean-ctx -c git'
1787alias npm='lean-ctx -c npm'
1788fi
1789
1790# other stuff
1791export EDITOR=vim
1792"#;
1793        let result = remove_lean_ctx_block(input);
1794        assert!(!result.contains("lean-ctx"), "block should be removed");
1795        assert!(result.contains("export PATH"), "other content preserved");
1796        assert!(
1797            result.contains("export EDITOR"),
1798            "trailing content preserved"
1799        );
1800    }
1801
1802    #[test]
1803    fn test_remove_lean_ctx_block_fish() {
1804        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";
1805        let result = remove_lean_ctx_block(input);
1806        assert!(!result.contains("lean-ctx"), "block should be removed");
1807        assert!(result.contains("set -x FOO"), "other content preserved");
1808        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1809    }
1810
1811    #[test]
1812    fn test_remove_lean_ctx_block_ps() {
1813        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";
1814        let result = remove_lean_ctx_block_ps(input);
1815        assert!(
1816            !result.contains("lean-ctx shell hook"),
1817            "block should be removed"
1818        );
1819        assert!(result.contains("$env:FOO"), "other content preserved");
1820        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1821    }
1822
1823    #[test]
1824    fn test_remove_lean_ctx_block_ps_nested() {
1825        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";
1826        let result = remove_lean_ctx_block_ps(input);
1827        assert!(
1828            !result.contains("lean-ctx shell hook"),
1829            "block should be removed"
1830        );
1831        assert!(!result.contains("_lc"), "function should be removed");
1832        assert!(result.contains("$env:FOO"), "other content preserved");
1833        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1834    }
1835
1836    #[test]
1837    fn test_remove_block_no_lean_ctx() {
1838        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1839        let result = remove_lean_ctx_block(input);
1840        assert!(result.contains("export PATH"), "content unchanged");
1841    }
1842
1843    #[test]
1844    fn test_bash_hook_contains_pipe_guard() {
1845        let binary = "/usr/local/bin/lean-ctx";
1846        let hook = format!(
1847            r#"_lc() {{
1848    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1849        command "$@"
1850        return
1851    fi
1852    '{binary}' -t "$@"
1853}}"#
1854        );
1855        assert!(
1856            hook.contains("! -t 1"),
1857            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1858        );
1859        assert!(
1860            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1861            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1862        );
1863    }
1864
1865    #[test]
1866    fn test_lc_uses_track_mode_by_default() {
1867        let binary = "/usr/local/bin/lean-ctx";
1868        let alias_list = crate::rewrite_registry::shell_alias_list();
1869        let aliases = format!(
1870            r#"_lc() {{
1871    '{binary}' -t "$@"
1872}}
1873_lc_compress() {{
1874    '{binary}' -c "$@"
1875}}"#
1876        );
1877        assert!(
1878            aliases.contains("-t \"$@\""),
1879            "_lc must use -t (track mode) by default"
1880        );
1881        assert!(
1882            aliases.contains("-c \"$@\""),
1883            "_lc_compress must use -c (compress mode)"
1884        );
1885        let _ = alias_list;
1886    }
1887
1888    #[test]
1889    fn test_posix_shell_has_lean_ctx_mode() {
1890        let alias_list = crate::rewrite_registry::shell_alias_list();
1891        let aliases = r#"
1892lean-ctx-mode() {{
1893    case "${{1:-}}" in
1894        compress) echo compress ;;
1895        track) echo track ;;
1896        off) echo off ;;
1897    esac
1898}}
1899"#
1900        .to_string();
1901        assert!(
1902            aliases.contains("lean-ctx-mode()"),
1903            "lean-ctx-mode function must exist"
1904        );
1905        assert!(
1906            aliases.contains("compress"),
1907            "compress mode must be available"
1908        );
1909        assert!(aliases.contains("track"), "track mode must be available");
1910        let _ = alias_list;
1911    }
1912
1913    #[test]
1914    fn test_fish_hook_contains_pipe_guard() {
1915        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";
1916        assert!(
1917            hook.contains("isatty stdout"),
1918            "fish hook must contain pipe guard (isatty stdout)"
1919        );
1920    }
1921
1922    #[test]
1923    fn test_powershell_hook_contains_pipe_guard() {
1924        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1925        assert!(
1926            hook.contains("IsOutputRedirected"),
1927            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1928        );
1929    }
1930
1931    #[test]
1932    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1933        let input = r#"# existing config
1934export PATH="$HOME/bin:$PATH"
1935
1936# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1937_lean_ctx_cmds=(git npm pnpm)
1938
1939lean-ctx-on() {
1940    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1941        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1942    done
1943    export LEAN_CTX_ENABLED=1
1944    echo "lean-ctx: ON"
1945}
1946
1947lean-ctx-off() {
1948    unset LEAN_CTX_ENABLED
1949    echo "lean-ctx: OFF"
1950}
1951
1952if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1953    lean-ctx-on
1954fi
1955# lean-ctx shell hook — end
1956
1957# other stuff
1958export EDITOR=vim
1959"#;
1960        let result = remove_lean_ctx_block(input);
1961        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1962        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1963        assert!(result.contains("export PATH"), "other content preserved");
1964        assert!(
1965            result.contains("export EDITOR"),
1966            "trailing content preserved"
1967        );
1968    }
1969
1970    #[test]
1971    fn env_sh_for_containers_includes_self_heal() {
1972        let _g = crate::core::data_dir::test_env_lock();
1973        let tmp = tempfile::tempdir().expect("tempdir");
1974        let data_dir = tmp.path().join("data");
1975        std::fs::create_dir_all(&data_dir).expect("mkdir data");
1976        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1977
1978        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1979        let env_sh = data_dir.join("env.sh");
1980        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1981        assert!(content.contains("lean-ctx docker self-heal"));
1982        assert!(content.contains("claude mcp list"));
1983        assert!(content.contains("lean-ctx init --agent claude"));
1984
1985        std::env::remove_var("LEAN_CTX_DATA_DIR");
1986    }
1987}