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