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!(
18 "Usage: lean-ctx read <file> [--mode full|map|signatures|aggressive|entropy] [--fresh]"
19 );
20 std::process::exit(1);
21 }
22
23 let path = &args[0];
24 let mode = args
25 .iter()
26 .position(|a| a == "--mode" || a == "-m")
27 .and_then(|i| args.get(i + 1))
28 .map(|s| s.as_str())
29 .unwrap_or("full");
30 let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
31
32 let short = protocol::shorten_path(path);
33
34 if !force_fresh && mode == "full" {
35 use crate::core::cli_cache::{self, CacheResult};
36 match cli_cache::check_and_read(path) {
37 CacheResult::Hit { entry, file_ref } => {
38 let msg = cli_cache::format_hit(&entry, &file_ref, &short);
39 println!("{msg}");
40 stats::record("cli_read", entry.original_tokens, count_tokens(&msg));
41 return;
42 }
43 CacheResult::Miss { content } if content.is_empty() => {
44 eprintln!("Error: could not read {path}");
45 std::process::exit(1);
46 }
47 CacheResult::Miss { content } => {
48 let line_count = content.lines().count();
49 println!("{short} [{line_count}L]");
50 println!("{content}");
51 stats::record("cli_read", count_tokens(&content), count_tokens(&content));
52 return;
53 }
54 }
55 }
56
57 let content = match crate::tools::ctx_read::read_file_lossy(path) {
58 Ok(c) => c,
59 Err(e) => {
60 eprintln!("Error: {e}");
61 std::process::exit(1);
62 }
63 };
64
65 let ext = Path::new(path)
66 .extension()
67 .and_then(|e| e.to_str())
68 .unwrap_or("");
69 let line_count = content.lines().count();
70 let original_tokens = count_tokens(&content);
71
72 let mode = if mode == "auto" {
73 let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
74 let predictor = crate::core::mode_predictor::ModePredictor::new();
75 predictor
76 .predict_best_mode(&sig)
77 .unwrap_or_else(|| "full".to_string())
78 } else {
79 mode.to_string()
80 };
81 let mode = mode.as_str();
82
83 match mode {
84 "map" => {
85 let sigs = signatures::extract_signatures(&content, ext);
86 let dep_info = dep_extract::extract_deps(&content, ext);
87
88 println!("{short} [{line_count}L]");
89 if !dep_info.imports.is_empty() {
90 println!(" deps: {}", dep_info.imports.join(", "));
91 }
92 if !dep_info.exports.is_empty() {
93 println!(" exports: {}", dep_info.exports.join(", "));
94 }
95 let key_sigs: Vec<_> = sigs
96 .iter()
97 .filter(|s| s.is_exported || s.indent == 0)
98 .collect();
99 if !key_sigs.is_empty() {
100 println!(" API:");
101 for sig in &key_sigs {
102 println!(" {}", sig.to_compact());
103 }
104 }
105 let sent = count_tokens(&short.to_string());
106 print_savings(original_tokens, sent);
107 }
108 "signatures" => {
109 let sigs = signatures::extract_signatures(&content, ext);
110 println!("{short} [{line_count}L]");
111 for sig in &sigs {
112 println!("{}", sig.to_compact());
113 }
114 let sent = count_tokens(&short.to_string());
115 print_savings(original_tokens, sent);
116 }
117 "aggressive" => {
118 let compressed = compressor::aggressive_compress(&content, Some(ext));
119 println!("{short} [{line_count}L]");
120 println!("{compressed}");
121 let sent = count_tokens(&compressed);
122 print_savings(original_tokens, sent);
123 }
124 "entropy" => {
125 let result = entropy::entropy_compress(&content);
126 let avg_h = entropy::analyze_entropy(&content).avg_entropy;
127 println!("{short} [{line_count}L] (H̄={avg_h:.1})");
128 for tech in &result.techniques {
129 println!("{tech}");
130 }
131 println!("{}", result.output);
132 let sent = count_tokens(&result.output);
133 print_savings(original_tokens, sent);
134 }
135 _ => {
136 println!("{short} [{line_count}L]");
137 println!("{content}");
138 }
139 }
140}
141
142pub fn cmd_diff(args: &[String]) {
143 if args.len() < 2 {
144 eprintln!("Usage: lean-ctx diff <file1> <file2>");
145 std::process::exit(1);
146 }
147
148 let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
149 Ok(c) => c,
150 Err(e) => {
151 eprintln!("Error reading {}: {e}", args[0]);
152 std::process::exit(1);
153 }
154 };
155
156 let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
157 Ok(c) => c,
158 Err(e) => {
159 eprintln!("Error reading {}: {e}", args[1]);
160 std::process::exit(1);
161 }
162 };
163
164 let diff = compressor::diff_content(&content1, &content2);
165 let original = count_tokens(&content1) + count_tokens(&content2);
166 let sent = count_tokens(&diff);
167
168 println!(
169 "diff {} {}",
170 protocol::shorten_path(&args[0]),
171 protocol::shorten_path(&args[1])
172 );
173 println!("{diff}");
174 print_savings(original, sent);
175}
176
177pub fn cmd_grep(args: &[String]) {
178 if args.is_empty() {
179 eprintln!("Usage: lean-ctx grep <pattern> [path]");
180 std::process::exit(1);
181 }
182
183 let pattern = &args[0];
184 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
185
186 let re = match regex::Regex::new(pattern) {
187 Ok(r) => r,
188 Err(e) => {
189 eprintln!("Invalid regex pattern: {e}");
190 std::process::exit(1);
191 }
192 };
193
194 let mut found = false;
195 for entry in ignore::WalkBuilder::new(path)
196 .hidden(true)
197 .git_ignore(true)
198 .git_global(true)
199 .git_exclude(true)
200 .max_depth(Some(10))
201 .build()
202 .flatten()
203 {
204 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
205 continue;
206 }
207 let file_path = entry.path();
208 if let Ok(content) = std::fs::read_to_string(file_path) {
209 for (i, line) in content.lines().enumerate() {
210 if re.is_match(line) {
211 println!("{}:{}:{}", file_path.display(), i + 1, line);
212 found = true;
213 }
214 }
215 }
216 }
217
218 if !found {
219 std::process::exit(1);
220 }
221}
222
223pub fn cmd_find(args: &[String]) {
224 if args.is_empty() {
225 eprintln!("Usage: lean-ctx find <pattern> [path]");
226 std::process::exit(1);
227 }
228
229 let raw_pattern = &args[0];
230 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
231
232 let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
233 let glob_matcher = if is_glob {
234 glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
235 } else {
236 None
237 };
238 let substring = raw_pattern.to_lowercase();
239
240 let mut found = false;
241 for entry in ignore::WalkBuilder::new(path)
242 .hidden(true)
243 .git_ignore(true)
244 .git_global(true)
245 .git_exclude(true)
246 .max_depth(Some(10))
247 .build()
248 .flatten()
249 {
250 let name = entry.file_name().to_string_lossy().to_lowercase();
251 let matches = if let Some(ref g) = glob_matcher {
252 g.matches(&name)
253 } else {
254 name.contains(&substring)
255 };
256 if matches {
257 println!("{}", entry.path().display());
258 found = true;
259 }
260 }
261
262 if !found {
263 std::process::exit(1);
264 }
265}
266
267pub fn cmd_ls(args: &[String]) {
268 let path = args.first().map(|s| s.as_str()).unwrap_or(".");
269 let command = if cfg!(windows) {
270 format!("dir {}", path.replace('/', "\\"))
271 } else {
272 format!("ls -la {path}")
273 };
274 let code = crate::shell::exec(&command);
275 std::process::exit(code);
276}
277
278pub fn cmd_deps(args: &[String]) {
279 let path = args.first().map(|s| s.as_str()).unwrap_or(".");
280
281 match deps_cmd::detect_and_compress(path) {
282 Some(result) => println!("{result}"),
283 None => {
284 eprintln!("No dependency file found in {path}");
285 std::process::exit(1);
286 }
287 }
288}
289
290pub fn cmd_discover(_args: &[String]) {
291 let history = load_shell_history();
292 if history.is_empty() {
293 println!("No shell history found.");
294 return;
295 }
296
297 let result = crate::tools::ctx_discover::analyze_history(&history, 20);
298 println!("{}", crate::tools::ctx_discover::format_cli_output(&result));
299}
300
301pub fn cmd_session() {
302 let history = load_shell_history();
303 let gain = stats::load_stats();
304
305 let compressible_commands = [
306 "git ",
307 "npm ",
308 "yarn ",
309 "pnpm ",
310 "cargo ",
311 "docker ",
312 "kubectl ",
313 "gh ",
314 "pip ",
315 "pip3 ",
316 "eslint",
317 "prettier",
318 "ruff ",
319 "go ",
320 "golangci-lint",
321 "curl ",
322 "wget ",
323 "grep ",
324 "rg ",
325 "find ",
326 "ls ",
327 ];
328
329 let mut total = 0u32;
330 let mut via_hook = 0u32;
331
332 for line in &history {
333 let cmd = line.trim().to_lowercase();
334 if cmd.starts_with("lean-ctx") {
335 via_hook += 1;
336 total += 1;
337 } else {
338 for p in &compressible_commands {
339 if cmd.starts_with(p) {
340 total += 1;
341 break;
342 }
343 }
344 }
345 }
346
347 let pct = if total > 0 {
348 (via_hook as f64 / total as f64 * 100.0).round() as u32
349 } else {
350 0
351 };
352
353 println!("lean-ctx session statistics\n");
354 println!(
355 "Adoption: {}% ({}/{} compressible commands)",
356 pct, via_hook, total
357 );
358 println!("Saved: {} tokens total", gain.total_saved);
359 println!("Calls: {} compressed", gain.total_calls);
360
361 if total > via_hook {
362 let missed = total - via_hook;
363 let est = missed * 150;
364 println!(
365 "Missed: {} commands (~{} tokens saveable)",
366 missed, est
367 );
368 }
369
370 println!("\nRun 'lean-ctx discover' for details on missed commands.");
371}
372
373pub fn cmd_wrapped(args: &[String]) {
374 let period = if args.iter().any(|a| a == "--month") {
375 "month"
376 } else if args.iter().any(|a| a == "--all") {
377 "all"
378 } else {
379 "week"
380 };
381
382 let report = crate::core::wrapped::WrappedReport::generate(period);
383 println!("{}", report.format_ascii());
384}
385
386pub fn cmd_sessions(args: &[String]) {
387 use crate::core::session::SessionState;
388
389 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
390
391 match action {
392 "list" | "ls" => {
393 let sessions = SessionState::list_sessions();
394 if sessions.is_empty() {
395 println!("No sessions found.");
396 return;
397 }
398 println!("Sessions ({}):\n", sessions.len());
399 for s in sessions.iter().take(20) {
400 let task = s.task.as_deref().unwrap_or("(no task)");
401 let task_short: String = task.chars().take(50).collect();
402 let date = s.updated_at.format("%Y-%m-%d %H:%M");
403 println!(
404 " {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
405 s.id,
406 s.version,
407 s.tool_calls,
408 format_tokens_cli(s.tokens_saved),
409 date,
410 task_short
411 );
412 }
413 if sessions.len() > 20 {
414 println!(" ... +{} more", sessions.len() - 20);
415 }
416 }
417 "show" => {
418 let id = args.get(1);
419 let session = if let Some(id) = id {
420 SessionState::load_by_id(id)
421 } else {
422 SessionState::load_latest()
423 };
424 match session {
425 Some(s) => println!("{}", s.format_compact()),
426 None => println!("Session not found."),
427 }
428 }
429 "cleanup" => {
430 let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
431 let removed = SessionState::cleanup_old_sessions(days);
432 println!("Cleaned up {removed} session(s) older than {days} days.");
433 }
434 _ => {
435 eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
436 std::process::exit(1);
437 }
438 }
439}
440
441pub fn cmd_benchmark(args: &[String]) {
442 use crate::core::benchmark;
443
444 let action = args.first().map(|s| s.as_str()).unwrap_or("run");
445
446 match action {
447 "run" => {
448 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
449 let is_json = args.iter().any(|a| a == "--json");
450
451 let result = benchmark::run_project_benchmark(path);
452 if is_json {
453 println!("{}", benchmark::format_json(&result));
454 } else {
455 println!("{}", benchmark::format_terminal(&result));
456 }
457 }
458 "report" => {
459 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
460 let result = benchmark::run_project_benchmark(path);
461 println!("{}", benchmark::format_markdown(&result));
462 }
463 _ => {
464 if std::path::Path::new(action).exists() {
465 let result = benchmark::run_project_benchmark(action);
466 println!("{}", benchmark::format_terminal(&result));
467 } else {
468 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
469 eprintln!(" lean-ctx benchmark report [path]");
470 std::process::exit(1);
471 }
472 }
473 }
474}
475
476fn format_tokens_cli(tokens: u64) -> String {
477 if tokens >= 1_000_000 {
478 format!("{:.1}M", tokens as f64 / 1_000_000.0)
479 } else if tokens >= 1_000 {
480 format!("{:.1}K", tokens as f64 / 1_000.0)
481 } else {
482 format!("{tokens}")
483 }
484}
485
486pub fn cmd_stats(args: &[String]) {
487 match args.first().map(|s| s.as_str()) {
488 Some("reset-cep") => {
489 crate::core::stats::reset_cep();
490 println!("CEP stats reset. Shell hook data preserved.");
491 }
492 Some("json") => {
493 let store = crate::core::stats::load();
494 println!(
495 "{}",
496 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
497 );
498 }
499 _ => {
500 let store = crate::core::stats::load();
501 let input_saved = store
502 .total_input_tokens
503 .saturating_sub(store.total_output_tokens);
504 let pct = if store.total_input_tokens > 0 {
505 input_saved as f64 / store.total_input_tokens as f64 * 100.0
506 } else {
507 0.0
508 };
509 println!("Commands: {}", store.total_commands);
510 println!("Input: {} tokens", store.total_input_tokens);
511 println!("Output: {} tokens", store.total_output_tokens);
512 println!("Saved: {} tokens ({:.1}%)", input_saved, pct);
513 println!();
514 println!("CEP sessions: {}", store.cep.sessions);
515 println!(
516 "CEP tokens: {} → {}",
517 store.cep.total_tokens_original, store.cep.total_tokens_compressed
518 );
519 println!();
520 println!("Subcommands: stats reset-cep | stats json");
521 }
522 }
523}
524
525pub fn cmd_cache(args: &[String]) {
526 use crate::core::cli_cache;
527 match args.first().map(|s| s.as_str()) {
528 Some("clear") => {
529 let count = cli_cache::clear();
530 println!("Cleared {count} cached entries.");
531 }
532 Some("reset") => {
533 let project_flag = args.get(1).map(|s| s.as_str()) == Some("--project");
534 if project_flag {
535 let root =
536 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root);
537 match root {
538 Some(root) => {
539 let count = cli_cache::clear_project(&root);
540 println!("Reset {count} cache entries for project: {root}");
541 }
542 None => {
543 eprintln!("No active project root found. Start a session first.");
544 std::process::exit(1);
545 }
546 }
547 } else {
548 let count = cli_cache::clear();
549 println!("Reset all {count} cache entries.");
550 }
551 }
552 Some("stats") => {
553 let (hits, reads, entries) = cli_cache::stats();
554 let rate = if reads > 0 {
555 (hits as f64 / reads as f64 * 100.0).round() as u32
556 } else {
557 0
558 };
559 println!("CLI Cache Stats:");
560 println!(" Entries: {entries}");
561 println!(" Reads: {reads}");
562 println!(" Hits: {hits}");
563 println!(" Hit Rate: {rate}%");
564 }
565 Some("invalidate") => {
566 if args.len() < 2 {
567 eprintln!("Usage: lean-ctx cache invalidate <path>");
568 std::process::exit(1);
569 }
570 cli_cache::invalidate(&args[1]);
571 println!("Invalidated cache for {}", args[1]);
572 }
573 _ => {
574 let (hits, reads, entries) = cli_cache::stats();
575 let rate = if reads > 0 {
576 (hits as f64 / reads as f64 * 100.0).round() as u32
577 } else {
578 0
579 };
580 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
581 println!();
582 println!("Subcommands:");
583 println!(" cache stats Show detailed stats");
584 println!(" cache clear Clear all cached entries");
585 println!(" cache reset Reset all cache (or --project for current project only)");
586 println!(" cache invalidate Remove specific file from cache");
587 }
588 }
589}
590
591pub fn cmd_config(args: &[String]) {
592 let cfg = config::Config::load();
593
594 if args.is_empty() {
595 println!("{}", cfg.show());
596 return;
597 }
598
599 match args[0].as_str() {
600 "init" | "create" => {
601 let default = config::Config::default();
602 match default.save() {
603 Ok(()) => {
604 let path = config::Config::path()
605 .map(|p| p.to_string_lossy().to_string())
606 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
607 println!("Created default config at {path}");
608 }
609 Err(e) => eprintln!("Error: {e}"),
610 }
611 }
612 "set" => {
613 if args.len() < 3 {
614 eprintln!("Usage: lean-ctx config set <key> <value>");
615 std::process::exit(1);
616 }
617 let mut cfg = cfg;
618 let key = &args[1];
619 let val = &args[2];
620 match key.as_str() {
621 "ultra_compact" => cfg.ultra_compact = val == "true",
622 "tee_on_error" | "tee_mode" => {
623 cfg.tee_mode = match val.as_str() {
624 "true" | "failures" => config::TeeMode::Failures,
625 "always" => config::TeeMode::Always,
626 "false" | "never" => config::TeeMode::Never,
627 _ => {
628 eprintln!("Valid tee_mode values: always, failures, never");
629 std::process::exit(1);
630 }
631 };
632 }
633 "checkpoint_interval" => {
634 cfg.checkpoint_interval = val.parse().unwrap_or(15);
635 }
636 "theme" => {
637 if theme::from_preset(val).is_some() || val == "custom" {
638 cfg.theme = val.to_string();
639 } else {
640 eprintln!(
641 "Unknown theme '{val}'. Available: {}",
642 theme::PRESET_NAMES.join(", ")
643 );
644 std::process::exit(1);
645 }
646 }
647 "slow_command_threshold_ms" => {
648 cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
649 }
650 "passthrough_urls" => {
651 cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
652 }
653 "rules_scope" => match val.as_str() {
654 "global" | "project" | "both" => {
655 cfg.rules_scope = Some(val.to_string());
656 }
657 _ => {
658 eprintln!("Valid rules_scope values: global, project, both");
659 std::process::exit(1);
660 }
661 },
662 _ => {
663 eprintln!("Unknown config key: {key}");
664 std::process::exit(1);
665 }
666 }
667 match cfg.save() {
668 Ok(()) => println!("Updated {key} = {val}"),
669 Err(e) => eprintln!("Error saving config: {e}"),
670 }
671 }
672 _ => {
673 eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
674 std::process::exit(1);
675 }
676 }
677}
678
679pub fn cmd_cheatsheet() {
680 let ver = env!("CARGO_PKG_VERSION");
681 let ver_pad = format!("v{ver}");
682 let header = format!(
683 "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
684\x1b[1;36m║\x1b[0m \x1b[1;37mlean-ctx Workflow Cheat Sheet\x1b[0m \x1b[2m{ver_pad:>6}\x1b[0m \x1b[1;36m║\x1b[0m
685\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m");
686 println!(
687 "{header}
688
689\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
690 ctx_session load \x1b[2m# restore previous session\x1b[0m
691 ctx_overview task=\"...\" \x1b[2m# task-aware file map\x1b[0m
692 ctx_graph action=build \x1b[2m# index project (first time)\x1b[0m
693 ctx_knowledge action=recall \x1b[2m# check stored project facts\x1b[0m
694
695\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
696 ctx_read mode=full \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
697 ctx_read mode=map \x1b[2m# context-only files (~93% saved)\x1b[0m
698 ctx_read mode=diff \x1b[2m# after editing (~98% saved)\x1b[0m
699 ctx_read mode=sigs \x1b[2m# API surface of large files (~95%)\x1b[0m
700 ctx_multi_read \x1b[2m# read multiple files at once\x1b[0m
701 ctx_search \x1b[2m# search with compressed results (~70%)\x1b[0m
702 ctx_shell \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
703
704\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
705 ctx_session finding \"...\" \x1b[2m# record what you discovered\x1b[0m
706 ctx_session decision \"...\" \x1b[2m# record architectural choices\x1b[0m
707 ctx_knowledge action=remember \x1b[2m# store permanent project facts\x1b[0m
708 ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
709 ctx_metrics \x1b[2m# see session statistics\x1b[0m
710
711\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
712 ctx_agent action=register \x1b[2m# announce yourself\x1b[0m
713 ctx_agent action=list \x1b[2m# see other active agents\x1b[0m
714 ctx_agent action=post \x1b[2m# share findings\x1b[0m
715 ctx_agent action=read \x1b[2m# check messages\x1b[0m
716
717\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
718 Will edit? → \x1b[1mfull\x1b[0m (re-reads: 13 tokens) → after edit: \x1b[1mdiff\x1b[0m
719 API only? → \x1b[1msignatures\x1b[0m
720 Deps/exports? → \x1b[1mmap\x1b[0m
721 Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
722 Browsing? → \x1b[1maggressive\x1b[0m (syntax stripped)
723
724\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
725 lean-ctx gain \x1b[2m# visual savings dashboard\x1b[0m
726 lean-ctx gain --live \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
727 lean-ctx dashboard \x1b[2m# web dashboard with charts\x1b[0m
728 lean-ctx wrapped \x1b[2m# weekly savings report\x1b[0m
729 lean-ctx discover \x1b[2m# find uncompressed commands\x1b[0m
730 lean-ctx doctor \x1b[2m# diagnose installation\x1b[0m
731 lean-ctx update \x1b[2m# self-update to latest\x1b[0m
732
733\x1b[2m Full guide: https://leanctx.com/docs/workflow\x1b[0m"
734 );
735}
736
737pub fn cmd_slow_log(args: &[String]) {
738 use crate::core::slow_log;
739
740 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
741 match action {
742 "list" | "ls" | "" => println!("{}", slow_log::list()),
743 "clear" | "purge" => println!("{}", slow_log::clear()),
744 _ => {
745 eprintln!("Usage: lean-ctx slow-log [list|clear]");
746 std::process::exit(1);
747 }
748 }
749}
750
751pub fn cmd_tee(args: &[String]) {
752 let tee_dir = match dirs::home_dir() {
753 Some(h) => h.join(".lean-ctx").join("tee"),
754 None => {
755 eprintln!("Cannot determine home directory");
756 std::process::exit(1);
757 }
758 };
759
760 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
761 match action {
762 "list" | "ls" => {
763 if !tee_dir.exists() {
764 println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
765 return;
766 }
767 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
768 .unwrap_or_else(|e| {
769 eprintln!("Error: {e}");
770 std::process::exit(1);
771 })
772 .filter_map(|e| e.ok())
773 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
774 .collect();
775 entries.sort_by_key(|e| e.file_name());
776
777 if entries.is_empty() {
778 println!("No tee logs found.");
779 return;
780 }
781
782 println!("Tee logs ({}):\n", entries.len());
783 for entry in &entries {
784 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
785 let name = entry.file_name();
786 let size_str = if size > 1024 {
787 format!("{}K", size / 1024)
788 } else {
789 format!("{}B", size)
790 };
791 println!(" {:<60} {}", name.to_string_lossy(), size_str);
792 }
793 println!("\nUse 'lean-ctx tee clear' to delete all logs.");
794 }
795 "clear" | "purge" => {
796 if !tee_dir.exists() {
797 println!("No tee logs to clear.");
798 return;
799 }
800 let mut count = 0u32;
801 if let Ok(entries) = std::fs::read_dir(&tee_dir) {
802 for entry in entries.flatten() {
803 if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
804 && std::fs::remove_file(entry.path()).is_ok()
805 {
806 count += 1;
807 }
808 }
809 }
810 println!("Cleared {count} tee log(s) from {}", tee_dir.display());
811 }
812 "show" => {
813 let filename = args.get(1);
814 if filename.is_none() {
815 eprintln!("Usage: lean-ctx tee show <filename>");
816 std::process::exit(1);
817 }
818 let path = tee_dir.join(filename.unwrap());
819 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
820 Ok(content) => print!("{content}"),
821 Err(e) => {
822 eprintln!("Error reading {}: {e}", path.display());
823 std::process::exit(1);
824 }
825 }
826 }
827 "last" => {
828 if !tee_dir.exists() {
829 println!("No tee logs found.");
830 return;
831 }
832 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
833 .ok()
834 .into_iter()
835 .flat_map(|d| d.filter_map(|e| e.ok()))
836 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
837 .collect();
838 entries.sort_by_key(|e| {
839 e.metadata()
840 .and_then(|m| m.modified())
841 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
842 });
843 match entries.last() {
844 Some(entry) => {
845 let path = entry.path();
846 println!(
847 "--- {} ---\n",
848 path.file_name().unwrap_or_default().to_string_lossy()
849 );
850 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
851 Ok(content) => print!("{content}"),
852 Err(e) => eprintln!("Error: {e}"),
853 }
854 }
855 None => println!("No tee logs found."),
856 }
857 }
858 _ => {
859 eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
860 std::process::exit(1);
861 }
862 }
863}
864
865pub fn cmd_filter(args: &[String]) {
866 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
867 match action {
868 "list" | "ls" => match crate::core::filters::FilterEngine::load() {
869 Some(engine) => {
870 let rules = engine.list_rules();
871 println!("Loaded {} filter rule(s):\n", rules.len());
872 for rule in &rules {
873 println!("{rule}");
874 }
875 }
876 None => {
877 println!("No custom filters found.");
878 println!("Create one: lean-ctx filter init");
879 }
880 },
881 "validate" => {
882 let path = args.get(1);
883 if path.is_none() {
884 eprintln!("Usage: lean-ctx filter validate <file.toml>");
885 std::process::exit(1);
886 }
887 match crate::core::filters::validate_filter_file(path.unwrap()) {
888 Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
889 Err(e) => {
890 eprintln!("Validation failed: {e}");
891 std::process::exit(1);
892 }
893 }
894 }
895 "init" => match crate::core::filters::create_example_filter() {
896 Ok(path) => {
897 println!("Created example filter: {path}");
898 println!("Edit it to add your custom compression rules.");
899 }
900 Err(e) => {
901 eprintln!("{e}");
902 std::process::exit(1);
903 }
904 },
905 _ => {
906 eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
907 std::process::exit(1);
908 }
909 }
910}
911
912fn quiet_enabled() -> bool {
913 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
914}
915
916macro_rules! qprintln {
917 ($($t:tt)*) => {
918 if !quiet_enabled() {
919 println!($($t)*);
920 }
921 };
922}
923
924pub fn cmd_init(args: &[String]) {
925 let global = args.iter().any(|a| a == "--global" || a == "-g");
926 let dry_run = args.iter().any(|a| a == "--dry-run");
927
928 let agents: Vec<&str> = args
929 .windows(2)
930 .filter(|w| w[0] == "--agent")
931 .map(|w| w[1].as_str())
932 .collect();
933
934 if !agents.is_empty() {
935 for agent_name in &agents {
936 crate::hooks::install_agent_hook(agent_name, global);
937 if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
938 eprintln!("MCP config for '{agent_name}' not updated: {e}");
939 }
940 }
941 if !global {
942 crate::hooks::install_project_rules();
943 }
944 qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
945 return;
946 }
947
948 let shell_name = std::env::var("SHELL").unwrap_or_default();
949 let is_zsh = shell_name.contains("zsh");
950 let is_fish = shell_name.contains("fish");
951 let is_powershell = cfg!(windows) && shell_name.is_empty();
952
953 let binary = std::env::current_exe()
954 .map(|p| p.to_string_lossy().to_string())
955 .unwrap_or_else(|_| "lean-ctx".to_string());
956
957 if dry_run {
958 let rc = if is_powershell {
959 "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
960 } else if is_fish {
961 "~/.config/fish/config.fish".to_string()
962 } else if is_zsh {
963 "~/.zshrc".to_string()
964 } else {
965 "~/.bashrc".to_string()
966 };
967 qprintln!("\nlean-ctx init --dry-run\n");
968 qprintln!(" Would modify: {rc}");
969 qprintln!(" Would backup: {rc}.lean-ctx.bak");
970 qprintln!(" Would alias: git npm pnpm yarn cargo docker docker-compose kubectl");
971 qprintln!(" gh pip pip3 ruff go golangci-lint eslint prettier tsc");
972 qprintln!(" curl wget php composer (24 commands + k)");
973 qprintln!(" Would create: ~/.lean-ctx/");
974 qprintln!(" Binary: {binary}");
975 qprintln!("\n Safety: aliases auto-fallback to original command if lean-ctx is removed.");
976 qprintln!("\n Run without --dry-run to apply.");
977 return;
978 }
979
980 if is_powershell {
981 init_powershell(&binary);
982 } else {
983 let bash_binary = to_bash_compatible_path(&binary);
984 if is_fish {
985 init_fish(&bash_binary);
986 } else {
987 init_posix(is_zsh, &bash_binary);
988 }
989 }
990
991 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
992 if let Some(dir) = lean_dir {
993 if !dir.exists() {
994 let _ = std::fs::create_dir_all(&dir);
995 qprintln!("Created {}", dir.display());
996 }
997 }
998
999 let rc = if is_powershell {
1000 "$PROFILE"
1001 } else if is_fish {
1002 "config.fish"
1003 } else if is_zsh {
1004 ".zshrc"
1005 } else {
1006 ".bashrc"
1007 };
1008
1009 qprintln!("\nlean-ctx init complete (24 aliases installed)");
1010 qprintln!();
1011 qprintln!(" Disable temporarily: lean-ctx-off");
1012 qprintln!(" Re-enable: lean-ctx-on");
1013 qprintln!(" Check status: lean-ctx-status");
1014 qprintln!(" Full uninstall: lean-ctx uninstall");
1015 qprintln!(" Diagnose issues: lean-ctx doctor");
1016 qprintln!(" Preview changes: lean-ctx init --global --dry-run");
1017 qprintln!();
1018 if is_powershell {
1019 qprintln!(" Restart PowerShell or run: . {rc}");
1020 } else {
1021 qprintln!(" Restart your shell or run: source ~/{rc}");
1022 }
1023 qprintln!();
1024 qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1025 qprintln!(" Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1026 qprintln!(" crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1027 qprintln!(" pi, qwen, roo, sublime, trae, verdent, windsurf");
1028}
1029
1030pub fn cmd_init_quiet(args: &[String]) {
1031 std::env::set_var("LEAN_CTX_QUIET", "1");
1032 cmd_init(args);
1033 std::env::remove_var("LEAN_CTX_QUIET");
1034}
1035
1036fn backup_shell_config(path: &std::path::Path) {
1037 if !path.exists() {
1038 return;
1039 }
1040 let bak = path.with_extension("lean-ctx.bak");
1041 if std::fs::copy(path, &bak).is_ok() {
1042 qprintln!(
1043 " Backup: {}",
1044 bak.file_name()
1045 .map(|n| format!("~/{}", n.to_string_lossy()))
1046 .unwrap_or_else(|| bak.display().to_string())
1047 );
1048 }
1049}
1050
1051pub fn init_powershell(binary: &str) {
1052 let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
1053 let profile_path = match profile_dir {
1054 Some(dir) => {
1055 let _ = std::fs::create_dir_all(&dir);
1056 dir.join("Microsoft.PowerShell_profile.ps1")
1057 }
1058 None => {
1059 eprintln!("Could not resolve PowerShell profile directory");
1060 return;
1061 }
1062 };
1063
1064 let binary_escaped = binary.replace('\\', "\\\\");
1065 let functions = format!(
1066 r#"
1067# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1068if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED) {{
1069 $LeanCtxBin = "{binary_escaped}"
1070 function _lc {{
1071 if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) {{ & @args; return }}
1072 & $LeanCtxBin -c @args
1073 if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
1074 & @args
1075 }}
1076 }}
1077 function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
1078 if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
1079 function git {{ _lc git @args }}
1080 function cargo {{ _lc cargo @args }}
1081 function docker {{ _lc docker @args }}
1082 function kubectl {{ _lc kubectl @args }}
1083 function gh {{ _lc gh @args }}
1084 function pip {{ _lc pip @args }}
1085 function pip3 {{ _lc pip3 @args }}
1086 function ruff {{ _lc ruff @args }}
1087 function go {{ _lc go @args }}
1088 function curl {{ _lc curl @args }}
1089 function wget {{ _lc wget @args }}
1090 foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
1091 if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
1092 New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc $c @args")) -Force | Out-Null
1093 }}
1094 }}
1095 }}
1096}}
1097"#
1098 );
1099
1100 backup_shell_config(&profile_path);
1101
1102 if let Ok(existing) = std::fs::read_to_string(&profile_path) {
1103 if existing.contains("lean-ctx shell hook") {
1104 let cleaned = remove_lean_ctx_block_ps(&existing);
1105 match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
1106 Ok(()) => {
1107 qprintln!("Updated lean-ctx functions in {}", profile_path.display());
1108 qprintln!(" Binary: {binary}");
1109 return;
1110 }
1111 Err(e) => {
1112 eprintln!("Error updating {}: {e}", profile_path.display());
1113 return;
1114 }
1115 }
1116 }
1117 }
1118
1119 match std::fs::OpenOptions::new()
1120 .append(true)
1121 .create(true)
1122 .open(&profile_path)
1123 {
1124 Ok(mut f) => {
1125 use std::io::Write;
1126 let _ = f.write_all(functions.as_bytes());
1127 qprintln!("Added lean-ctx functions to {}", profile_path.display());
1128 qprintln!(" Binary: {binary}");
1129 }
1130 Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
1131 }
1132}
1133
1134fn remove_lean_ctx_block_ps(content: &str) -> String {
1135 let mut result = String::new();
1136 let mut in_block = false;
1137 let mut brace_depth = 0i32;
1138
1139 for line in content.lines() {
1140 if line.contains("lean-ctx shell hook") {
1141 in_block = true;
1142 continue;
1143 }
1144 if in_block {
1145 brace_depth += line.matches('{').count() as i32;
1146 brace_depth -= line.matches('}').count() as i32;
1147 if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
1148 if line.trim() == "}" {
1149 in_block = false;
1150 brace_depth = 0;
1151 }
1152 continue;
1153 }
1154 continue;
1155 }
1156 result.push_str(line);
1157 result.push('\n');
1158 }
1159 result
1160}
1161
1162pub fn init_fish(binary: &str) {
1163 let config = dirs::home_dir()
1164 .map(|h| h.join(".config/fish/config.fish"))
1165 .unwrap_or_default();
1166
1167 let alias_list = crate::rewrite_registry::shell_alias_list();
1168 let aliases = format!(
1169 "\n# lean-ctx shell hook — smart shell mode (track-by-default)\n\
1170 set -g _lean_ctx_cmds {alias_list}\n\
1171 \n\
1172 function _lc\n\
1173 \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1174 \t\tcommand $argv\n\
1175 \t\treturn\n\
1176 \tend\n\
1177 \t'{binary}' -t $argv\n\
1178 \tset -l _lc_rc $status\n\
1179 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1180 \t\tcommand $argv\n\
1181 \telse\n\
1182 \t\treturn $_lc_rc\n\
1183 \tend\n\
1184 end\n\
1185 \n\
1186 function _lc_compress\n\
1187 \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1188 \t\tcommand $argv\n\
1189 \t\treturn\n\
1190 \tend\n\
1191 \t'{binary}' -c $argv\n\
1192 \tset -l _lc_rc $status\n\
1193 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1194 \t\tcommand $argv\n\
1195 \telse\n\
1196 \t\treturn $_lc_rc\n\
1197 \tend\n\
1198 end\n\
1199 \n\
1200 function lean-ctx-on\n\
1201 \tfor _lc_cmd in $_lean_ctx_cmds\n\
1202 \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
1203 \tend\n\
1204 \talias k '_lc kubectl'\n\
1205 \tset -gx LEAN_CTX_ENABLED 1\n\
1206 \techo 'lean-ctx: ON (track mode — full output, stats recorded)'\n\
1207 end\n\
1208 \n\
1209 function lean-ctx-off\n\
1210 \tfor _lc_cmd in $_lean_ctx_cmds\n\
1211 \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
1212 \tend\n\
1213 \tfunctions --erase k 2>/dev/null; true\n\
1214 \tset -e LEAN_CTX_ENABLED\n\
1215 \techo 'lean-ctx: OFF'\n\
1216 end\n\
1217 \n\
1218 function lean-ctx-mode\n\
1219 \tswitch $argv[1]\n\
1220 \t\tcase compress\n\
1221 \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
1222 \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
1223 \t\t\t\tend\n\
1224 \t\t\talias k '_lc_compress kubectl'\n\
1225 \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
1226 \t\t\techo 'lean-ctx: COMPRESS mode (all output compressed)'\n\
1227 \t\tcase track\n\
1228 \t\t\tlean-ctx-on\n\
1229 \t\tcase off\n\
1230 \t\t\tlean-ctx-off\n\
1231 \t\tcase '*'\n\
1232 \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
1233 \t\t\techo ' track — Full output, stats recorded (default)'\n\
1234 \t\t\techo ' compress — Compressed output for all commands'\n\
1235 \t\t\techo ' off — No aliases, raw shell'\n\
1236 \tend\n\
1237 end\n\
1238 \n\
1239 function lean-ctx-raw\n\
1240 \tset -lx LEAN_CTX_RAW 1\n\
1241 \tcommand $argv\n\
1242 end\n\
1243 \n\
1244 function lean-ctx-status\n\
1245 \tif set -q LEAN_CTX_DISABLED\n\
1246 \t\techo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
1247 \telse if set -q LEAN_CTX_ENABLED\n\
1248 \t\techo 'lean-ctx: ON'\n\
1249 \telse\n\
1250 \t\techo 'lean-ctx: OFF'\n\
1251 \tend\n\
1252 end\n\
1253 \n\
1254 if not set -q LEAN_CTX_ACTIVE; and not set -q LEAN_CTX_DISABLED; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
1255 \tif command -q lean-ctx\n\
1256 \t\tlean-ctx-on\n\
1257 \tend\n\
1258 end\n\
1259 # lean-ctx shell hook — end\n"
1260 );
1261
1262 backup_shell_config(&config);
1263
1264 if let Ok(existing) = std::fs::read_to_string(&config) {
1265 if existing.contains("lean-ctx shell hook") {
1266 let cleaned = remove_lean_ctx_block(&existing);
1267 match std::fs::write(&config, format!("{cleaned}{aliases}")) {
1268 Ok(()) => {
1269 qprintln!("Updated lean-ctx aliases in {}", config.display());
1270 qprintln!(" Binary: {binary}");
1271 return;
1272 }
1273 Err(e) => {
1274 eprintln!("Error updating {}: {e}", config.display());
1275 return;
1276 }
1277 }
1278 }
1279 }
1280
1281 match std::fs::OpenOptions::new()
1282 .append(true)
1283 .create(true)
1284 .open(&config)
1285 {
1286 Ok(mut f) => {
1287 use std::io::Write;
1288 let _ = f.write_all(aliases.as_bytes());
1289 qprintln!("Added lean-ctx aliases to {}", config.display());
1290 qprintln!(" Binary: {binary}");
1291 }
1292 Err(e) => eprintln!("Error writing {}: {e}", config.display()),
1293 }
1294}
1295
1296pub fn init_posix(is_zsh: bool, binary: &str) {
1297 let rc_file = if is_zsh {
1298 dirs::home_dir()
1299 .map(|h| h.join(".zshrc"))
1300 .unwrap_or_default()
1301 } else {
1302 dirs::home_dir()
1303 .map(|h| h.join(".bashrc"))
1304 .unwrap_or_default()
1305 };
1306
1307 let alias_list = crate::rewrite_registry::shell_alias_list();
1308 let aliases = format!(
1309 r#"
1310# lean-ctx shell hook — smart shell mode (track-by-default)
1311_lean_ctx_cmds=({alias_list})
1312
1313_lc() {{
1314 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1315 command "$@"
1316 return
1317 fi
1318 '{binary}' -t "$@"
1319 local _lc_rc=$?
1320 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1321 command "$@"
1322 else
1323 return "$_lc_rc"
1324 fi
1325}}
1326
1327_lc_compress() {{
1328 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1329 command "$@"
1330 return
1331 fi
1332 '{binary}' -c "$@"
1333 local _lc_rc=$?
1334 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1335 command "$@"
1336 else
1337 return "$_lc_rc"
1338 fi
1339}}
1340
1341lean-ctx-on() {{
1342 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1343 # shellcheck disable=SC2139
1344 alias "$_lc_cmd"='_lc '"$_lc_cmd"
1345 done
1346 alias k='_lc kubectl'
1347 export LEAN_CTX_ENABLED=1
1348 echo "lean-ctx: ON (track mode — full output, stats recorded)"
1349}}
1350
1351lean-ctx-off() {{
1352 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1353 unalias "$_lc_cmd" 2>/dev/null || true
1354 done
1355 unalias k 2>/dev/null || true
1356 unset LEAN_CTX_ENABLED
1357 echo "lean-ctx: OFF"
1358}}
1359
1360lean-ctx-mode() {{
1361 case "${{1:-}}" in
1362 compress)
1363 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1364 # shellcheck disable=SC2139
1365 alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
1366 done
1367 alias k='_lc_compress kubectl'
1368 export LEAN_CTX_ENABLED=1
1369 echo "lean-ctx: COMPRESS mode (all output compressed)"
1370 ;;
1371 track)
1372 lean-ctx-on
1373 ;;
1374 off)
1375 lean-ctx-off
1376 ;;
1377 *)
1378 echo "Usage: lean-ctx-mode <track|compress|off>"
1379 echo " track — Full output, stats recorded (default)"
1380 echo " compress — Compressed output for all commands"
1381 echo " off — No aliases, raw shell"
1382 ;;
1383 esac
1384}}
1385
1386lean-ctx-raw() {{
1387 LEAN_CTX_RAW=1 command "$@"
1388}}
1389
1390lean-ctx-status() {{
1391 if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1392 echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
1393 elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1394 echo "lean-ctx: ON"
1395 else
1396 echo "lean-ctx: OFF"
1397 fi
1398}}
1399
1400if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1401 command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
1402fi
1403# lean-ctx shell hook — end
1404"#
1405 );
1406
1407 backup_shell_config(&rc_file);
1408
1409 if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1410 if existing.contains("lean-ctx shell hook") {
1411 let cleaned = remove_lean_ctx_block(&existing);
1412 match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1413 Ok(()) => {
1414 qprintln!("Updated lean-ctx aliases in {}", rc_file.display());
1415 qprintln!(" Binary: {binary}");
1416 return;
1417 }
1418 Err(e) => {
1419 eprintln!("Error updating {}: {e}", rc_file.display());
1420 return;
1421 }
1422 }
1423 }
1424 }
1425
1426 match std::fs::OpenOptions::new()
1427 .append(true)
1428 .create(true)
1429 .open(&rc_file)
1430 {
1431 Ok(mut f) => {
1432 use std::io::Write;
1433 let _ = f.write_all(aliases.as_bytes());
1434 qprintln!("Added lean-ctx aliases to {}", rc_file.display());
1435 qprintln!(" Binary: {binary}");
1436 }
1437 Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1438 }
1439
1440 write_env_sh_for_containers(&aliases);
1441 print_docker_env_hints(is_zsh);
1442}
1443
1444fn write_env_sh_for_containers(aliases: &str) {
1445 let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
1446 Ok(d) => d.join("env.sh"),
1447 Err(_) => return,
1448 };
1449 if let Some(parent) = env_sh.parent() {
1450 let _ = std::fs::create_dir_all(parent);
1451 }
1452 let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
1453 let mut content = sanitized_aliases;
1454 content.push_str(
1455 r#"
1456
1457# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
1458if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
1459 if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
1460 LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
1461 fi
1462fi
1463"#,
1464 );
1465 match std::fs::write(&env_sh, content) {
1466 Ok(()) => qprintln!(" env.sh: {}", env_sh.display()),
1467 Err(e) => eprintln!(" Warning: could not write {}: {e}", env_sh.display()),
1468 }
1469}
1470
1471fn print_docker_env_hints(is_zsh: bool) {
1472 if is_zsh || !crate::shell::is_container() {
1473 return;
1474 }
1475 let env_sh = crate::core::data_dir::lean_ctx_data_dir()
1476 .map(|d| d.join("env.sh").to_string_lossy().to_string())
1477 .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
1478
1479 let has_bash_env = std::env::var("BASH_ENV").is_ok();
1480 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
1481
1482 if has_bash_env && has_claude_env {
1483 return;
1484 }
1485
1486 eprintln!();
1487 eprintln!(" \x1b[33m⚠ Docker detected — environment hints:\x1b[0m");
1488
1489 if !has_bash_env {
1490 eprintln!(" For generic bash -c usage (non-interactive shells):");
1491 eprintln!(" \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
1492 }
1493 if !has_claude_env {
1494 eprintln!(" For Claude Code (sources before each command):");
1495 eprintln!(" \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
1496 }
1497 eprintln!();
1498}
1499
1500fn remove_lean_ctx_block(content: &str) -> String {
1501 if content.contains("# lean-ctx shell hook — end") {
1503 return remove_lean_ctx_block_by_marker(content);
1504 }
1505 remove_lean_ctx_block_legacy(content)
1506}
1507
1508fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1509 let mut result = String::new();
1510 let mut in_block = false;
1511
1512 for line in content.lines() {
1513 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1514 in_block = true;
1515 continue;
1516 }
1517 if in_block {
1518 if line.trim() == "# lean-ctx shell hook — end" {
1519 in_block = false;
1520 }
1521 continue;
1522 }
1523 result.push_str(line);
1524 result.push('\n');
1525 }
1526 result
1527}
1528
1529fn remove_lean_ctx_block_legacy(content: &str) -> String {
1530 let mut result = String::new();
1531 let mut in_block = false;
1532
1533 for line in content.lines() {
1534 if line.contains("lean-ctx shell hook") {
1535 in_block = true;
1536 continue;
1537 }
1538 if in_block {
1539 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1540 if line.trim() == "fi" || line.trim() == "end" {
1541 in_block = false;
1542 }
1543 continue;
1544 }
1545 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1546 in_block = false;
1547 result.push_str(line);
1548 result.push('\n');
1549 }
1550 continue;
1551 }
1552 result.push_str(line);
1553 result.push('\n');
1554 }
1555 result
1556}
1557
1558pub fn load_shell_history_pub() -> Vec<String> {
1559 load_shell_history()
1560}
1561
1562fn load_shell_history() -> Vec<String> {
1563 let shell = std::env::var("SHELL").unwrap_or_default();
1564 let home = match dirs::home_dir() {
1565 Some(h) => h,
1566 None => return Vec::new(),
1567 };
1568
1569 let history_file = if shell.contains("zsh") {
1570 home.join(".zsh_history")
1571 } else if shell.contains("fish") {
1572 home.join(".local/share/fish/fish_history")
1573 } else if cfg!(windows) && shell.is_empty() {
1574 home.join("AppData")
1575 .join("Roaming")
1576 .join("Microsoft")
1577 .join("Windows")
1578 .join("PowerShell")
1579 .join("PSReadLine")
1580 .join("ConsoleHost_history.txt")
1581 } else {
1582 home.join(".bash_history")
1583 };
1584
1585 match std::fs::read_to_string(&history_file) {
1586 Ok(content) => content
1587 .lines()
1588 .filter_map(|l| {
1589 let trimmed = l.trim();
1590 if trimmed.starts_with(':') {
1591 trimmed.split(';').nth(1).map(|s| s.to_string())
1592 } else {
1593 Some(trimmed.to_string())
1594 }
1595 })
1596 .filter(|l| !l.is_empty())
1597 .collect(),
1598 Err(_) => Vec::new(),
1599 }
1600}
1601
1602fn print_savings(original: usize, sent: usize) {
1603 let saved = original.saturating_sub(sent);
1604 if original > 0 && saved > 0 {
1605 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1606 println!("[{saved} tok saved ({pct}%)]");
1607 }
1608}
1609
1610pub fn cmd_theme(args: &[String]) {
1611 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1612 let r = theme::rst();
1613 let b = theme::bold();
1614 let d = theme::dim();
1615
1616 match sub {
1617 "list" => {
1618 let cfg = config::Config::load();
1619 let active = cfg.theme.as_str();
1620 println!();
1621 println!(" {b}Available themes:{r}");
1622 println!(" {ln}", ln = "─".repeat(40));
1623 for name in theme::PRESET_NAMES {
1624 let marker = if *name == active { " ◀ active" } else { "" };
1625 let t = theme::from_preset(name).unwrap();
1626 let preview = format!(
1627 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1628 p = t.primary.fg(),
1629 s = t.secondary.fg(),
1630 a = t.accent.fg(),
1631 sc = t.success.fg(),
1632 w = t.warning.fg(),
1633 );
1634 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1635 }
1636 if let Some(path) = theme::theme_file_path() {
1637 if path.exists() {
1638 let custom = theme::load_theme("_custom_");
1639 let preview = format!(
1640 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1641 p = custom.primary.fg(),
1642 s = custom.secondary.fg(),
1643 a = custom.accent.fg(),
1644 sc = custom.success.fg(),
1645 w = custom.warning.fg(),
1646 );
1647 let marker = if active == "custom" {
1648 " ◀ active"
1649 } else {
1650 ""
1651 };
1652 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1653 }
1654 }
1655 println!();
1656 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1657 println!();
1658 }
1659 "set" => {
1660 if args.len() < 2 {
1661 eprintln!("Usage: lean-ctx theme set <name>");
1662 std::process::exit(1);
1663 }
1664 let name = &args[1];
1665 if theme::from_preset(name).is_none() && name != "custom" {
1666 eprintln!(
1667 "Unknown theme '{name}'. Available: {}",
1668 theme::PRESET_NAMES.join(", ")
1669 );
1670 std::process::exit(1);
1671 }
1672 let mut cfg = config::Config::load();
1673 cfg.theme = name.to_string();
1674 match cfg.save() {
1675 Ok(()) => {
1676 let t = theme::load_theme(name);
1677 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1678 let preview = t.gradient_bar(0.75, 30);
1679 println!(" {preview}");
1680 }
1681 Err(e) => eprintln!("Error: {e}"),
1682 }
1683 }
1684 "export" => {
1685 let cfg = config::Config::load();
1686 let t = theme::load_theme(&cfg.theme);
1687 println!("{}", t.to_toml());
1688 }
1689 "import" => {
1690 if args.len() < 2 {
1691 eprintln!("Usage: lean-ctx theme import <path>");
1692 std::process::exit(1);
1693 }
1694 let path = std::path::Path::new(&args[1]);
1695 if !path.exists() {
1696 eprintln!("File not found: {}", args[1]);
1697 std::process::exit(1);
1698 }
1699 match std::fs::read_to_string(path) {
1700 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1701 Ok(imported) => match theme::save_theme(&imported) {
1702 Ok(()) => {
1703 let mut cfg = config::Config::load();
1704 cfg.theme = "custom".to_string();
1705 let _ = cfg.save();
1706 println!(
1707 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1708 sc = imported.success.fg(),
1709 name = imported.name,
1710 );
1711 println!(" Config updated: theme = custom");
1712 }
1713 Err(e) => eprintln!("Error saving theme: {e}"),
1714 },
1715 Err(e) => eprintln!("Invalid theme file: {e}"),
1716 },
1717 Err(e) => eprintln!("Error reading file: {e}"),
1718 }
1719 }
1720 "preview" => {
1721 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1722 let t = match theme::from_preset(name) {
1723 Some(t) => t,
1724 None => {
1725 eprintln!("Unknown theme: {name}");
1726 std::process::exit(1);
1727 }
1728 };
1729 println!();
1730 println!(
1731 " {icon} {title} {d}Theme Preview: {name}{r}",
1732 icon = t.header_icon(),
1733 title = t.brand_title(),
1734 );
1735 println!(" {ln}", ln = t.border_line(50));
1736 println!();
1737 println!(
1738 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1739 sc = t.success.fg(),
1740 sec = t.secondary.fg(),
1741 wrn = t.warning.fg(),
1742 acc = t.accent.fg(),
1743 );
1744 println!(" {d} tokens saved compression commands USD saved{r}");
1745 println!();
1746 println!(
1747 " {b}{txt}Gradient Bar{r} {bar}",
1748 txt = t.text.fg(),
1749 bar = t.gradient_bar(0.85, 30),
1750 );
1751 println!(
1752 " {b}{txt}Sparkline{r} {spark}",
1753 txt = t.text.fg(),
1754 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1755 );
1756 println!();
1757 println!(" {top}", top = t.box_top(50));
1758 println!(
1759 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1760 side = t.box_side(),
1761 side_r = t.box_side(),
1762 txt = t.text.fg(),
1763 );
1764 println!(" {bot}", bot = t.box_bottom(50));
1765 println!();
1766 }
1767 _ => {
1768 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1769 std::process::exit(1);
1770 }
1771 }
1772}
1773
1774#[cfg(test)]
1775mod tests {
1776 use super::*;
1777 use tempfile;
1778
1779 #[test]
1780 fn test_remove_lean_ctx_block_posix() {
1781 let input = r#"# existing config
1782export PATH="$HOME/bin:$PATH"
1783
1784# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1785if [ -z "$LEAN_CTX_ACTIVE" ]; then
1786alias git='lean-ctx -c git'
1787alias npm='lean-ctx -c npm'
1788fi
1789
1790# other stuff
1791export EDITOR=vim
1792"#;
1793 let result = remove_lean_ctx_block(input);
1794 assert!(!result.contains("lean-ctx"), "block should be removed");
1795 assert!(result.contains("export PATH"), "other content preserved");
1796 assert!(
1797 result.contains("export EDITOR"),
1798 "trailing content preserved"
1799 );
1800 }
1801
1802 #[test]
1803 fn test_remove_lean_ctx_block_fish() {
1804 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";
1805 let result = remove_lean_ctx_block(input);
1806 assert!(!result.contains("lean-ctx"), "block should be removed");
1807 assert!(result.contains("set -x FOO"), "other content preserved");
1808 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1809 }
1810
1811 #[test]
1812 fn test_remove_lean_ctx_block_ps() {
1813 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";
1814 let result = remove_lean_ctx_block_ps(input);
1815 assert!(
1816 !result.contains("lean-ctx shell hook"),
1817 "block should be removed"
1818 );
1819 assert!(result.contains("$env:FOO"), "other content preserved");
1820 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1821 }
1822
1823 #[test]
1824 fn test_remove_lean_ctx_block_ps_nested() {
1825 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 = \"lean-ctx\"\n function _lc {\n & $LeanCtxBin -c \"$($args -join ' ')\"\n }\n if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {\n function git { _lc git @args }\n foreach ($c in @('npm','pnpm')) {\n if ($a) {\n Set-Variable -Name \"_lc_$c\" -Value $a.Source -Scope Script\n }\n }\n }\n}\n\n# other stuff\n$env:EDITOR = 'vim'\n";
1826 let result = remove_lean_ctx_block_ps(input);
1827 assert!(
1828 !result.contains("lean-ctx shell hook"),
1829 "block should be removed"
1830 );
1831 assert!(!result.contains("_lc"), "function should be removed");
1832 assert!(result.contains("$env:FOO"), "other content preserved");
1833 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1834 }
1835
1836 #[test]
1837 fn test_remove_block_no_lean_ctx() {
1838 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1839 let result = remove_lean_ctx_block(input);
1840 assert!(result.contains("export PATH"), "content unchanged");
1841 }
1842
1843 #[test]
1844 fn test_bash_hook_contains_pipe_guard() {
1845 let binary = "/usr/local/bin/lean-ctx";
1846 let hook = format!(
1847 r#"_lc() {{
1848 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1849 command "$@"
1850 return
1851 fi
1852 '{binary}' -t "$@"
1853}}"#
1854 );
1855 assert!(
1856 hook.contains("! -t 1"),
1857 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1858 );
1859 assert!(
1860 hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1861 "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1862 );
1863 }
1864
1865 #[test]
1866 fn test_lc_uses_track_mode_by_default() {
1867 let binary = "/usr/local/bin/lean-ctx";
1868 let alias_list = crate::rewrite_registry::shell_alias_list();
1869 let aliases = format!(
1870 r#"_lc() {{
1871 '{binary}' -t "$@"
1872}}
1873_lc_compress() {{
1874 '{binary}' -c "$@"
1875}}"#
1876 );
1877 assert!(
1878 aliases.contains("-t \"$@\""),
1879 "_lc must use -t (track mode) by default"
1880 );
1881 assert!(
1882 aliases.contains("-c \"$@\""),
1883 "_lc_compress must use -c (compress mode)"
1884 );
1885 let _ = alias_list;
1886 }
1887
1888 #[test]
1889 fn test_posix_shell_has_lean_ctx_mode() {
1890 let alias_list = crate::rewrite_registry::shell_alias_list();
1891 let aliases = r#"
1892lean-ctx-mode() {{
1893 case "${{1:-}}" in
1894 compress) echo compress ;;
1895 track) echo track ;;
1896 off) echo off ;;
1897 esac
1898}}
1899"#
1900 .to_string();
1901 assert!(
1902 aliases.contains("lean-ctx-mode()"),
1903 "lean-ctx-mode function must exist"
1904 );
1905 assert!(
1906 aliases.contains("compress"),
1907 "compress mode must be available"
1908 );
1909 assert!(aliases.contains("track"), "track mode must be available");
1910 let _ = alias_list;
1911 }
1912
1913 #[test]
1914 fn test_fish_hook_contains_pipe_guard() {
1915 let hook = "function _lc\n\tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\t\tcommand $argv\n\t\treturn\n\tend\nend";
1916 assert!(
1917 hook.contains("isatty stdout"),
1918 "fish hook must contain pipe guard (isatty stdout)"
1919 );
1920 }
1921
1922 #[test]
1923 fn test_powershell_hook_contains_pipe_guard() {
1924 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1925 assert!(
1926 hook.contains("IsOutputRedirected"),
1927 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1928 );
1929 }
1930
1931 #[test]
1932 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1933 let input = r#"# existing config
1934export PATH="$HOME/bin:$PATH"
1935
1936# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1937_lean_ctx_cmds=(git npm pnpm)
1938
1939lean-ctx-on() {
1940 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1941 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1942 done
1943 export LEAN_CTX_ENABLED=1
1944 echo "lean-ctx: ON"
1945}
1946
1947lean-ctx-off() {
1948 unset LEAN_CTX_ENABLED
1949 echo "lean-ctx: OFF"
1950}
1951
1952if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1953 lean-ctx-on
1954fi
1955# lean-ctx shell hook — end
1956
1957# other stuff
1958export EDITOR=vim
1959"#;
1960 let result = remove_lean_ctx_block(input);
1961 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1962 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1963 assert!(result.contains("export PATH"), "other content preserved");
1964 assert!(
1965 result.contains("export EDITOR"),
1966 "trailing content preserved"
1967 );
1968 }
1969
1970 #[test]
1971 fn env_sh_for_containers_includes_self_heal() {
1972 let _g = crate::core::data_dir::test_env_lock();
1973 let tmp = tempfile::tempdir().expect("tempdir");
1974 let data_dir = tmp.path().join("data");
1975 std::fs::create_dir_all(&data_dir).expect("mkdir data");
1976 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1977
1978 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1979 let env_sh = data_dir.join("env.sh");
1980 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1981 assert!(content.contains("lean-ctx docker self-heal"));
1982 assert!(content.contains("claude mcp list"));
1983 assert!(content.contains("lean-ctx init --agent claude"));
1984
1985 std::env::remove_var("LEAN_CTX_DATA_DIR");
1986 }
1987}