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