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_config(args: &[String]) {
481    let cfg = config::Config::load();
482
483    if args.is_empty() {
484        println!("{}", cfg.show());
485        return;
486    }
487
488    match args[0].as_str() {
489        "init" | "create" => {
490            let default = config::Config::default();
491            match default.save() {
492                Ok(()) => {
493                    let path = config::Config::path()
494                        .map(|p| p.to_string_lossy().to_string())
495                        .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
496                    println!("Created default config at {path}");
497                }
498                Err(e) => eprintln!("Error: {e}"),
499            }
500        }
501        "set" => {
502            if args.len() < 3 {
503                eprintln!("Usage: lean-ctx config set <key> <value>");
504                std::process::exit(1);
505            }
506            let mut cfg = cfg;
507            let key = &args[1];
508            let val = &args[2];
509            match key.as_str() {
510                "ultra_compact" => cfg.ultra_compact = val == "true",
511                "tee_on_error" => cfg.tee_on_error = val == "true",
512                "checkpoint_interval" => {
513                    cfg.checkpoint_interval = val.parse().unwrap_or(15);
514                }
515                "theme" => {
516                    if theme::from_preset(val).is_some() || val == "custom" {
517                        cfg.theme = val.to_string();
518                    } else {
519                        eprintln!(
520                            "Unknown theme '{val}'. Available: {}",
521                            theme::PRESET_NAMES.join(", ")
522                        );
523                        std::process::exit(1);
524                    }
525                }
526                _ => {
527                    eprintln!("Unknown config key: {key}");
528                    std::process::exit(1);
529                }
530            }
531            match cfg.save() {
532                Ok(()) => println!("Updated {key} = {val}"),
533                Err(e) => eprintln!("Error saving config: {e}"),
534            }
535        }
536        _ => {
537            eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
538            std::process::exit(1);
539        }
540    }
541}
542
543pub fn cmd_cheatsheet() {
544    println!(
545        "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
546\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
547\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m
548
549\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
550  ctx_session load               \x1b[2m# restore previous session\x1b[0m
551  ctx_overview task=\"...\"         \x1b[2m# task-aware file map\x1b[0m
552  ctx_graph action=build          \x1b[2m# index project (first time)\x1b[0m
553  ctx_knowledge action=recall     \x1b[2m# check stored project facts\x1b[0m
554
555\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
556  ctx_read mode=full    \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
557  ctx_read mode=map     \x1b[2m# context-only files (~93% saved)\x1b[0m
558  ctx_read mode=diff    \x1b[2m# after editing (~98% saved)\x1b[0m
559  ctx_read mode=sigs    \x1b[2m# API surface of large files (~95%)\x1b[0m
560  ctx_multi_read        \x1b[2m# read multiple files at once\x1b[0m
561  ctx_search            \x1b[2m# search with compressed results (~70%)\x1b[0m
562  ctx_shell             \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
563
564\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
565  ctx_session finding \"...\"       \x1b[2m# record what you discovered\x1b[0m
566  ctx_session decision \"...\"      \x1b[2m# record architectural choices\x1b[0m
567  ctx_knowledge action=remember   \x1b[2m# store permanent project facts\x1b[0m
568  ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
569  ctx_metrics                     \x1b[2m# see session statistics\x1b[0m
570
571\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
572  ctx_agent action=register       \x1b[2m# announce yourself\x1b[0m
573  ctx_agent action=list           \x1b[2m# see other active agents\x1b[0m
574  ctx_agent action=post           \x1b[2m# share findings\x1b[0m
575  ctx_agent action=read           \x1b[2m# check messages\x1b[0m
576
577\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
578  Will edit?  → \x1b[1mfull\x1b[0m (re-reads: 13 tokens)  → after edit: \x1b[1mdiff\x1b[0m
579  API only?   → \x1b[1msignatures\x1b[0m
580  Deps/exports? → \x1b[1mmap\x1b[0m
581  Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
582  Browsing?   → \x1b[1maggressive\x1b[0m (syntax stripped)
583
584\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
585  lean-ctx gain          \x1b[2m# visual savings dashboard\x1b[0m
586  lean-ctx gain --live   \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
587  lean-ctx dashboard     \x1b[2m# web dashboard with charts\x1b[0m
588  lean-ctx wrapped       \x1b[2m# weekly savings report\x1b[0m
589  lean-ctx discover      \x1b[2m# find uncompressed commands\x1b[0m
590  lean-ctx doctor        \x1b[2m# diagnose installation\x1b[0m
591  lean-ctx update        \x1b[2m# self-update to latest\x1b[0m
592
593\x1b[2m  Full guide: https://leanctx.com/docs/workflow\x1b[0m"
594    );
595}
596
597pub fn cmd_slow_log(args: &[String]) {
598    use crate::core::slow_log;
599
600    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
601    match action {
602        "list" | "ls" | "" => println!("{}", slow_log::list()),
603        "clear" | "purge" => println!("{}", slow_log::clear()),
604        _ => {
605            eprintln!("Usage: lean-ctx slow-log [list|clear]");
606            std::process::exit(1);
607        }
608    }
609}
610
611pub fn cmd_tee(args: &[String]) {
612    let tee_dir = match dirs::home_dir() {
613        Some(h) => h.join(".lean-ctx").join("tee"),
614        None => {
615            eprintln!("Cannot determine home directory");
616            std::process::exit(1);
617        }
618    };
619
620    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
621    match action {
622        "list" | "ls" => {
623            if !tee_dir.exists() {
624                println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
625                return;
626            }
627            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
628                .unwrap_or_else(|e| {
629                    eprintln!("Error: {e}");
630                    std::process::exit(1);
631                })
632                .filter_map(|e| e.ok())
633                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
634                .collect();
635            entries.sort_by_key(|e| e.file_name());
636
637            if entries.is_empty() {
638                println!("No tee logs found.");
639                return;
640            }
641
642            println!("Tee logs ({}):\n", entries.len());
643            for entry in &entries {
644                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
645                let name = entry.file_name();
646                let size_str = if size > 1024 {
647                    format!("{}K", size / 1024)
648                } else {
649                    format!("{}B", size)
650                };
651                println!("  {:<60} {}", name.to_string_lossy(), size_str);
652            }
653            println!("\nUse 'lean-ctx tee clear' to delete all logs.");
654        }
655        "clear" | "purge" => {
656            if !tee_dir.exists() {
657                println!("No tee logs to clear.");
658                return;
659            }
660            let mut count = 0u32;
661            if let Ok(entries) = std::fs::read_dir(&tee_dir) {
662                for entry in entries.flatten() {
663                    if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
664                        && std::fs::remove_file(entry.path()).is_ok()
665                    {
666                        count += 1;
667                    }
668                }
669            }
670            println!("Cleared {count} tee log(s) from {}", tee_dir.display());
671        }
672        "show" => {
673            let filename = args.get(1);
674            if filename.is_none() {
675                eprintln!("Usage: lean-ctx tee show <filename>");
676                std::process::exit(1);
677            }
678            let path = tee_dir.join(filename.unwrap());
679            match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
680                Ok(content) => print!("{content}"),
681                Err(e) => {
682                    eprintln!("Error reading {}: {e}", path.display());
683                    std::process::exit(1);
684                }
685            }
686        }
687        _ => {
688            eprintln!("Usage: lean-ctx tee [list|clear|show <file>]");
689            std::process::exit(1);
690        }
691    }
692}
693
694pub fn cmd_init(args: &[String]) {
695    let global = args.iter().any(|a| a == "--global" || a == "-g");
696
697    let agents: Vec<&str> = args
698        .windows(2)
699        .filter(|w| w[0] == "--agent")
700        .map(|w| w[1].as_str())
701        .collect();
702
703    if !agents.is_empty() {
704        for agent_name in &agents {
705            crate::hooks::install_agent_hook(agent_name, global);
706        }
707        println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
708        return;
709    }
710
711    let shell_name = std::env::var("SHELL").unwrap_or_default();
712    let is_zsh = shell_name.contains("zsh");
713    let is_fish = shell_name.contains("fish");
714    let is_powershell = cfg!(windows) && shell_name.is_empty();
715
716    let binary = std::env::current_exe()
717        .map(|p| p.to_string_lossy().to_string())
718        .unwrap_or_else(|_| "lean-ctx".to_string());
719
720    if is_powershell {
721        init_powershell(&binary);
722    } else {
723        let bash_binary = to_bash_compatible_path(&binary);
724        if is_fish {
725            init_fish(&bash_binary);
726        } else {
727            init_posix(is_zsh, &bash_binary);
728        }
729    }
730
731    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
732    if let Some(dir) = lean_dir {
733        if !dir.exists() {
734            let _ = std::fs::create_dir_all(&dir);
735            println!("Created {}", dir.display());
736        }
737    }
738
739    if global && !is_powershell {
740        let rc = if is_fish {
741            "config.fish"
742        } else if is_zsh {
743            ".zshrc"
744        } else {
745            ".bashrc"
746        };
747        println!("\nRestart your shell or run: source ~/{rc}");
748    } else if global && is_powershell {
749        println!("\nRestart PowerShell or run: . $PROFILE");
750    }
751
752    println!("\nlean-ctx init complete. (23 aliases installed)");
753    println!("Binary: {binary}");
754    println!("\nFor AI tool integration, use: lean-ctx init --agent <tool>");
755    println!("  Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, pi");
756    println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
757    println!("Run 'lean-ctx discover' to find missed savings in your shell history.");
758}
759
760fn init_powershell(binary: &str) {
761    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
762    let profile_path = match profile_dir {
763        Some(dir) => {
764            let _ = std::fs::create_dir_all(&dir);
765            dir.join("Microsoft.PowerShell_profile.ps1")
766        }
767        None => {
768            eprintln!("Could not resolve PowerShell profile directory");
769            return;
770        }
771    };
772
773    let binary_escaped = binary.replace('\\', "\\\\");
774    let functions = format!(
775        r#"
776# lean-ctx shell hook — transparent CLI compression (90+ patterns)
777if (-not $env:LEAN_CTX_ACTIVE) {{
778  $LeanCtxBin = "{binary_escaped}"
779  function git {{ & $LeanCtxBin -c "git $($args -join ' ')" }}
780  function npm {{ & $LeanCtxBin -c "npm $($args -join ' ')" }}
781  function pnpm {{ & $LeanCtxBin -c "pnpm $($args -join ' ')" }}
782  function yarn {{ & $LeanCtxBin -c "yarn $($args -join ' ')" }}
783  function cargo {{ & $LeanCtxBin -c "cargo $($args -join ' ')" }}
784  function docker {{ & $LeanCtxBin -c "docker $($args -join ' ')" }}
785  function kubectl {{ & $LeanCtxBin -c "kubectl $($args -join ' ')" }}
786  function gh {{ & $LeanCtxBin -c "gh $($args -join ' ')" }}
787  function pip {{ & $LeanCtxBin -c "pip $($args -join ' ')" }}
788  function pip3 {{ & $LeanCtxBin -c "pip3 $($args -join ' ')" }}
789  function ruff {{ & $LeanCtxBin -c "ruff $($args -join ' ')" }}
790  function go {{ & $LeanCtxBin -c "go $($args -join ' ')" }}
791  function eslint {{ & $LeanCtxBin -c "eslint $($args -join ' ')" }}
792  function prettier {{ & $LeanCtxBin -c "prettier $($args -join ' ')" }}
793  function tsc {{ & $LeanCtxBin -c "tsc $($args -join ' ')" }}
794  function curl {{ & $LeanCtxBin -c "curl $($args -join ' ')" }}
795  function wget {{ & $LeanCtxBin -c "wget $($args -join ' ')" }}
796}}
797"#
798    );
799
800    if let Ok(existing) = std::fs::read_to_string(&profile_path) {
801        if existing.contains("lean-ctx shell hook") {
802            let cleaned = remove_lean_ctx_block_ps(&existing);
803            match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
804                Ok(()) => {
805                    println!("Updated lean-ctx functions in {}", profile_path.display());
806                    println!("  Binary: {binary}");
807                    return;
808                }
809                Err(e) => {
810                    eprintln!("Error updating {}: {e}", profile_path.display());
811                    return;
812                }
813            }
814        }
815    }
816
817    match std::fs::OpenOptions::new()
818        .append(true)
819        .create(true)
820        .open(&profile_path)
821    {
822        Ok(mut f) => {
823            use std::io::Write;
824            let _ = f.write_all(functions.as_bytes());
825            println!("Added lean-ctx functions to {}", profile_path.display());
826            println!("  Binary: {binary}");
827        }
828        Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
829    }
830}
831
832fn remove_lean_ctx_block_ps(content: &str) -> String {
833    let mut result = String::new();
834    let mut in_block = false;
835    let mut brace_depth = 0i32;
836
837    for line in content.lines() {
838        if line.contains("lean-ctx shell hook") {
839            in_block = true;
840            continue;
841        }
842        if in_block {
843            brace_depth += line.matches('{').count() as i32;
844            brace_depth -= line.matches('}').count() as i32;
845            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
846                if line.trim() == "}" {
847                    in_block = false;
848                    brace_depth = 0;
849                }
850                continue;
851            }
852            continue;
853        }
854        result.push_str(line);
855        result.push('\n');
856    }
857    result
858}
859
860fn init_fish(binary: &str) {
861    let config = dirs::home_dir()
862        .map(|h| h.join(".config/fish/config.fish"))
863        .unwrap_or_default();
864
865    let aliases = format!(
866        "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
867        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\
868        \n\
869        function lean-ctx-on\n\
870        \tfor _lc_cmd in $_lean_ctx_cmds\n\
871        \t\talias $_lc_cmd '{binary} -c '$_lc_cmd\n\
872        \tend\n\
873        \talias k '{binary} -c kubectl'\n\
874        \tset -gx LEAN_CTX_ENABLED 1\n\
875        \techo 'lean-ctx: ON'\n\
876        end\n\
877        \n\
878        function lean-ctx-off\n\
879        \tfor _lc_cmd in $_lean_ctx_cmds\n\
880        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
881        \tend\n\
882        \tfunctions --erase k 2>/dev/null; true\n\
883        \tset -e LEAN_CTX_ENABLED\n\
884        \techo 'lean-ctx: OFF'\n\
885        end\n\
886        \n\
887        function lean-ctx-status\n\
888        \tif set -q LEAN_CTX_ENABLED\n\
889        \t\techo 'lean-ctx: ON'\n\
890        \telse\n\
891        \t\techo 'lean-ctx: OFF'\n\
892        \tend\n\
893        end\n\
894        \n\
895        if not set -q LEAN_CTX_ACTIVE; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
896        \tlean-ctx-on\n\
897        end\n\
898        # lean-ctx shell hook — end\n"
899    );
900
901    if let Ok(existing) = std::fs::read_to_string(&config) {
902        if existing.contains("lean-ctx shell hook") {
903            let cleaned = remove_lean_ctx_block(&existing);
904            match std::fs::write(&config, format!("{cleaned}{aliases}")) {
905                Ok(()) => {
906                    println!("Updated lean-ctx aliases in {}", config.display());
907                    println!("  Binary: {binary}");
908                    return;
909                }
910                Err(e) => {
911                    eprintln!("Error updating {}: {e}", config.display());
912                    return;
913                }
914            }
915        }
916    }
917
918    match std::fs::OpenOptions::new()
919        .append(true)
920        .create(true)
921        .open(&config)
922    {
923        Ok(mut f) => {
924            use std::io::Write;
925            let _ = f.write_all(aliases.as_bytes());
926            println!("Added lean-ctx aliases to {}", config.display());
927            println!("  Binary: {binary}");
928        }
929        Err(e) => eprintln!("Error writing {}: {e}", config.display()),
930    }
931}
932
933fn init_posix(is_zsh: bool, binary: &str) {
934    let rc_file = if is_zsh {
935        dirs::home_dir()
936            .map(|h| h.join(".zshrc"))
937            .unwrap_or_default()
938    } else {
939        dirs::home_dir()
940            .map(|h| h.join(".bashrc"))
941            .unwrap_or_default()
942    };
943
944    let aliases = format!(
945        r#"
946# lean-ctx shell hook — transparent CLI compression (90+ patterns)
947_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)
948
949lean-ctx-on() {{
950    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
951        # shellcheck disable=SC2139
952        alias "$_lc_cmd"='{binary} -c '"$_lc_cmd"
953    done
954    alias k='{binary} -c kubectl'
955    export LEAN_CTX_ENABLED=1
956    echo "lean-ctx: ON"
957}}
958
959lean-ctx-off() {{
960    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
961        unalias "$_lc_cmd" 2>/dev/null || true
962    done
963    unalias k 2>/dev/null || true
964    unset LEAN_CTX_ENABLED
965    echo "lean-ctx: OFF"
966}}
967
968lean-ctx-status() {{
969    if [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
970        echo "lean-ctx: ON"
971    else
972        echo "lean-ctx: OFF"
973    fi
974}}
975
976if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
977    lean-ctx-on
978fi
979# lean-ctx shell hook — end
980"#
981    );
982
983    if let Ok(existing) = std::fs::read_to_string(&rc_file) {
984        if existing.contains("lean-ctx shell hook") {
985            let cleaned = remove_lean_ctx_block(&existing);
986            match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
987                Ok(()) => {
988                    println!("Updated lean-ctx aliases in {}", rc_file.display());
989                    println!("  Binary: {binary}");
990                    return;
991                }
992                Err(e) => {
993                    eprintln!("Error updating {}: {e}", rc_file.display());
994                    return;
995                }
996            }
997        }
998    }
999
1000    match std::fs::OpenOptions::new()
1001        .append(true)
1002        .create(true)
1003        .open(&rc_file)
1004    {
1005        Ok(mut f) => {
1006            use std::io::Write;
1007            let _ = f.write_all(aliases.as_bytes());
1008            println!("Added lean-ctx aliases to {}", rc_file.display());
1009            println!("  Binary: {binary}");
1010        }
1011        Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1012    }
1013}
1014
1015fn remove_lean_ctx_block(content: &str) -> String {
1016    // New format uses explicit end marker; old format ends at first top-level `fi`/`end`.
1017    if content.contains("# lean-ctx shell hook — end") {
1018        return remove_lean_ctx_block_by_marker(content);
1019    }
1020    remove_lean_ctx_block_legacy(content)
1021}
1022
1023fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1024    let mut result = String::new();
1025    let mut in_block = false;
1026
1027    for line in content.lines() {
1028        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1029            in_block = true;
1030            continue;
1031        }
1032        if in_block {
1033            if line.trim() == "# lean-ctx shell hook — end" {
1034                in_block = false;
1035            }
1036            continue;
1037        }
1038        result.push_str(line);
1039        result.push('\n');
1040    }
1041    result
1042}
1043
1044fn remove_lean_ctx_block_legacy(content: &str) -> String {
1045    let mut result = String::new();
1046    let mut in_block = false;
1047
1048    for line in content.lines() {
1049        if line.contains("lean-ctx shell hook") {
1050            in_block = true;
1051            continue;
1052        }
1053        if in_block {
1054            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1055                if line.trim() == "fi" || line.trim() == "end" {
1056                    in_block = false;
1057                }
1058                continue;
1059            }
1060            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1061                in_block = false;
1062                result.push_str(line);
1063                result.push('\n');
1064            }
1065            continue;
1066        }
1067        result.push_str(line);
1068        result.push('\n');
1069    }
1070    result
1071}
1072
1073pub fn load_shell_history_pub() -> Vec<String> {
1074    load_shell_history()
1075}
1076
1077fn load_shell_history() -> Vec<String> {
1078    let shell = std::env::var("SHELL").unwrap_or_default();
1079    let home = match dirs::home_dir() {
1080        Some(h) => h,
1081        None => return Vec::new(),
1082    };
1083
1084    let history_file = if shell.contains("zsh") {
1085        home.join(".zsh_history")
1086    } else if shell.contains("fish") {
1087        home.join(".local/share/fish/fish_history")
1088    } else if cfg!(windows) && shell.is_empty() {
1089        home.join("AppData")
1090            .join("Roaming")
1091            .join("Microsoft")
1092            .join("Windows")
1093            .join("PowerShell")
1094            .join("PSReadLine")
1095            .join("ConsoleHost_history.txt")
1096    } else {
1097        home.join(".bash_history")
1098    };
1099
1100    match std::fs::read_to_string(&history_file) {
1101        Ok(content) => content
1102            .lines()
1103            .filter_map(|l| {
1104                let trimmed = l.trim();
1105                if trimmed.starts_with(':') {
1106                    trimmed.split(';').nth(1).map(|s| s.to_string())
1107                } else {
1108                    Some(trimmed.to_string())
1109                }
1110            })
1111            .filter(|l| !l.is_empty())
1112            .collect(),
1113        Err(_) => Vec::new(),
1114    }
1115}
1116
1117fn print_savings(original: usize, sent: usize) {
1118    let saved = original.saturating_sub(sent);
1119    if original > 0 && saved > 0 {
1120        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1121        println!("[{saved} tok saved ({pct}%)]");
1122    }
1123}
1124
1125pub fn cmd_theme(args: &[String]) {
1126    let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1127    let r = theme::rst();
1128    let b = theme::bold();
1129    let d = theme::dim();
1130
1131    match sub {
1132        "list" => {
1133            let cfg = config::Config::load();
1134            let active = cfg.theme.as_str();
1135            println!();
1136            println!("  {b}Available themes:{r}");
1137            println!("  {ln}", ln = "─".repeat(40));
1138            for name in theme::PRESET_NAMES {
1139                let marker = if *name == active { " ◀ active" } else { "" };
1140                let t = theme::from_preset(name).unwrap();
1141                let preview = format!(
1142                    "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1143                    p = t.primary.fg(),
1144                    s = t.secondary.fg(),
1145                    a = t.accent.fg(),
1146                    sc = t.success.fg(),
1147                    w = t.warning.fg(),
1148                );
1149                println!("  {preview}  {b}{name:<12}{r}{d}{marker}{r}");
1150            }
1151            if let Some(path) = theme::theme_file_path() {
1152                if path.exists() {
1153                    let custom = theme::load_theme("_custom_");
1154                    let preview = format!(
1155                        "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1156                        p = custom.primary.fg(),
1157                        s = custom.secondary.fg(),
1158                        a = custom.accent.fg(),
1159                        sc = custom.success.fg(),
1160                        w = custom.warning.fg(),
1161                    );
1162                    let marker = if active == "custom" {
1163                        " ◀ active"
1164                    } else {
1165                        ""
1166                    };
1167                    println!("  {preview}  {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1168                }
1169            }
1170            println!();
1171            println!("  {d}Set theme: lean-ctx theme set <name>{r}");
1172            println!();
1173        }
1174        "set" => {
1175            if args.len() < 2 {
1176                eprintln!("Usage: lean-ctx theme set <name>");
1177                std::process::exit(1);
1178            }
1179            let name = &args[1];
1180            if theme::from_preset(name).is_none() && name != "custom" {
1181                eprintln!(
1182                    "Unknown theme '{name}'. Available: {}",
1183                    theme::PRESET_NAMES.join(", ")
1184                );
1185                std::process::exit(1);
1186            }
1187            let mut cfg = config::Config::load();
1188            cfg.theme = name.to_string();
1189            match cfg.save() {
1190                Ok(()) => {
1191                    let t = theme::load_theme(name);
1192                    println!("  {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1193                    let preview = t.gradient_bar(0.75, 30);
1194                    println!("  {preview}");
1195                }
1196                Err(e) => eprintln!("Error: {e}"),
1197            }
1198        }
1199        "export" => {
1200            let cfg = config::Config::load();
1201            let t = theme::load_theme(&cfg.theme);
1202            println!("{}", t.to_toml());
1203        }
1204        "import" => {
1205            if args.len() < 2 {
1206                eprintln!("Usage: lean-ctx theme import <path>");
1207                std::process::exit(1);
1208            }
1209            let path = std::path::Path::new(&args[1]);
1210            if !path.exists() {
1211                eprintln!("File not found: {}", args[1]);
1212                std::process::exit(1);
1213            }
1214            match std::fs::read_to_string(path) {
1215                Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1216                    Ok(imported) => match theme::save_theme(&imported) {
1217                        Ok(()) => {
1218                            let mut cfg = config::Config::load();
1219                            cfg.theme = "custom".to_string();
1220                            let _ = cfg.save();
1221                            println!(
1222                                "  {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1223                                sc = imported.success.fg(),
1224                                name = imported.name,
1225                            );
1226                            println!("  Config updated: theme = custom");
1227                        }
1228                        Err(e) => eprintln!("Error saving theme: {e}"),
1229                    },
1230                    Err(e) => eprintln!("Invalid theme file: {e}"),
1231                },
1232                Err(e) => eprintln!("Error reading file: {e}"),
1233            }
1234        }
1235        "preview" => {
1236            let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1237            let t = match theme::from_preset(name) {
1238                Some(t) => t,
1239                None => {
1240                    eprintln!("Unknown theme: {name}");
1241                    std::process::exit(1);
1242                }
1243            };
1244            println!();
1245            println!(
1246                "  {icon} {title}  {d}Theme Preview: {name}{r}",
1247                icon = t.header_icon(),
1248                title = t.brand_title(),
1249            );
1250            println!("  {ln}", ln = t.border_line(50));
1251            println!();
1252            println!(
1253                "  {b}{sc} 1.2M      {r}  {b}{sec} 87.3%     {r}  {b}{wrn} 4,521    {r}  {b}{acc} $12.50   {r}",
1254                sc = t.success.fg(),
1255                sec = t.secondary.fg(),
1256                wrn = t.warning.fg(),
1257                acc = t.accent.fg(),
1258            );
1259            println!("  {d} tokens saved   compression    commands       USD saved{r}");
1260            println!();
1261            println!(
1262                "  {b}{txt}Gradient Bar{r}      {bar}",
1263                txt = t.text.fg(),
1264                bar = t.gradient_bar(0.85, 30),
1265            );
1266            println!(
1267                "  {b}{txt}Sparkline{r}         {spark}",
1268                txt = t.text.fg(),
1269                spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1270            );
1271            println!();
1272            println!("  {top}", top = t.box_top(50));
1273            println!(
1274                "  {side}  {b}{txt}Box content with themed borders{r}                  {side_r}",
1275                side = t.box_side(),
1276                side_r = t.box_side(),
1277                txt = t.text.fg(),
1278            );
1279            println!("  {bot}", bot = t.box_bottom(50));
1280            println!();
1281        }
1282        _ => {
1283            eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1284            std::process::exit(1);
1285        }
1286    }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    use super::*;
1292
1293    #[test]
1294    fn test_remove_lean_ctx_block_posix() {
1295        let input = r#"# existing config
1296export PATH="$HOME/bin:$PATH"
1297
1298# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1299if [ -z "$LEAN_CTX_ACTIVE" ]; then
1300alias git='lean-ctx -c git'
1301alias npm='lean-ctx -c npm'
1302fi
1303
1304# other stuff
1305export EDITOR=vim
1306"#;
1307        let result = remove_lean_ctx_block(input);
1308        assert!(!result.contains("lean-ctx"), "block should be removed");
1309        assert!(result.contains("export PATH"), "other content preserved");
1310        assert!(
1311            result.contains("export EDITOR"),
1312            "trailing content preserved"
1313        );
1314    }
1315
1316    #[test]
1317    fn test_remove_lean_ctx_block_fish() {
1318        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";
1319        let result = remove_lean_ctx_block(input);
1320        assert!(!result.contains("lean-ctx"), "block should be removed");
1321        assert!(result.contains("set -x FOO"), "other content preserved");
1322        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1323    }
1324
1325    #[test]
1326    fn test_remove_lean_ctx_block_ps() {
1327        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";
1328        let result = remove_lean_ctx_block_ps(input);
1329        assert!(
1330            !result.contains("lean-ctx shell hook"),
1331            "block should be removed"
1332        );
1333        assert!(result.contains("$env:FOO"), "other content preserved");
1334        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1335    }
1336
1337    #[test]
1338    fn test_remove_block_no_lean_ctx() {
1339        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1340        let result = remove_lean_ctx_block(input);
1341        assert!(result.contains("export PATH"), "content unchanged");
1342    }
1343
1344    #[test]
1345    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1346        let input = r#"# existing config
1347export PATH="$HOME/bin:$PATH"
1348
1349# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1350_lean_ctx_cmds=(git npm pnpm)
1351
1352lean-ctx-on() {
1353    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1354        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1355    done
1356    export LEAN_CTX_ENABLED=1
1357    echo "lean-ctx: ON"
1358}
1359
1360lean-ctx-off() {
1361    unset LEAN_CTX_ENABLED
1362    echo "lean-ctx: OFF"
1363}
1364
1365if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1366    lean-ctx-on
1367fi
1368# lean-ctx shell hook — end
1369
1370# other stuff
1371export EDITOR=vim
1372"#;
1373        let result = remove_lean_ctx_block(input);
1374        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1375        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1376        assert!(result.contains("export PATH"), "other content preserved");
1377        assert!(
1378            result.contains("export EDITOR"),
1379            "trailing content preserved"
1380        );
1381    }
1382}