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