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        println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
748        return;
749    }
750
751    let shell_name = std::env::var("SHELL").unwrap_or_default();
752    let is_zsh = shell_name.contains("zsh");
753    let is_fish = shell_name.contains("fish");
754    let is_powershell = cfg!(windows) && shell_name.is_empty();
755
756    let binary = std::env::current_exe()
757        .map(|p| p.to_string_lossy().to_string())
758        .unwrap_or_else(|_| "lean-ctx".to_string());
759
760    if dry_run {
761        let rc = if is_powershell {
762            "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
763        } else if is_fish {
764            "~/.config/fish/config.fish".to_string()
765        } else if is_zsh {
766            "~/.zshrc".to_string()
767        } else {
768            "~/.bashrc".to_string()
769        };
770        println!("\nlean-ctx init --dry-run\n");
771        println!("  Would modify:  {rc}");
772        println!("  Would backup:  {rc}.lean-ctx.bak");
773        println!("  Would alias:   git npm pnpm yarn cargo docker docker-compose kubectl");
774        println!("                 gh pip pip3 ruff go golangci-lint eslint prettier tsc");
775        println!("                 ls find grep curl wget (22 commands + k)");
776        println!("  Would create:  ~/.lean-ctx/");
777        println!("  Binary:        {binary}");
778        println!("\n  Safety: aliases auto-fallback to original command if lean-ctx is removed.");
779        println!("\n  Run without --dry-run to apply.");
780        return;
781    }
782
783    if is_powershell {
784        init_powershell(&binary);
785    } else {
786        let bash_binary = to_bash_compatible_path(&binary);
787        if is_fish {
788            init_fish(&bash_binary);
789        } else {
790            init_posix(is_zsh, &bash_binary);
791        }
792    }
793
794    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
795    if let Some(dir) = lean_dir {
796        if !dir.exists() {
797            let _ = std::fs::create_dir_all(&dir);
798            println!("Created {}", dir.display());
799        }
800    }
801
802    let rc = if is_powershell {
803        "$PROFILE"
804    } else if is_fish {
805        "config.fish"
806    } else if is_zsh {
807        ".zshrc"
808    } else {
809        ".bashrc"
810    };
811
812    println!("\nlean-ctx init complete (22 aliases installed)");
813    println!();
814    println!("  Disable temporarily:  lean-ctx-off");
815    println!("  Re-enable:            lean-ctx-on");
816    println!("  Check status:         lean-ctx-status");
817    println!("  Full uninstall:       lean-ctx uninstall");
818    println!("  Diagnose issues:      lean-ctx doctor");
819    println!("  Preview changes:      lean-ctx init --global --dry-run");
820    println!();
821    if is_powershell {
822        println!("  Restart PowerShell or run: . {rc}");
823    } else {
824        println!("  Restart your shell or run: source ~/{rc}");
825    }
826    println!();
827    println!("For AI tool integration: lean-ctx init --agent <tool>");
828    println!("  Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, pi");
829}
830
831fn backup_shell_config(path: &std::path::Path) {
832    if !path.exists() {
833        return;
834    }
835    let bak = path.with_extension("lean-ctx.bak");
836    if std::fs::copy(path, &bak).is_ok() {
837        println!(
838            "  Backup: {}",
839            bak.file_name()
840                .map(|n| format!("~/{}", n.to_string_lossy()))
841                .unwrap_or_else(|| bak.display().to_string())
842        );
843    }
844}
845
846fn init_powershell(binary: &str) {
847    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
848    let profile_path = match profile_dir {
849        Some(dir) => {
850            let _ = std::fs::create_dir_all(&dir);
851            dir.join("Microsoft.PowerShell_profile.ps1")
852        }
853        None => {
854            eprintln!("Could not resolve PowerShell profile directory");
855            return;
856        }
857    };
858
859    let binary_escaped = binary.replace('\\', "\\\\");
860    let functions = format!(
861        r#"
862# lean-ctx shell hook — transparent CLI compression (90+ patterns)
863if (-not $env:LEAN_CTX_ACTIVE) {{
864  $LeanCtxBin = "{binary_escaped}"
865  function _lc {{
866    & $LeanCtxBin -c "$($args -join ' ')"
867    if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
868      $cmd = $args[0]; $rest = $args[1..($args.Length)]
869      & $cmd @rest
870    }}
871  }}
872  if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
873    function git {{ _lc git @args }}
874    function npm {{ _lc npm.cmd @args }}
875    function pnpm {{ _lc pnpm.cmd @args }}
876    function yarn {{ _lc yarn.cmd @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 eslint {{ _lc eslint.cmd @args }}
886    function prettier {{ _lc prettier.cmd @args }}
887    function tsc {{ _lc tsc.cmd @args }}
888    function curl {{ _lc curl @args }}
889    function wget {{ _lc wget @args }}
890  }}
891}}
892"#
893    );
894
895    backup_shell_config(&profile_path);
896
897    if let Ok(existing) = std::fs::read_to_string(&profile_path) {
898        if existing.contains("lean-ctx shell hook") {
899            let cleaned = remove_lean_ctx_block_ps(&existing);
900            match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
901                Ok(()) => {
902                    println!("Updated lean-ctx functions in {}", profile_path.display());
903                    println!("  Binary: {binary}");
904                    return;
905                }
906                Err(e) => {
907                    eprintln!("Error updating {}: {e}", profile_path.display());
908                    return;
909                }
910            }
911        }
912    }
913
914    match std::fs::OpenOptions::new()
915        .append(true)
916        .create(true)
917        .open(&profile_path)
918    {
919        Ok(mut f) => {
920            use std::io::Write;
921            let _ = f.write_all(functions.as_bytes());
922            println!("Added lean-ctx functions to {}", profile_path.display());
923            println!("  Binary: {binary}");
924        }
925        Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
926    }
927}
928
929fn remove_lean_ctx_block_ps(content: &str) -> String {
930    let mut result = String::new();
931    let mut in_block = false;
932    let mut brace_depth = 0i32;
933
934    for line in content.lines() {
935        if line.contains("lean-ctx shell hook") {
936            in_block = true;
937            continue;
938        }
939        if in_block {
940            brace_depth += line.matches('{').count() as i32;
941            brace_depth -= line.matches('}').count() as i32;
942            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
943                if line.trim() == "}" {
944                    in_block = false;
945                    brace_depth = 0;
946                }
947                continue;
948            }
949            continue;
950        }
951        result.push_str(line);
952        result.push('\n');
953    }
954    result
955}
956
957fn init_fish(binary: &str) {
958    let config = dirs::home_dir()
959        .map(|h| h.join(".config/fish/config.fish"))
960        .unwrap_or_default();
961
962    let aliases = format!(
963        "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
964        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\
965        \n\
966        function _lc\n\
967        \t'{binary}' -c \"$argv\"\n\
968        \tset -l _lc_rc $status\n\
969        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
970        \t\tcommand $argv\n\
971        \telse\n\
972        \t\treturn $_lc_rc\n\
973        \tend\n\
974        end\n\
975        \n\
976        function lean-ctx-on\n\
977        \tfor _lc_cmd in $_lean_ctx_cmds\n\
978        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
979        \tend\n\
980        \talias k '_lc kubectl'\n\
981        \tset -gx LEAN_CTX_ENABLED 1\n\
982        \techo 'lean-ctx: ON'\n\
983        end\n\
984        \n\
985        function lean-ctx-off\n\
986        \tfor _lc_cmd in $_lean_ctx_cmds\n\
987        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
988        \tend\n\
989        \tfunctions --erase k 2>/dev/null; true\n\
990        \tset -e LEAN_CTX_ENABLED\n\
991        \techo 'lean-ctx: OFF'\n\
992        end\n\
993        \n\
994        function lean-ctx-status\n\
995        \tif set -q LEAN_CTX_ENABLED\n\
996        \t\techo 'lean-ctx: ON'\n\
997        \telse\n\
998        \t\techo 'lean-ctx: OFF'\n\
999        \tend\n\
1000        end\n\
1001        \n\
1002        if not set -q LEAN_CTX_ACTIVE; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
1003        \tif command -q lean-ctx\n\
1004        \t\tlean-ctx-on\n\
1005        \tend\n\
1006        end\n\
1007        # lean-ctx shell hook — end\n"
1008    );
1009
1010    backup_shell_config(&config);
1011
1012    if let Ok(existing) = std::fs::read_to_string(&config) {
1013        if existing.contains("lean-ctx shell hook") {
1014            let cleaned = remove_lean_ctx_block(&existing);
1015            match std::fs::write(&config, format!("{cleaned}{aliases}")) {
1016                Ok(()) => {
1017                    println!("Updated lean-ctx aliases in {}", config.display());
1018                    println!("  Binary: {binary}");
1019                    return;
1020                }
1021                Err(e) => {
1022                    eprintln!("Error updating {}: {e}", config.display());
1023                    return;
1024                }
1025            }
1026        }
1027    }
1028
1029    match std::fs::OpenOptions::new()
1030        .append(true)
1031        .create(true)
1032        .open(&config)
1033    {
1034        Ok(mut f) => {
1035            use std::io::Write;
1036            let _ = f.write_all(aliases.as_bytes());
1037            println!("Added lean-ctx aliases to {}", config.display());
1038            println!("  Binary: {binary}");
1039        }
1040        Err(e) => eprintln!("Error writing {}: {e}", config.display()),
1041    }
1042}
1043
1044fn init_posix(is_zsh: bool, binary: &str) {
1045    let rc_file = if is_zsh {
1046        dirs::home_dir()
1047            .map(|h| h.join(".zshrc"))
1048            .unwrap_or_default()
1049    } else {
1050        dirs::home_dir()
1051            .map(|h| h.join(".bashrc"))
1052            .unwrap_or_default()
1053    };
1054
1055    let aliases = format!(
1056        r#"
1057# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1058_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)
1059
1060_lc() {{
1061    '{binary}' -c "$*"
1062    local _lc_rc=$?
1063    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1064        command "$@"
1065    else
1066        return "$_lc_rc"
1067    fi
1068}}
1069
1070lean-ctx-on() {{
1071    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1072        # shellcheck disable=SC2139
1073        alias "$_lc_cmd"='_lc '"$_lc_cmd"
1074    done
1075    alias k='_lc kubectl'
1076    export LEAN_CTX_ENABLED=1
1077    echo "lean-ctx: ON"
1078}}
1079
1080lean-ctx-off() {{
1081    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1082        unalias "$_lc_cmd" 2>/dev/null || true
1083    done
1084    unalias k 2>/dev/null || true
1085    unset LEAN_CTX_ENABLED
1086    echo "lean-ctx: OFF"
1087}}
1088
1089lean-ctx-status() {{
1090    if [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1091        echo "lean-ctx: ON"
1092    else
1093        echo "lean-ctx: OFF"
1094    fi
1095}}
1096
1097if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1098    command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
1099fi
1100# lean-ctx shell hook — end
1101"#
1102    );
1103
1104    backup_shell_config(&rc_file);
1105
1106    if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1107        if existing.contains("lean-ctx shell hook") {
1108            let cleaned = remove_lean_ctx_block(&existing);
1109            match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1110                Ok(()) => {
1111                    println!("Updated lean-ctx aliases in {}", rc_file.display());
1112                    println!("  Binary: {binary}");
1113                    return;
1114                }
1115                Err(e) => {
1116                    eprintln!("Error updating {}: {e}", rc_file.display());
1117                    return;
1118                }
1119            }
1120        }
1121    }
1122
1123    match std::fs::OpenOptions::new()
1124        .append(true)
1125        .create(true)
1126        .open(&rc_file)
1127    {
1128        Ok(mut f) => {
1129            use std::io::Write;
1130            let _ = f.write_all(aliases.as_bytes());
1131            println!("Added lean-ctx aliases to {}", rc_file.display());
1132            println!("  Binary: {binary}");
1133        }
1134        Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1135    }
1136}
1137
1138fn remove_lean_ctx_block(content: &str) -> String {
1139    // New format uses explicit end marker; old format ends at first top-level `fi`/`end`.
1140    if content.contains("# lean-ctx shell hook — end") {
1141        return remove_lean_ctx_block_by_marker(content);
1142    }
1143    remove_lean_ctx_block_legacy(content)
1144}
1145
1146fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1147    let mut result = String::new();
1148    let mut in_block = false;
1149
1150    for line in content.lines() {
1151        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1152            in_block = true;
1153            continue;
1154        }
1155        if in_block {
1156            if line.trim() == "# lean-ctx shell hook — end" {
1157                in_block = false;
1158            }
1159            continue;
1160        }
1161        result.push_str(line);
1162        result.push('\n');
1163    }
1164    result
1165}
1166
1167fn remove_lean_ctx_block_legacy(content: &str) -> String {
1168    let mut result = String::new();
1169    let mut in_block = false;
1170
1171    for line in content.lines() {
1172        if line.contains("lean-ctx shell hook") {
1173            in_block = true;
1174            continue;
1175        }
1176        if in_block {
1177            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1178                if line.trim() == "fi" || line.trim() == "end" {
1179                    in_block = false;
1180                }
1181                continue;
1182            }
1183            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1184                in_block = false;
1185                result.push_str(line);
1186                result.push('\n');
1187            }
1188            continue;
1189        }
1190        result.push_str(line);
1191        result.push('\n');
1192    }
1193    result
1194}
1195
1196pub fn load_shell_history_pub() -> Vec<String> {
1197    load_shell_history()
1198}
1199
1200fn load_shell_history() -> Vec<String> {
1201    let shell = std::env::var("SHELL").unwrap_or_default();
1202    let home = match dirs::home_dir() {
1203        Some(h) => h,
1204        None => return Vec::new(),
1205    };
1206
1207    let history_file = if shell.contains("zsh") {
1208        home.join(".zsh_history")
1209    } else if shell.contains("fish") {
1210        home.join(".local/share/fish/fish_history")
1211    } else if cfg!(windows) && shell.is_empty() {
1212        home.join("AppData")
1213            .join("Roaming")
1214            .join("Microsoft")
1215            .join("Windows")
1216            .join("PowerShell")
1217            .join("PSReadLine")
1218            .join("ConsoleHost_history.txt")
1219    } else {
1220        home.join(".bash_history")
1221    };
1222
1223    match std::fs::read_to_string(&history_file) {
1224        Ok(content) => content
1225            .lines()
1226            .filter_map(|l| {
1227                let trimmed = l.trim();
1228                if trimmed.starts_with(':') {
1229                    trimmed.split(';').nth(1).map(|s| s.to_string())
1230                } else {
1231                    Some(trimmed.to_string())
1232                }
1233            })
1234            .filter(|l| !l.is_empty())
1235            .collect(),
1236        Err(_) => Vec::new(),
1237    }
1238}
1239
1240fn print_savings(original: usize, sent: usize) {
1241    let saved = original.saturating_sub(sent);
1242    if original > 0 && saved > 0 {
1243        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1244        println!("[{saved} tok saved ({pct}%)]");
1245    }
1246}
1247
1248pub fn cmd_theme(args: &[String]) {
1249    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1250    let r = theme::rst();
1251    let b = theme::bold();
1252    let d = theme::dim();
1253
1254    match sub {
1255        "list" => {
1256            let cfg = config::Config::load();
1257            let active = cfg.theme.as_str();
1258            println!();
1259            println!("  {b}Available themes:{r}");
1260            println!("  {ln}", ln = "─".repeat(40));
1261            for name in theme::PRESET_NAMES {
1262                let marker = if *name == active { " ◀ active" } else { "" };
1263                let t = theme::from_preset(name).unwrap();
1264                let preview = format!(
1265                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1266                    p = t.primary.fg(),
1267                    s = t.secondary.fg(),
1268                    a = t.accent.fg(),
1269                    sc = t.success.fg(),
1270                    w = t.warning.fg(),
1271                );
1272                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1273            }
1274            if let Some(path) = theme::theme_file_path() {
1275                if path.exists() {
1276                    let custom = theme::load_theme("_custom_");
1277                    let preview = format!(
1278                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1279                        p = custom.primary.fg(),
1280                        s = custom.secondary.fg(),
1281                        a = custom.accent.fg(),
1282                        sc = custom.success.fg(),
1283                        w = custom.warning.fg(),
1284                    );
1285                    let marker = if active == "custom" {
1286                        " ◀ active"
1287                    } else {
1288                        ""
1289                    };
1290                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1291                }
1292            }
1293            println!();
1294            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1295            println!();
1296        }
1297        "set" => {
1298            if args.len() < 2 {
1299                eprintln!("Usage: lean-ctx theme set <name>");
1300                std::process::exit(1);
1301            }
1302            let name = &args[1];
1303            if theme::from_preset(name).is_none() && name != "custom" {
1304                eprintln!(
1305                    "Unknown theme '{name}'. Available: {}",
1306                    theme::PRESET_NAMES.join(", ")
1307                );
1308                std::process::exit(1);
1309            }
1310            let mut cfg = config::Config::load();
1311            cfg.theme = name.to_string();
1312            match cfg.save() {
1313                Ok(()) => {
1314                    let t = theme::load_theme(name);
1315                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1316                    let preview = t.gradient_bar(0.75, 30);
1317                    println!("  {preview}");
1318                }
1319                Err(e) => eprintln!("Error: {e}"),
1320            }
1321        }
1322        "export" => {
1323            let cfg = config::Config::load();
1324            let t = theme::load_theme(&cfg.theme);
1325            println!("{}", t.to_toml());
1326        }
1327        "import" => {
1328            if args.len() < 2 {
1329                eprintln!("Usage: lean-ctx theme import <path>");
1330                std::process::exit(1);
1331            }
1332            let path = std::path::Path::new(&args[1]);
1333            if !path.exists() {
1334                eprintln!("File not found: {}", args[1]);
1335                std::process::exit(1);
1336            }
1337            match std::fs::read_to_string(path) {
1338                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1339                    Ok(imported) => match theme::save_theme(&imported) {
1340                        Ok(()) => {
1341                            let mut cfg = config::Config::load();
1342                            cfg.theme = "custom".to_string();
1343                            let _ = cfg.save();
1344                            println!(
1345                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1346                                sc = imported.success.fg(),
1347                                name = imported.name,
1348                            );
1349                            println!("  Config updated: theme = custom");
1350                        }
1351                        Err(e) => eprintln!("Error saving theme: {e}"),
1352                    },
1353                    Err(e) => eprintln!("Invalid theme file: {e}"),
1354                },
1355                Err(e) => eprintln!("Error reading file: {e}"),
1356            }
1357        }
1358        "preview" => {
1359            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1360            let t = match theme::from_preset(name) {
1361                Some(t) => t,
1362                None => {
1363                    eprintln!("Unknown theme: {name}");
1364                    std::process::exit(1);
1365                }
1366            };
1367            println!();
1368            println!(
1369                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1370                icon = t.header_icon(),
1371                title = t.brand_title(),
1372            );
1373            println!("  {ln}", ln = t.border_line(50));
1374            println!();
1375            println!(
1376                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1377                sc = t.success.fg(),
1378                sec = t.secondary.fg(),
1379                wrn = t.warning.fg(),
1380                acc = t.accent.fg(),
1381            );
1382            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1383            println!();
1384            println!(
1385                "  {b}{txt}Gradient Bar{r}      {bar}",
1386                txt = t.text.fg(),
1387                bar = t.gradient_bar(0.85, 30),
1388            );
1389            println!(
1390                "  {b}{txt}Sparkline{r}         {spark}",
1391                txt = t.text.fg(),
1392                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1393            );
1394            println!();
1395            println!("  {top}", top = t.box_top(50));
1396            println!(
1397                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1398                side = t.box_side(),
1399                side_r = t.box_side(),
1400                txt = t.text.fg(),
1401            );
1402            println!("  {bot}", bot = t.box_bottom(50));
1403            println!();
1404        }
1405        _ => {
1406            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1407            std::process::exit(1);
1408        }
1409    }
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::*;
1415
1416    #[test]
1417    fn test_remove_lean_ctx_block_posix() {
1418        let input = r#"# existing config
1419export PATH="$HOME/bin:$PATH"
1420
1421# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1422if [ -z "$LEAN_CTX_ACTIVE" ]; then
1423alias git='lean-ctx -c git'
1424alias npm='lean-ctx -c npm'
1425fi
1426
1427# other stuff
1428export EDITOR=vim
1429"#;
1430        let result = remove_lean_ctx_block(input);
1431        assert!(!result.contains("lean-ctx"), "block should be removed");
1432        assert!(result.contains("export PATH"), "other content preserved");
1433        assert!(
1434            result.contains("export EDITOR"),
1435            "trailing content preserved"
1436        );
1437    }
1438
1439    #[test]
1440    fn test_remove_lean_ctx_block_fish() {
1441        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";
1442        let result = remove_lean_ctx_block(input);
1443        assert!(!result.contains("lean-ctx"), "block should be removed");
1444        assert!(result.contains("set -x FOO"), "other content preserved");
1445        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1446    }
1447
1448    #[test]
1449    fn test_remove_lean_ctx_block_ps() {
1450        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";
1451        let result = remove_lean_ctx_block_ps(input);
1452        assert!(
1453            !result.contains("lean-ctx shell hook"),
1454            "block should be removed"
1455        );
1456        assert!(result.contains("$env:FOO"), "other content preserved");
1457        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1458    }
1459
1460    #[test]
1461    fn test_remove_block_no_lean_ctx() {
1462        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1463        let result = remove_lean_ctx_block(input);
1464        assert!(result.contains("export PATH"), "content unchanged");
1465    }
1466
1467    #[test]
1468    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1469        let input = r#"# existing config
1470export PATH="$HOME/bin:$PATH"
1471
1472# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1473_lean_ctx_cmds=(git npm pnpm)
1474
1475lean-ctx-on() {
1476    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1477        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1478    done
1479    export LEAN_CTX_ENABLED=1
1480    echo "lean-ctx: ON"
1481}
1482
1483lean-ctx-off() {
1484    unset LEAN_CTX_ENABLED
1485    echo "lean-ctx: OFF"
1486}
1487
1488if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1489    lean-ctx-on
1490fi
1491# lean-ctx shell hook — end
1492
1493# other stuff
1494export EDITOR=vim
1495"#;
1496        let result = remove_lean_ctx_block(input);
1497        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1498        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1499        assert!(result.contains("export PATH"), "other content preserved");
1500        assert!(
1501            result.contains("export EDITOR"),
1502            "trailing content preserved"
1503        );
1504    }
1505}