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 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}