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