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