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::tokens::count_tokens;
12use crate::hooks::to_bash_compatible_path;
13
14pub fn cmd_read(args: &[String]) {
15    if args.is_empty() {
16        eprintln!("Usage: lean-ctx read <file> [--mode full|map|signatures|aggressive|entropy]");
17        std::process::exit(1);
18    }
19
20    let path = &args[0];
21    let mode = args
22        .iter()
23        .position(|a| a == "--mode" || a == "-m")
24        .and_then(|i| args.get(i + 1))
25        .map(|s| s.as_str())
26        .unwrap_or("full");
27
28    let content = match crate::tools::ctx_read::read_file_lossy(path) {
29        Ok(c) => c,
30        Err(e) => {
31            eprintln!("Error: {e}");
32            std::process::exit(1);
33        }
34    };
35
36    let ext = Path::new(path)
37        .extension()
38        .and_then(|e| e.to_str())
39        .unwrap_or("");
40    let short = protocol::shorten_path(path);
41    let line_count = content.lines().count();
42    let original_tokens = count_tokens(&content);
43
44    let mode = if mode == "auto" {
45        let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
46        let predictor = crate::core::mode_predictor::ModePredictor::new();
47        predictor
48            .predict_best_mode(&sig)
49            .unwrap_or_else(|| "full".to_string())
50    } else {
51        mode.to_string()
52    };
53    let mode = mode.as_str();
54
55    match mode {
56        "map" => {
57            let sigs = signatures::extract_signatures(&content, ext);
58            let dep_info = dep_extract::extract_deps(&content, ext);
59
60            println!("{short} [{line_count}L]");
61            if !dep_info.imports.is_empty() {
62                println!("  deps: {}", dep_info.imports.join(", "));
63            }
64            if !dep_info.exports.is_empty() {
65                println!("  exports: {}", dep_info.exports.join(", "));
66            }
67            let key_sigs: Vec<_> = sigs
68                .iter()
69                .filter(|s| s.is_exported || s.indent == 0)
70                .collect();
71            if !key_sigs.is_empty() {
72                println!("  API:");
73                for sig in &key_sigs {
74                    println!("    {}", sig.to_compact());
75                }
76            }
77            let sent = count_tokens(&short.to_string());
78            print_savings(original_tokens, sent);
79        }
80        "signatures" => {
81            let sigs = signatures::extract_signatures(&content, ext);
82            println!("{short} [{line_count}L]");
83            for sig in &sigs {
84                println!("{}", sig.to_compact());
85            }
86            let sent = count_tokens(&short.to_string());
87            print_savings(original_tokens, sent);
88        }
89        "aggressive" => {
90            let compressed = compressor::aggressive_compress(&content, Some(ext));
91            println!("{short} [{line_count}L]");
92            println!("{compressed}");
93            let sent = count_tokens(&compressed);
94            print_savings(original_tokens, sent);
95        }
96        "entropy" => {
97            let result = entropy::entropy_compress(&content);
98            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
99            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
100            for tech in &result.techniques {
101                println!("{tech}");
102            }
103            println!("{}", result.output);
104            let sent = count_tokens(&result.output);
105            print_savings(original_tokens, sent);
106        }
107        _ => {
108            println!("{short} [{line_count}L]");
109            println!("{content}");
110        }
111    }
112}
113
114pub fn cmd_diff(args: &[String]) {
115    if args.len() < 2 {
116        eprintln!("Usage: lean-ctx diff <file1> <file2>");
117        std::process::exit(1);
118    }
119
120    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
121        Ok(c) => c,
122        Err(e) => {
123            eprintln!("Error reading {}: {e}", args[0]);
124            std::process::exit(1);
125        }
126    };
127
128    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
129        Ok(c) => c,
130        Err(e) => {
131            eprintln!("Error reading {}: {e}", args[1]);
132            std::process::exit(1);
133        }
134    };
135
136    let diff = compressor::diff_content(&content1, &content2);
137    let original = count_tokens(&content1) + count_tokens(&content2);
138    let sent = count_tokens(&diff);
139
140    println!(
141        "diff {} {}",
142        protocol::shorten_path(&args[0]),
143        protocol::shorten_path(&args[1])
144    );
145    println!("{diff}");
146    print_savings(original, sent);
147}
148
149pub fn cmd_grep(args: &[String]) {
150    if args.is_empty() {
151        eprintln!("Usage: lean-ctx grep <pattern> [path]");
152        std::process::exit(1);
153    }
154
155    let pattern = &args[0];
156    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
157
158    let command = if cfg!(windows) {
159        format!(
160            "findstr /S /N /R \"{}\" {}\\*",
161            pattern,
162            path.replace('/', "\\")
163        )
164    } else {
165        format!("grep -rn '{}' {}", pattern.replace('\'', "'\\''"), path)
166    };
167    let code = crate::shell::exec(&command);
168    std::process::exit(code);
169}
170
171pub fn cmd_find(args: &[String]) {
172    if args.is_empty() {
173        eprintln!("Usage: lean-ctx find <pattern> [path]");
174        std::process::exit(1);
175    }
176
177    let pattern = &args[0];
178    let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
179    let command = if cfg!(windows) {
180        format!("dir /S /B {}\\{}", path.replace('/', "\\"), pattern)
181    } else {
182        format!("find {path} -name \"{pattern}\" -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/target/*'")
183    };
184    let code = crate::shell::exec(&command);
185    std::process::exit(code);
186}
187
188pub fn cmd_ls(args: &[String]) {
189    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
190    let command = if cfg!(windows) {
191        format!("dir {}", path.replace('/', "\\"))
192    } else {
193        format!("ls -la {path}")
194    };
195    let code = crate::shell::exec(&command);
196    std::process::exit(code);
197}
198
199pub fn cmd_deps(args: &[String]) {
200    let path = args.first().map(|s| s.as_str()).unwrap_or(".");
201
202    match deps_cmd::detect_and_compress(path) {
203        Some(result) => println!("{result}"),
204        None => {
205            eprintln!("No dependency file found in {path}");
206            std::process::exit(1);
207        }
208    }
209}
210
211pub fn cmd_discover(_args: &[String]) {
212    let history = load_shell_history();
213    if history.is_empty() {
214        println!("No shell history found.");
215        return;
216    }
217
218    let compressible_commands = [
219        "git ",
220        "npm ",
221        "yarn ",
222        "pnpm ",
223        "cargo ",
224        "docker ",
225        "kubectl ",
226        "gh ",
227        "pip ",
228        "pip3 ",
229        "eslint",
230        "prettier",
231        "ruff ",
232        "go ",
233        "golangci-lint",
234        "playwright",
235        "cypress",
236        "next ",
237        "vite ",
238        "tsc",
239        "curl ",
240        "wget ",
241        "grep ",
242        "rg ",
243        "find ",
244        "env",
245        "ls ",
246    ];
247
248    let mut missed: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
249    let mut total_compressible = 0u32;
250    let mut via_lean_ctx = 0u32;
251
252    for line in &history {
253        let cmd = line.trim().to_lowercase();
254        if cmd.starts_with("lean-ctx") {
255            via_lean_ctx += 1;
256            continue;
257        }
258        for pattern in &compressible_commands {
259            if cmd.starts_with(pattern) {
260                total_compressible += 1;
261                let key = cmd.split_whitespace().take(2).collect::<Vec<_>>().join(" ");
262                *missed.entry(key).or_insert(0) += 1;
263                break;
264            }
265        }
266    }
267
268    if missed.is_empty() {
269        println!("All compressible commands are already using lean-ctx!");
270        return;
271    }
272
273    let mut sorted: Vec<(String, u32)> = missed.into_iter().collect();
274    sorted.sort_by(|a, b| b.1.cmp(&a.1));
275
276    println!(
277        "Found {} compressible commands not using lean-ctx:\n",
278        total_compressible
279    );
280    for (cmd, count) in sorted.iter().take(15) {
281        let est_savings = count * 150;
282        println!("  {cmd:<30} (used {count}x, ~{est_savings} tokens saveable)");
283    }
284    if sorted.len() > 15 {
285        println!("  ... +{} more command types", sorted.len() - 15);
286    }
287
288    let total_est = total_compressible * 150;
289    println!("\nEstimated missed savings: ~{total_est} tokens");
290    println!("Already using lean-ctx: {via_lean_ctx} commands");
291    println!("\nRun 'lean-ctx init --global' to enable compression for all commands.");
292}
293
294pub fn cmd_session() {
295    let history = load_shell_history();
296    let gain = stats::load_stats();
297
298    let compressible_commands = [
299        "git ",
300        "npm ",
301        "yarn ",
302        "pnpm ",
303        "cargo ",
304        "docker ",
305        "kubectl ",
306        "gh ",
307        "pip ",
308        "pip3 ",
309        "eslint",
310        "prettier",
311        "ruff ",
312        "go ",
313        "golangci-lint",
314        "curl ",
315        "wget ",
316        "grep ",
317        "rg ",
318        "find ",
319        "ls ",
320    ];
321
322    let mut total = 0u32;
323    let mut via_hook = 0u32;
324
325    for line in &history {
326        let cmd = line.trim().to_lowercase();
327        if cmd.starts_with("lean-ctx") {
328            via_hook += 1;
329            total += 1;
330        } else {
331            for p in &compressible_commands {
332                if cmd.starts_with(p) {
333                    total += 1;
334                    break;
335                }
336            }
337        }
338    }
339
340    let pct = if total > 0 {
341        (via_hook as f64 / total as f64 * 100.0).round() as u32
342    } else {
343        0
344    };
345
346    println!("lean-ctx session statistics\n");
347    println!(
348        "Adoption:    {}% ({}/{} compressible commands)",
349        pct, via_hook, total
350    );
351    println!("Saved:       {} tokens total", gain.total_saved);
352    println!("Calls:       {} compressed", gain.total_calls);
353
354    if total > via_hook {
355        let missed = total - via_hook;
356        let est = missed * 150;
357        println!(
358            "Missed:      {} commands (~{} tokens saveable)",
359            missed, est
360        );
361    }
362
363    println!("\nRun 'lean-ctx discover' for details on missed commands.");
364}
365
366pub fn cmd_wrapped(args: &[String]) {
367    let period = if args.iter().any(|a| a == "--month") {
368        "month"
369    } else if args.iter().any(|a| a == "--all") {
370        "all"
371    } else {
372        "week"
373    };
374
375    let report = crate::core::wrapped::WrappedReport::generate(period);
376    println!("{}", report.format_ascii());
377}
378
379pub fn cmd_sessions(args: &[String]) {
380    use crate::core::session::SessionState;
381
382    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
383
384    match action {
385        "list" | "ls" => {
386            let sessions = SessionState::list_sessions();
387            if sessions.is_empty() {
388                println!("No sessions found.");
389                return;
390            }
391            println!("Sessions ({}):\n", sessions.len());
392            for s in sessions.iter().take(20) {
393                let task = s.task.as_deref().unwrap_or("(no task)");
394                let task_short: String = task.chars().take(50).collect();
395                let date = s.updated_at.format("%Y-%m-%d %H:%M");
396                println!(
397                    "  {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
398                    s.id,
399                    s.version,
400                    s.tool_calls,
401                    format_tokens_cli(s.tokens_saved),
402                    date,
403                    task_short
404                );
405            }
406            if sessions.len() > 20 {
407                println!("  ... +{} more", sessions.len() - 20);
408            }
409        }
410        "show" => {
411            let id = args.get(1);
412            let session = if let Some(id) = id {
413                SessionState::load_by_id(id)
414            } else {
415                SessionState::load_latest()
416            };
417            match session {
418                Some(s) => println!("{}", s.format_compact()),
419                None => println!("Session not found."),
420            }
421        }
422        "cleanup" => {
423            let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
424            let removed = SessionState::cleanup_old_sessions(days);
425            println!("Cleaned up {removed} session(s) older than {days} days.");
426        }
427        _ => {
428            eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
429            std::process::exit(1);
430        }
431    }
432}
433
434pub fn cmd_benchmark(args: &[String]) {
435    use crate::core::benchmark;
436
437    let action = args.first().map(|s| s.as_str()).unwrap_or("run");
438
439    match action {
440        "run" => {
441            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
442            let is_json = args.iter().any(|a| a == "--json");
443
444            let result = benchmark::run_project_benchmark(path);
445            if is_json {
446                println!("{}", benchmark::format_json(&result));
447            } else {
448                println!("{}", benchmark::format_terminal(&result));
449            }
450        }
451        "report" => {
452            let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
453            let result = benchmark::run_project_benchmark(path);
454            println!("{}", benchmark::format_markdown(&result));
455        }
456        _ => {
457            if std::path::Path::new(action).exists() {
458                let result = benchmark::run_project_benchmark(action);
459                println!("{}", benchmark::format_terminal(&result));
460            } else {
461                eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
462                eprintln!("       lean-ctx benchmark report [path]");
463                std::process::exit(1);
464            }
465        }
466    }
467}
468
469fn format_tokens_cli(tokens: u64) -> String {
470    if tokens >= 1_000_000 {
471        format!("{:.1}M", tokens as f64 / 1_000_000.0)
472    } else if tokens >= 1_000 {
473        format!("{:.1}K", tokens as f64 / 1_000.0)
474    } else {
475        format!("{tokens}")
476    }
477}
478
479pub fn cmd_config(args: &[String]) {
480    let cfg = config::Config::load();
481
482    if args.is_empty() {
483        println!("{}", cfg.show());
484        return;
485    }
486
487    match args[0].as_str() {
488        "init" | "create" => {
489            let default = config::Config::default();
490            match default.save() {
491                Ok(()) => {
492                    let path = config::Config::path()
493                        .map(|p| p.to_string_lossy().to_string())
494                        .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
495                    println!("Created default config at {path}");
496                }
497                Err(e) => eprintln!("Error: {e}"),
498            }
499        }
500        "set" => {
501            if args.len() < 3 {
502                eprintln!("Usage: lean-ctx config set <key> <value>");
503                std::process::exit(1);
504            }
505            let mut cfg = cfg;
506            let key = &args[1];
507            let val = &args[2];
508            match key.as_str() {
509                "ultra_compact" => cfg.ultra_compact = val == "true",
510                "tee_on_error" => cfg.tee_on_error = val == "true",
511                "checkpoint_interval" => {
512                    cfg.checkpoint_interval = val.parse().unwrap_or(15);
513                }
514                _ => {
515                    eprintln!("Unknown config key: {key}");
516                    std::process::exit(1);
517                }
518            }
519            match cfg.save() {
520                Ok(()) => println!("Updated {key} = {val}"),
521                Err(e) => eprintln!("Error saving config: {e}"),
522            }
523        }
524        _ => {
525            eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
526            std::process::exit(1);
527        }
528    }
529}
530
531pub fn cmd_cheatsheet() {
532    println!(
533        "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
534\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
535\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m
536
537\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
538  ctx_session load               \x1b[2m# restore previous session\x1b[0m
539  ctx_overview task=\"...\"         \x1b[2m# task-aware file map\x1b[0m
540  ctx_graph action=build          \x1b[2m# index project (first time)\x1b[0m
541  ctx_knowledge action=recall     \x1b[2m# check stored project facts\x1b[0m
542
543\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
544  ctx_read mode=full    \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
545  ctx_read mode=map     \x1b[2m# context-only files (~93% saved)\x1b[0m
546  ctx_read mode=diff    \x1b[2m# after editing (~98% saved)\x1b[0m
547  ctx_read mode=sigs    \x1b[2m# API surface of large files (~95%)\x1b[0m
548  ctx_multi_read        \x1b[2m# read multiple files at once\x1b[0m
549  ctx_search            \x1b[2m# search with compressed results (~70%)\x1b[0m
550  ctx_shell             \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
551
552\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
553  ctx_session finding \"...\"       \x1b[2m# record what you discovered\x1b[0m
554  ctx_session decision \"...\"      \x1b[2m# record architectural choices\x1b[0m
555  ctx_knowledge action=remember   \x1b[2m# store permanent project facts\x1b[0m
556  ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
557  ctx_metrics                     \x1b[2m# see session statistics\x1b[0m
558
559\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
560  ctx_agent action=register       \x1b[2m# announce yourself\x1b[0m
561  ctx_agent action=list           \x1b[2m# see other active agents\x1b[0m
562  ctx_agent action=post           \x1b[2m# share findings\x1b[0m
563  ctx_agent action=read           \x1b[2m# check messages\x1b[0m
564
565\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
566  Will edit?  → \x1b[1mfull\x1b[0m (re-reads: 13 tokens)  → after edit: \x1b[1mdiff\x1b[0m
567  API only?   → \x1b[1msignatures\x1b[0m
568  Deps/exports? → \x1b[1mmap\x1b[0m
569  Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
570  Browsing?   → \x1b[1maggressive\x1b[0m (syntax stripped)
571
572\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
573  lean-ctx gain          \x1b[2m# visual savings dashboard\x1b[0m
574  lean-ctx gain --live   \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
575  lean-ctx dashboard     \x1b[2m# web dashboard with charts\x1b[0m
576  lean-ctx wrapped       \x1b[2m# weekly savings report\x1b[0m
577  lean-ctx discover      \x1b[2m# find uncompressed commands\x1b[0m
578  lean-ctx doctor        \x1b[2m# diagnose installation\x1b[0m
579  lean-ctx update        \x1b[2m# self-update to latest\x1b[0m
580
581\x1b[2m  Full guide: https://leanctx.com/docs/workflow\x1b[0m"
582    );
583}
584
585pub fn cmd_slow_log(args: &[String]) {
586    use crate::core::slow_log;
587
588    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
589    match action {
590        "list" | "ls" | "" => println!("{}", slow_log::list()),
591        "clear" | "purge" => println!("{}", slow_log::clear()),
592        _ => {
593            eprintln!("Usage: lean-ctx slow-log [list|clear]");
594            std::process::exit(1);
595        }
596    }
597}
598
599pub fn cmd_tee(args: &[String]) {
600    let tee_dir = match dirs::home_dir() {
601        Some(h) => h.join(".lean-ctx").join("tee"),
602        None => {
603            eprintln!("Cannot determine home directory");
604            std::process::exit(1);
605        }
606    };
607
608    let action = args.first().map(|s| s.as_str()).unwrap_or("list");
609    match action {
610        "list" | "ls" => {
611            if !tee_dir.exists() {
612                println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
613                return;
614            }
615            let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
616                .unwrap_or_else(|e| {
617                    eprintln!("Error: {e}");
618                    std::process::exit(1);
619                })
620                .filter_map(|e| e.ok())
621                .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
622                .collect();
623            entries.sort_by_key(|e| e.file_name());
624
625            if entries.is_empty() {
626                println!("No tee logs found.");
627                return;
628            }
629
630            println!("Tee logs ({}):\n", entries.len());
631            for entry in &entries {
632                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
633                let name = entry.file_name();
634                let size_str = if size > 1024 {
635                    format!("{}K", size / 1024)
636                } else {
637                    format!("{}B", size)
638                };
639                println!("  {:<60} {}", name.to_string_lossy(), size_str);
640            }
641            println!("\nUse 'lean-ctx tee clear' to delete all logs.");
642        }
643        "clear" | "purge" => {
644            if !tee_dir.exists() {
645                println!("No tee logs to clear.");
646                return;
647            }
648            let mut count = 0u32;
649            if let Ok(entries) = std::fs::read_dir(&tee_dir) {
650                for entry in entries.flatten() {
651                    if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
652                        && std::fs::remove_file(entry.path()).is_ok()
653                    {
654                        count += 1;
655                    }
656                }
657            }
658            println!("Cleared {count} tee log(s) from {}", tee_dir.display());
659        }
660        "show" => {
661            let filename = args.get(1);
662            if filename.is_none() {
663                eprintln!("Usage: lean-ctx tee show <filename>");
664                std::process::exit(1);
665            }
666            let path = tee_dir.join(filename.unwrap());
667            match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
668                Ok(content) => print!("{content}"),
669                Err(e) => {
670                    eprintln!("Error reading {}: {e}", path.display());
671                    std::process::exit(1);
672                }
673            }
674        }
675        _ => {
676            eprintln!("Usage: lean-ctx tee [list|clear|show <file>]");
677            std::process::exit(1);
678        }
679    }
680}
681
682pub fn cmd_init(args: &[String]) {
683    let global = args.iter().any(|a| a == "--global" || a == "-g");
684
685    let agents: Vec<&str> = args
686        .windows(2)
687        .filter(|w| w[0] == "--agent")
688        .map(|w| w[1].as_str())
689        .collect();
690
691    if !agents.is_empty() {
692        for agent_name in &agents {
693            crate::hooks::install_agent_hook(agent_name, global);
694        }
695        println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
696        return;
697    }
698
699    let shell_name = std::env::var("SHELL").unwrap_or_default();
700    let is_zsh = shell_name.contains("zsh");
701    let is_fish = shell_name.contains("fish");
702    let is_powershell = cfg!(windows) && shell_name.is_empty();
703
704    let binary = std::env::current_exe()
705        .map(|p| p.to_string_lossy().to_string())
706        .unwrap_or_else(|_| "lean-ctx".to_string());
707
708    if is_powershell {
709        init_powershell(&binary);
710    } else {
711        let bash_binary = to_bash_compatible_path(&binary);
712        if is_fish {
713            init_fish(&bash_binary);
714        } else {
715            init_posix(is_zsh, &bash_binary);
716        }
717    }
718
719    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
720    if let Some(dir) = lean_dir {
721        if !dir.exists() {
722            let _ = std::fs::create_dir_all(&dir);
723            println!("Created {}", dir.display());
724        }
725    }
726
727    if global && !is_powershell {
728        let rc = if is_fish {
729            "config.fish"
730        } else if is_zsh {
731            ".zshrc"
732        } else {
733            ".bashrc"
734        };
735        println!("\nRestart your shell or run: source ~/{rc}");
736    } else if global && is_powershell {
737        println!("\nRestart PowerShell or run: . $PROFILE");
738    }
739
740    println!("\nlean-ctx init complete. (23 aliases installed)");
741    println!("Binary: {binary}");
742    println!("\nFor AI tool integration, use: lean-ctx init --agent <tool>");
743    println!("  Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, pi");
744    println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
745    println!("Run 'lean-ctx discover' to find missed savings in your shell history.");
746}
747
748fn init_powershell(binary: &str) {
749    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
750    let profile_path = match profile_dir {
751        Some(dir) => {
752            let _ = std::fs::create_dir_all(&dir);
753            dir.join("Microsoft.PowerShell_profile.ps1")
754        }
755        None => {
756            eprintln!("Could not resolve PowerShell profile directory");
757            return;
758        }
759    };
760
761    let binary_escaped = binary.replace('\\', "\\\\");
762    let functions = format!(
763        r#"
764# lean-ctx shell hook — transparent CLI compression (90+ patterns)
765if (-not $env:LEAN_CTX_ACTIVE) {{
766  $LeanCtxBin = "{binary_escaped}"
767  function git {{ & $LeanCtxBin -c "git $($args -join ' ')" }}
768  function npm {{ & $LeanCtxBin -c "npm $($args -join ' ')" }}
769  function pnpm {{ & $LeanCtxBin -c "pnpm $($args -join ' ')" }}
770  function yarn {{ & $LeanCtxBin -c "yarn $($args -join ' ')" }}
771  function cargo {{ & $LeanCtxBin -c "cargo $($args -join ' ')" }}
772  function docker {{ & $LeanCtxBin -c "docker $($args -join ' ')" }}
773  function kubectl {{ & $LeanCtxBin -c "kubectl $($args -join ' ')" }}
774  function gh {{ & $LeanCtxBin -c "gh $($args -join ' ')" }}
775  function pip {{ & $LeanCtxBin -c "pip $($args -join ' ')" }}
776  function pip3 {{ & $LeanCtxBin -c "pip3 $($args -join ' ')" }}
777  function ruff {{ & $LeanCtxBin -c "ruff $($args -join ' ')" }}
778  function go {{ & $LeanCtxBin -c "go $($args -join ' ')" }}
779  function eslint {{ & $LeanCtxBin -c "eslint $($args -join ' ')" }}
780  function prettier {{ & $LeanCtxBin -c "prettier $($args -join ' ')" }}
781  function tsc {{ & $LeanCtxBin -c "tsc $($args -join ' ')" }}
782  function curl {{ & $LeanCtxBin -c "curl $($args -join ' ')" }}
783  function wget {{ & $LeanCtxBin -c "wget $($args -join ' ')" }}
784}}
785"#
786    );
787
788    if let Ok(existing) = std::fs::read_to_string(&profile_path) {
789        if existing.contains("lean-ctx shell hook") {
790            let cleaned = remove_lean_ctx_block_ps(&existing);
791            match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
792                Ok(()) => {
793                    println!("Updated lean-ctx functions in {}", profile_path.display());
794                    println!("  Binary: {binary}");
795                    return;
796                }
797                Err(e) => {
798                    eprintln!("Error updating {}: {e}", profile_path.display());
799                    return;
800                }
801            }
802        }
803    }
804
805    match std::fs::OpenOptions::new()
806        .append(true)
807        .create(true)
808        .open(&profile_path)
809    {
810        Ok(mut f) => {
811            use std::io::Write;
812            let _ = f.write_all(functions.as_bytes());
813            println!("Added lean-ctx functions to {}", profile_path.display());
814            println!("  Binary: {binary}");
815        }
816        Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
817    }
818}
819
820fn remove_lean_ctx_block_ps(content: &str) -> String {
821    let mut result = String::new();
822    let mut in_block = false;
823    let mut brace_depth = 0i32;
824
825    for line in content.lines() {
826        if line.contains("lean-ctx shell hook") {
827            in_block = true;
828            continue;
829        }
830        if in_block {
831            brace_depth += line.matches('{').count() as i32;
832            brace_depth -= line.matches('}').count() as i32;
833            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
834                if line.trim() == "}" {
835                    in_block = false;
836                    brace_depth = 0;
837                }
838                continue;
839            }
840            continue;
841        }
842        result.push_str(line);
843        result.push('\n');
844    }
845    result
846}
847
848fn init_fish(binary: &str) {
849    let config = dirs::home_dir()
850        .map(|h| h.join(".config/fish/config.fish"))
851        .unwrap_or_default();
852
853    let aliases = format!(
854        "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
855        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\
856        \n\
857        function lean-ctx-on\n\
858        \tfor _lc_cmd in $_lean_ctx_cmds\n\
859        \t\talias $_lc_cmd '{binary} -c '$_lc_cmd\n\
860        \tend\n\
861        \talias k '{binary} -c kubectl'\n\
862        \tset -gx LEAN_CTX_ENABLED 1\n\
863        \techo 'lean-ctx: ON'\n\
864        end\n\
865        \n\
866        function lean-ctx-off\n\
867        \tfor _lc_cmd in $_lean_ctx_cmds\n\
868        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
869        \tend\n\
870        \tfunctions --erase k 2>/dev/null; true\n\
871        \tset -e LEAN_CTX_ENABLED\n\
872        \techo 'lean-ctx: OFF'\n\
873        end\n\
874        \n\
875        function lean-ctx-status\n\
876        \tif set -q LEAN_CTX_ENABLED\n\
877        \t\techo 'lean-ctx: ON'\n\
878        \telse\n\
879        \t\techo 'lean-ctx: OFF'\n\
880        \tend\n\
881        end\n\
882        \n\
883        if not set -q LEAN_CTX_ACTIVE; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
884        \tlean-ctx-on\n\
885        end\n\
886        # lean-ctx shell hook — end\n"
887    );
888
889    if let Ok(existing) = std::fs::read_to_string(&config) {
890        if existing.contains("lean-ctx shell hook") {
891            let cleaned = remove_lean_ctx_block(&existing);
892            match std::fs::write(&config, format!("{cleaned}{aliases}")) {
893                Ok(()) => {
894                    println!("Updated lean-ctx aliases in {}", config.display());
895                    println!("  Binary: {binary}");
896                    return;
897                }
898                Err(e) => {
899                    eprintln!("Error updating {}: {e}", config.display());
900                    return;
901                }
902            }
903        }
904    }
905
906    match std::fs::OpenOptions::new()
907        .append(true)
908        .create(true)
909        .open(&config)
910    {
911        Ok(mut f) => {
912            use std::io::Write;
913            let _ = f.write_all(aliases.as_bytes());
914            println!("Added lean-ctx aliases to {}", config.display());
915            println!("  Binary: {binary}");
916        }
917        Err(e) => eprintln!("Error writing {}: {e}", config.display()),
918    }
919}
920
921fn init_posix(is_zsh: bool, binary: &str) {
922    let rc_file = if is_zsh {
923        dirs::home_dir()
924            .map(|h| h.join(".zshrc"))
925            .unwrap_or_default()
926    } else {
927        dirs::home_dir()
928            .map(|h| h.join(".bashrc"))
929            .unwrap_or_default()
930    };
931
932    let aliases = format!(
933        r#"
934# lean-ctx shell hook — transparent CLI compression (90+ patterns)
935_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)
936
937lean-ctx-on() {{
938    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
939        # shellcheck disable=SC2139
940        alias "$_lc_cmd"='{binary} -c '"$_lc_cmd"
941    done
942    alias k='{binary} -c kubectl'
943    export LEAN_CTX_ENABLED=1
944    echo "lean-ctx: ON"
945}}
946
947lean-ctx-off() {{
948    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
949        unalias "$_lc_cmd" 2>/dev/null || true
950    done
951    unalias k 2>/dev/null || true
952    unset LEAN_CTX_ENABLED
953    echo "lean-ctx: OFF"
954}}
955
956lean-ctx-status() {{
957    if [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
958        echo "lean-ctx: ON"
959    else
960        echo "lean-ctx: OFF"
961    fi
962}}
963
964if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
965    lean-ctx-on
966fi
967# lean-ctx shell hook — end
968"#
969    );
970
971    if let Ok(existing) = std::fs::read_to_string(&rc_file) {
972        if existing.contains("lean-ctx shell hook") {
973            let cleaned = remove_lean_ctx_block(&existing);
974            match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
975                Ok(()) => {
976                    println!("Updated lean-ctx aliases in {}", rc_file.display());
977                    println!("  Binary: {binary}");
978                    return;
979                }
980                Err(e) => {
981                    eprintln!("Error updating {}: {e}", rc_file.display());
982                    return;
983                }
984            }
985        }
986    }
987
988    match std::fs::OpenOptions::new()
989        .append(true)
990        .create(true)
991        .open(&rc_file)
992    {
993        Ok(mut f) => {
994            use std::io::Write;
995            let _ = f.write_all(aliases.as_bytes());
996            println!("Added lean-ctx aliases to {}", rc_file.display());
997            println!("  Binary: {binary}");
998        }
999        Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1000    }
1001}
1002
1003fn remove_lean_ctx_block(content: &str) -> String {
1004    // New format uses explicit end marker; old format ends at first top-level `fi`/`end`.
1005    if content.contains("# lean-ctx shell hook — end") {
1006        return remove_lean_ctx_block_by_marker(content);
1007    }
1008    remove_lean_ctx_block_legacy(content)
1009}
1010
1011fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1012    let mut result = String::new();
1013    let mut in_block = false;
1014
1015    for line in content.lines() {
1016        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1017            in_block = true;
1018            continue;
1019        }
1020        if in_block {
1021            if line.trim() == "# lean-ctx shell hook — end" {
1022                in_block = false;
1023            }
1024            continue;
1025        }
1026        result.push_str(line);
1027        result.push('\n');
1028    }
1029    result
1030}
1031
1032fn remove_lean_ctx_block_legacy(content: &str) -> String {
1033    let mut result = String::new();
1034    let mut in_block = false;
1035
1036    for line in content.lines() {
1037        if line.contains("lean-ctx shell hook") {
1038            in_block = true;
1039            continue;
1040        }
1041        if in_block {
1042            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1043                if line.trim() == "fi" || line.trim() == "end" {
1044                    in_block = false;
1045                }
1046                continue;
1047            }
1048            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1049                in_block = false;
1050                result.push_str(line);
1051                result.push('\n');
1052            }
1053            continue;
1054        }
1055        result.push_str(line);
1056        result.push('\n');
1057    }
1058    result
1059}
1060
1061pub fn load_shell_history_pub() -> Vec<String> {
1062    load_shell_history()
1063}
1064
1065fn load_shell_history() -> Vec<String> {
1066    let shell = std::env::var("SHELL").unwrap_or_default();
1067    let home = match dirs::home_dir() {
1068        Some(h) => h,
1069        None => return Vec::new(),
1070    };
1071
1072    let history_file = if shell.contains("zsh") {
1073        home.join(".zsh_history")
1074    } else if shell.contains("fish") {
1075        home.join(".local/share/fish/fish_history")
1076    } else if cfg!(windows) && shell.is_empty() {
1077        home.join("AppData")
1078            .join("Roaming")
1079            .join("Microsoft")
1080            .join("Windows")
1081            .join("PowerShell")
1082            .join("PSReadLine")
1083            .join("ConsoleHost_history.txt")
1084    } else {
1085        home.join(".bash_history")
1086    };
1087
1088    match std::fs::read_to_string(&history_file) {
1089        Ok(content) => content
1090            .lines()
1091            .filter_map(|l| {
1092                let trimmed = l.trim();
1093                if trimmed.starts_with(':') {
1094                    trimmed.split(';').nth(1).map(|s| s.to_string())
1095                } else {
1096                    Some(trimmed.to_string())
1097                }
1098            })
1099            .filter(|l| !l.is_empty())
1100            .collect(),
1101        Err(_) => Vec::new(),
1102    }
1103}
1104
1105fn print_savings(original: usize, sent: usize) {
1106    let saved = original.saturating_sub(sent);
1107    if original > 0 && saved > 0 {
1108        let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1109        println!("[{saved} tok saved ({pct}%)]");
1110    }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116
1117    #[test]
1118    fn test_remove_lean_ctx_block_posix() {
1119        let input = r#"# existing config
1120export PATH="$HOME/bin:$PATH"
1121
1122# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1123if [ -z "$LEAN_CTX_ACTIVE" ]; then
1124alias git='lean-ctx -c git'
1125alias npm='lean-ctx -c npm'
1126fi
1127
1128# other stuff
1129export EDITOR=vim
1130"#;
1131        let result = remove_lean_ctx_block(input);
1132        assert!(!result.contains("lean-ctx"), "block should be removed");
1133        assert!(result.contains("export PATH"), "other content preserved");
1134        assert!(
1135            result.contains("export EDITOR"),
1136            "trailing content preserved"
1137        );
1138    }
1139
1140    #[test]
1141    fn test_remove_lean_ctx_block_fish() {
1142        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";
1143        let result = remove_lean_ctx_block(input);
1144        assert!(!result.contains("lean-ctx"), "block should be removed");
1145        assert!(result.contains("set -x FOO"), "other content preserved");
1146        assert!(result.contains("set -x BAZ"), "trailing content preserved");
1147    }
1148
1149    #[test]
1150    fn test_remove_lean_ctx_block_ps() {
1151        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";
1152        let result = remove_lean_ctx_block_ps(input);
1153        assert!(
1154            !result.contains("lean-ctx shell hook"),
1155            "block should be removed"
1156        );
1157        assert!(result.contains("$env:FOO"), "other content preserved");
1158        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1159    }
1160
1161    #[test]
1162    fn test_remove_block_no_lean_ctx() {
1163        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1164        let result = remove_lean_ctx_block(input);
1165        assert!(result.contains("export PATH"), "content unchanged");
1166    }
1167
1168    #[test]
1169    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1170        let input = r#"# existing config
1171export PATH="$HOME/bin:$PATH"
1172
1173# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1174_lean_ctx_cmds=(git npm pnpm)
1175
1176lean-ctx-on() {
1177    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1178        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1179    done
1180    export LEAN_CTX_ENABLED=1
1181    echo "lean-ctx: ON"
1182}
1183
1184lean-ctx-off() {
1185    unset LEAN_CTX_ENABLED
1186    echo "lean-ctx: OFF"
1187}
1188
1189if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1190    lean-ctx-on
1191fi
1192# lean-ctx shell hook — end
1193
1194# other stuff
1195export EDITOR=vim
1196"#;
1197        let result = remove_lean_ctx_block(input);
1198        assert!(!result.contains("lean-ctx-on"), "block should be removed");
1199        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1200        assert!(result.contains("export PATH"), "other content preserved");
1201        assert!(
1202            result.contains("export EDITOR"),
1203            "trailing content preserved"
1204        );
1205    }
1206}