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 _ => {
654 eprintln!("Unknown config key: {key}");
655 std::process::exit(1);
656 }
657 }
658 match cfg.save() {
659 Ok(()) => println!("Updated {key} = {val}"),
660 Err(e) => eprintln!("Error saving config: {e}"),
661 }
662 }
663 _ => {
664 eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
665 std::process::exit(1);
666 }
667 }
668}
669
670pub fn cmd_cheatsheet() {
671 let ver = env!("CARGO_PKG_VERSION");
672 let ver_pad = format!("v{ver}");
673 let header = format!(
674 "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
675\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
676\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m");
677 println!(
678 "{header}
679
680\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
681 ctx_session load \x1b[2m# restore previous session\x1b[0m
682 ctx_overview task=\"...\" \x1b[2m# task-aware file map\x1b[0m
683 ctx_graph action=build \x1b[2m# index project (first time)\x1b[0m
684 ctx_knowledge action=recall \x1b[2m# check stored project facts\x1b[0m
685
686\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
687 ctx_read mode=full \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
688 ctx_read mode=map \x1b[2m# context-only files (~93% saved)\x1b[0m
689 ctx_read mode=diff \x1b[2m# after editing (~98% saved)\x1b[0m
690 ctx_read mode=sigs \x1b[2m# API surface of large files (~95%)\x1b[0m
691 ctx_multi_read \x1b[2m# read multiple files at once\x1b[0m
692 ctx_search \x1b[2m# search with compressed results (~70%)\x1b[0m
693 ctx_shell \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
694
695\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
696 ctx_session finding \"...\" \x1b[2m# record what you discovered\x1b[0m
697 ctx_session decision \"...\" \x1b[2m# record architectural choices\x1b[0m
698 ctx_knowledge action=remember \x1b[2m# store permanent project facts\x1b[0m
699 ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
700 ctx_metrics \x1b[2m# see session statistics\x1b[0m
701
702\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
703 ctx_agent action=register \x1b[2m# announce yourself\x1b[0m
704 ctx_agent action=list \x1b[2m# see other active agents\x1b[0m
705 ctx_agent action=post \x1b[2m# share findings\x1b[0m
706 ctx_agent action=read \x1b[2m# check messages\x1b[0m
707
708\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
709 Will edit? → \x1b[1mfull\x1b[0m (re-reads: 13 tokens) → after edit: \x1b[1mdiff\x1b[0m
710 API only? → \x1b[1msignatures\x1b[0m
711 Deps/exports? → \x1b[1mmap\x1b[0m
712 Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
713 Browsing? → \x1b[1maggressive\x1b[0m (syntax stripped)
714
715\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
716 lean-ctx gain \x1b[2m# visual savings dashboard\x1b[0m
717 lean-ctx gain --live \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
718 lean-ctx dashboard \x1b[2m# web dashboard with charts\x1b[0m
719 lean-ctx wrapped \x1b[2m# weekly savings report\x1b[0m
720 lean-ctx discover \x1b[2m# find uncompressed commands\x1b[0m
721 lean-ctx doctor \x1b[2m# diagnose installation\x1b[0m
722 lean-ctx update \x1b[2m# self-update to latest\x1b[0m
723
724\x1b[2m Full guide: https://leanctx.com/docs/workflow\x1b[0m"
725 );
726}
727
728pub fn cmd_slow_log(args: &[String]) {
729 use crate::core::slow_log;
730
731 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
732 match action {
733 "list" | "ls" | "" => println!("{}", slow_log::list()),
734 "clear" | "purge" => println!("{}", slow_log::clear()),
735 _ => {
736 eprintln!("Usage: lean-ctx slow-log [list|clear]");
737 std::process::exit(1);
738 }
739 }
740}
741
742pub fn cmd_tee(args: &[String]) {
743 let tee_dir = match dirs::home_dir() {
744 Some(h) => h.join(".lean-ctx").join("tee"),
745 None => {
746 eprintln!("Cannot determine home directory");
747 std::process::exit(1);
748 }
749 };
750
751 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
752 match action {
753 "list" | "ls" => {
754 if !tee_dir.exists() {
755 println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
756 return;
757 }
758 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
759 .unwrap_or_else(|e| {
760 eprintln!("Error: {e}");
761 std::process::exit(1);
762 })
763 .filter_map(|e| e.ok())
764 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
765 .collect();
766 entries.sort_by_key(|e| e.file_name());
767
768 if entries.is_empty() {
769 println!("No tee logs found.");
770 return;
771 }
772
773 println!("Tee logs ({}):\n", entries.len());
774 for entry in &entries {
775 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
776 let name = entry.file_name();
777 let size_str = if size > 1024 {
778 format!("{}K", size / 1024)
779 } else {
780 format!("{}B", size)
781 };
782 println!(" {:<60} {}", name.to_string_lossy(), size_str);
783 }
784 println!("\nUse 'lean-ctx tee clear' to delete all logs.");
785 }
786 "clear" | "purge" => {
787 if !tee_dir.exists() {
788 println!("No tee logs to clear.");
789 return;
790 }
791 let mut count = 0u32;
792 if let Ok(entries) = std::fs::read_dir(&tee_dir) {
793 for entry in entries.flatten() {
794 if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
795 && std::fs::remove_file(entry.path()).is_ok()
796 {
797 count += 1;
798 }
799 }
800 }
801 println!("Cleared {count} tee log(s) from {}", tee_dir.display());
802 }
803 "show" => {
804 let filename = args.get(1);
805 if filename.is_none() {
806 eprintln!("Usage: lean-ctx tee show <filename>");
807 std::process::exit(1);
808 }
809 let path = tee_dir.join(filename.unwrap());
810 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
811 Ok(content) => print!("{content}"),
812 Err(e) => {
813 eprintln!("Error reading {}: {e}", path.display());
814 std::process::exit(1);
815 }
816 }
817 }
818 "last" => {
819 if !tee_dir.exists() {
820 println!("No tee logs found.");
821 return;
822 }
823 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
824 .ok()
825 .into_iter()
826 .flat_map(|d| d.filter_map(|e| e.ok()))
827 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
828 .collect();
829 entries.sort_by_key(|e| {
830 e.metadata()
831 .and_then(|m| m.modified())
832 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
833 });
834 match entries.last() {
835 Some(entry) => {
836 let path = entry.path();
837 println!(
838 "--- {} ---\n",
839 path.file_name().unwrap_or_default().to_string_lossy()
840 );
841 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
842 Ok(content) => print!("{content}"),
843 Err(e) => eprintln!("Error: {e}"),
844 }
845 }
846 None => println!("No tee logs found."),
847 }
848 }
849 _ => {
850 eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
851 std::process::exit(1);
852 }
853 }
854}
855
856pub fn cmd_filter(args: &[String]) {
857 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
858 match action {
859 "list" | "ls" => match crate::core::filters::FilterEngine::load() {
860 Some(engine) => {
861 let rules = engine.list_rules();
862 println!("Loaded {} filter rule(s):\n", rules.len());
863 for rule in &rules {
864 println!("{rule}");
865 }
866 }
867 None => {
868 println!("No custom filters found.");
869 println!("Create one: lean-ctx filter init");
870 }
871 },
872 "validate" => {
873 let path = args.get(1);
874 if path.is_none() {
875 eprintln!("Usage: lean-ctx filter validate <file.toml>");
876 std::process::exit(1);
877 }
878 match crate::core::filters::validate_filter_file(path.unwrap()) {
879 Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
880 Err(e) => {
881 eprintln!("Validation failed: {e}");
882 std::process::exit(1);
883 }
884 }
885 }
886 "init" => match crate::core::filters::create_example_filter() {
887 Ok(path) => {
888 println!("Created example filter: {path}");
889 println!("Edit it to add your custom compression rules.");
890 }
891 Err(e) => {
892 eprintln!("{e}");
893 std::process::exit(1);
894 }
895 },
896 _ => {
897 eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
898 std::process::exit(1);
899 }
900 }
901}
902
903fn quiet_enabled() -> bool {
904 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
905}
906
907macro_rules! qprintln {
908 ($($t:tt)*) => {
909 if !quiet_enabled() {
910 println!($($t)*);
911 }
912 };
913}
914
915pub fn cmd_init(args: &[String]) {
916 let global = args.iter().any(|a| a == "--global" || a == "-g");
917 let dry_run = args.iter().any(|a| a == "--dry-run");
918
919 let agents: Vec<&str> = args
920 .windows(2)
921 .filter(|w| w[0] == "--agent")
922 .map(|w| w[1].as_str())
923 .collect();
924
925 if !agents.is_empty() {
926 for agent_name in &agents {
927 crate::hooks::install_agent_hook(agent_name, global);
928 if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
929 eprintln!("MCP config for '{agent_name}' not updated: {e}");
930 }
931 }
932 if !global {
933 crate::hooks::install_project_rules();
934 }
935 qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
936 return;
937 }
938
939 let shell_name = std::env::var("SHELL").unwrap_or_default();
940 let is_zsh = shell_name.contains("zsh");
941 let is_fish = shell_name.contains("fish");
942 let is_powershell = cfg!(windows) && shell_name.is_empty();
943
944 let binary = std::env::current_exe()
945 .map(|p| p.to_string_lossy().to_string())
946 .unwrap_or_else(|_| "lean-ctx".to_string());
947
948 if dry_run {
949 let rc = if is_powershell {
950 "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
951 } else if is_fish {
952 "~/.config/fish/config.fish".to_string()
953 } else if is_zsh {
954 "~/.zshrc".to_string()
955 } else {
956 "~/.bashrc".to_string()
957 };
958 qprintln!("\nlean-ctx init --dry-run\n");
959 qprintln!(" Would modify: {rc}");
960 qprintln!(" Would backup: {rc}.lean-ctx.bak");
961 qprintln!(" Would alias: git npm pnpm yarn cargo docker docker-compose kubectl");
962 qprintln!(" gh pip pip3 ruff go golangci-lint eslint prettier tsc");
963 qprintln!(" curl wget php composer (24 commands + k)");
964 qprintln!(" Would create: ~/.lean-ctx/");
965 qprintln!(" Binary: {binary}");
966 qprintln!("\n Safety: aliases auto-fallback to original command if lean-ctx is removed.");
967 qprintln!("\n Run without --dry-run to apply.");
968 return;
969 }
970
971 if is_powershell {
972 init_powershell(&binary);
973 } else {
974 let bash_binary = to_bash_compatible_path(&binary);
975 if is_fish {
976 init_fish(&bash_binary);
977 } else {
978 init_posix(is_zsh, &bash_binary);
979 }
980 }
981
982 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
983 if let Some(dir) = lean_dir {
984 if !dir.exists() {
985 let _ = std::fs::create_dir_all(&dir);
986 qprintln!("Created {}", dir.display());
987 }
988 }
989
990 let rc = if is_powershell {
991 "$PROFILE"
992 } else if is_fish {
993 "config.fish"
994 } else if is_zsh {
995 ".zshrc"
996 } else {
997 ".bashrc"
998 };
999
1000 qprintln!("\nlean-ctx init complete (24 aliases installed)");
1001 qprintln!();
1002 qprintln!(" Disable temporarily: lean-ctx-off");
1003 qprintln!(" Re-enable: lean-ctx-on");
1004 qprintln!(" Check status: lean-ctx-status");
1005 qprintln!(" Full uninstall: lean-ctx uninstall");
1006 qprintln!(" Diagnose issues: lean-ctx doctor");
1007 qprintln!(" Preview changes: lean-ctx init --global --dry-run");
1008 qprintln!();
1009 if is_powershell {
1010 qprintln!(" Restart PowerShell or run: . {rc}");
1011 } else {
1012 qprintln!(" Restart your shell or run: source ~/{rc}");
1013 }
1014 qprintln!();
1015 qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1016 qprintln!(" Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1017 qprintln!(" crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1018 qprintln!(" pi, qwen, roo, sublime, trae, verdent, windsurf");
1019}
1020
1021pub fn cmd_init_quiet(args: &[String]) {
1022 std::env::set_var("LEAN_CTX_QUIET", "1");
1023 cmd_init(args);
1024 std::env::remove_var("LEAN_CTX_QUIET");
1025}
1026
1027fn backup_shell_config(path: &std::path::Path) {
1028 if !path.exists() {
1029 return;
1030 }
1031 let bak = path.with_extension("lean-ctx.bak");
1032 if std::fs::copy(path, &bak).is_ok() {
1033 qprintln!(
1034 " Backup: {}",
1035 bak.file_name()
1036 .map(|n| format!("~/{}", n.to_string_lossy()))
1037 .unwrap_or_else(|| bak.display().to_string())
1038 );
1039 }
1040}
1041
1042pub fn init_powershell(binary: &str) {
1043 let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
1044 let profile_path = match profile_dir {
1045 Some(dir) => {
1046 let _ = std::fs::create_dir_all(&dir);
1047 dir.join("Microsoft.PowerShell_profile.ps1")
1048 }
1049 None => {
1050 eprintln!("Could not resolve PowerShell profile directory");
1051 return;
1052 }
1053 };
1054
1055 let binary_escaped = binary.replace('\\', "\\\\");
1056 let functions = format!(
1057 r#"
1058# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1059if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED) {{
1060 $LeanCtxBin = "{binary_escaped}"
1061 function _lc {{
1062 if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) {{ & @args; return }}
1063 & $LeanCtxBin -c @args
1064 if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
1065 & @args
1066 }}
1067 }}
1068 function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
1069 if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
1070 function git {{ _lc git @args }}
1071 function cargo {{ _lc cargo @args }}
1072 function docker {{ _lc docker @args }}
1073 function kubectl {{ _lc kubectl @args }}
1074 function gh {{ _lc gh @args }}
1075 function pip {{ _lc pip @args }}
1076 function pip3 {{ _lc pip3 @args }}
1077 function ruff {{ _lc ruff @args }}
1078 function go {{ _lc go @args }}
1079 function curl {{ _lc curl @args }}
1080 function wget {{ _lc wget @args }}
1081 foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
1082 if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
1083 New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc $c @args")) -Force | Out-Null
1084 }}
1085 }}
1086 }}
1087}}
1088"#
1089 );
1090
1091 backup_shell_config(&profile_path);
1092
1093 if let Ok(existing) = std::fs::read_to_string(&profile_path) {
1094 if existing.contains("lean-ctx shell hook") {
1095 let cleaned = remove_lean_ctx_block_ps(&existing);
1096 match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
1097 Ok(()) => {
1098 qprintln!("Updated lean-ctx functions in {}", profile_path.display());
1099 qprintln!(" Binary: {binary}");
1100 return;
1101 }
1102 Err(e) => {
1103 eprintln!("Error updating {}: {e}", profile_path.display());
1104 return;
1105 }
1106 }
1107 }
1108 }
1109
1110 match std::fs::OpenOptions::new()
1111 .append(true)
1112 .create(true)
1113 .open(&profile_path)
1114 {
1115 Ok(mut f) => {
1116 use std::io::Write;
1117 let _ = f.write_all(functions.as_bytes());
1118 qprintln!("Added lean-ctx functions to {}", profile_path.display());
1119 qprintln!(" Binary: {binary}");
1120 }
1121 Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
1122 }
1123}
1124
1125fn remove_lean_ctx_block_ps(content: &str) -> String {
1126 let mut result = String::new();
1127 let mut in_block = false;
1128 let mut brace_depth = 0i32;
1129
1130 for line in content.lines() {
1131 if line.contains("lean-ctx shell hook") {
1132 in_block = true;
1133 continue;
1134 }
1135 if in_block {
1136 brace_depth += line.matches('{').count() as i32;
1137 brace_depth -= line.matches('}').count() as i32;
1138 if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
1139 if line.trim() == "}" {
1140 in_block = false;
1141 brace_depth = 0;
1142 }
1143 continue;
1144 }
1145 continue;
1146 }
1147 result.push_str(line);
1148 result.push('\n');
1149 }
1150 result
1151}
1152
1153pub fn init_fish(binary: &str) {
1154 let config = dirs::home_dir()
1155 .map(|h| h.join(".config/fish/config.fish"))
1156 .unwrap_or_default();
1157
1158 let alias_list = crate::rewrite_registry::shell_alias_list();
1159 let aliases = format!(
1160 "\n# lean-ctx shell hook — smart shell mode (track-by-default)\n\
1161 set -g _lean_ctx_cmds {alias_list}\n\
1162 \n\
1163 function _lc\n\
1164 \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1165 \t\tcommand $argv\n\
1166 \t\treturn\n\
1167 \tend\n\
1168 \t'{binary}' -t $argv\n\
1169 \tset -l _lc_rc $status\n\
1170 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1171 \t\tcommand $argv\n\
1172 \telse\n\
1173 \t\treturn $_lc_rc\n\
1174 \tend\n\
1175 end\n\
1176 \n\
1177 function _lc_compress\n\
1178 \tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\
1179 \t\tcommand $argv\n\
1180 \t\treturn\n\
1181 \tend\n\
1182 \t'{binary}' -c $argv\n\
1183 \tset -l _lc_rc $status\n\
1184 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1185 \t\tcommand $argv\n\
1186 \telse\n\
1187 \t\treturn $_lc_rc\n\
1188 \tend\n\
1189 end\n\
1190 \n\
1191 function lean-ctx-on\n\
1192 \tfor _lc_cmd in $_lean_ctx_cmds\n\
1193 \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
1194 \tend\n\
1195 \talias k '_lc kubectl'\n\
1196 \tset -gx LEAN_CTX_ENABLED 1\n\
1197 \techo 'lean-ctx: ON (track mode — full output, stats recorded)'\n\
1198 end\n\
1199 \n\
1200 function lean-ctx-off\n\
1201 \tfor _lc_cmd in $_lean_ctx_cmds\n\
1202 \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
1203 \tend\n\
1204 \tfunctions --erase k 2>/dev/null; true\n\
1205 \tset -e LEAN_CTX_ENABLED\n\
1206 \techo 'lean-ctx: OFF'\n\
1207 end\n\
1208 \n\
1209 function lean-ctx-mode\n\
1210 \tswitch $argv[1]\n\
1211 \t\tcase compress\n\
1212 \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
1213 \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
1214 \t\t\t\tend\n\
1215 \t\t\talias k '_lc_compress kubectl'\n\
1216 \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
1217 \t\t\techo 'lean-ctx: COMPRESS mode (all output compressed)'\n\
1218 \t\tcase track\n\
1219 \t\t\tlean-ctx-on\n\
1220 \t\tcase off\n\
1221 \t\t\tlean-ctx-off\n\
1222 \t\tcase '*'\n\
1223 \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
1224 \t\t\techo ' track — Full output, stats recorded (default)'\n\
1225 \t\t\techo ' compress — Compressed output for all commands'\n\
1226 \t\t\techo ' off — No aliases, raw shell'\n\
1227 \tend\n\
1228 end\n\
1229 \n\
1230 function lean-ctx-raw\n\
1231 \tset -lx LEAN_CTX_RAW 1\n\
1232 \tcommand $argv\n\
1233 end\n\
1234 \n\
1235 function lean-ctx-status\n\
1236 \tif set -q LEAN_CTX_DISABLED\n\
1237 \t\techo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
1238 \telse if set -q LEAN_CTX_ENABLED\n\
1239 \t\techo 'lean-ctx: ON'\n\
1240 \telse\n\
1241 \t\techo 'lean-ctx: OFF'\n\
1242 \tend\n\
1243 end\n\
1244 \n\
1245 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\
1246 \tif command -q lean-ctx\n\
1247 \t\tlean-ctx-on\n\
1248 \tend\n\
1249 end\n\
1250 # lean-ctx shell hook — end\n"
1251 );
1252
1253 backup_shell_config(&config);
1254
1255 if let Ok(existing) = std::fs::read_to_string(&config) {
1256 if existing.contains("lean-ctx shell hook") {
1257 let cleaned = remove_lean_ctx_block(&existing);
1258 match std::fs::write(&config, format!("{cleaned}{aliases}")) {
1259 Ok(()) => {
1260 qprintln!("Updated lean-ctx aliases in {}", config.display());
1261 qprintln!(" Binary: {binary}");
1262 return;
1263 }
1264 Err(e) => {
1265 eprintln!("Error updating {}: {e}", config.display());
1266 return;
1267 }
1268 }
1269 }
1270 }
1271
1272 match std::fs::OpenOptions::new()
1273 .append(true)
1274 .create(true)
1275 .open(&config)
1276 {
1277 Ok(mut f) => {
1278 use std::io::Write;
1279 let _ = f.write_all(aliases.as_bytes());
1280 qprintln!("Added lean-ctx aliases to {}", config.display());
1281 qprintln!(" Binary: {binary}");
1282 }
1283 Err(e) => eprintln!("Error writing {}: {e}", config.display()),
1284 }
1285}
1286
1287pub fn init_posix(is_zsh: bool, binary: &str) {
1288 let rc_file = if is_zsh {
1289 dirs::home_dir()
1290 .map(|h| h.join(".zshrc"))
1291 .unwrap_or_default()
1292 } else {
1293 dirs::home_dir()
1294 .map(|h| h.join(".bashrc"))
1295 .unwrap_or_default()
1296 };
1297
1298 let alias_list = crate::rewrite_registry::shell_alias_list();
1299 let aliases = format!(
1300 r#"
1301# lean-ctx shell hook — smart shell mode (track-by-default)
1302_lean_ctx_cmds=({alias_list})
1303
1304_lc() {{
1305 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1306 command "$@"
1307 return
1308 fi
1309 '{binary}' -t "$@"
1310 local _lc_rc=$?
1311 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1312 command "$@"
1313 else
1314 return "$_lc_rc"
1315 fi
1316}}
1317
1318_lc_compress() {{
1319 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1320 command "$@"
1321 return
1322 fi
1323 '{binary}' -c "$@"
1324 local _lc_rc=$?
1325 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1326 command "$@"
1327 else
1328 return "$_lc_rc"
1329 fi
1330}}
1331
1332lean-ctx-on() {{
1333 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1334 # shellcheck disable=SC2139
1335 alias "$_lc_cmd"='_lc '"$_lc_cmd"
1336 done
1337 alias k='_lc kubectl'
1338 export LEAN_CTX_ENABLED=1
1339 echo "lean-ctx: ON (track mode — full output, stats recorded)"
1340}}
1341
1342lean-ctx-off() {{
1343 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1344 unalias "$_lc_cmd" 2>/dev/null || true
1345 done
1346 unalias k 2>/dev/null || true
1347 unset LEAN_CTX_ENABLED
1348 echo "lean-ctx: OFF"
1349}}
1350
1351lean-ctx-mode() {{
1352 case "${{1:-}}" in
1353 compress)
1354 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1355 # shellcheck disable=SC2139
1356 alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
1357 done
1358 alias k='_lc_compress kubectl'
1359 export LEAN_CTX_ENABLED=1
1360 echo "lean-ctx: COMPRESS mode (all output compressed)"
1361 ;;
1362 track)
1363 lean-ctx-on
1364 ;;
1365 off)
1366 lean-ctx-off
1367 ;;
1368 *)
1369 echo "Usage: lean-ctx-mode <track|compress|off>"
1370 echo " track — Full output, stats recorded (default)"
1371 echo " compress — Compressed output for all commands"
1372 echo " off — No aliases, raw shell"
1373 ;;
1374 esac
1375}}
1376
1377lean-ctx-raw() {{
1378 LEAN_CTX_RAW=1 command "$@"
1379}}
1380
1381lean-ctx-status() {{
1382 if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1383 echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
1384 elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1385 echo "lean-ctx: ON"
1386 else
1387 echo "lean-ctx: OFF"
1388 fi
1389}}
1390
1391if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1392 command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
1393fi
1394# lean-ctx shell hook — end
1395"#
1396 );
1397
1398 backup_shell_config(&rc_file);
1399
1400 if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1401 if existing.contains("lean-ctx shell hook") {
1402 let cleaned = remove_lean_ctx_block(&existing);
1403 match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1404 Ok(()) => {
1405 qprintln!("Updated lean-ctx aliases in {}", rc_file.display());
1406 qprintln!(" Binary: {binary}");
1407 return;
1408 }
1409 Err(e) => {
1410 eprintln!("Error updating {}: {e}", rc_file.display());
1411 return;
1412 }
1413 }
1414 }
1415 }
1416
1417 match std::fs::OpenOptions::new()
1418 .append(true)
1419 .create(true)
1420 .open(&rc_file)
1421 {
1422 Ok(mut f) => {
1423 use std::io::Write;
1424 let _ = f.write_all(aliases.as_bytes());
1425 qprintln!("Added lean-ctx aliases to {}", rc_file.display());
1426 qprintln!(" Binary: {binary}");
1427 }
1428 Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1429 }
1430
1431 write_env_sh_for_containers(&aliases);
1432 print_docker_env_hints(is_zsh);
1433}
1434
1435fn write_env_sh_for_containers(aliases: &str) {
1436 let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
1437 Ok(d) => d.join("env.sh"),
1438 Err(_) => return,
1439 };
1440 if let Some(parent) = env_sh.parent() {
1441 let _ = std::fs::create_dir_all(parent);
1442 }
1443 let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
1444 let mut content = sanitized_aliases;
1445 content.push_str(
1446 r#"
1447
1448# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
1449if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
1450 if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
1451 LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
1452 fi
1453fi
1454"#,
1455 );
1456 match std::fs::write(&env_sh, content) {
1457 Ok(()) => qprintln!(" env.sh: {}", env_sh.display()),
1458 Err(e) => eprintln!(" Warning: could not write {}: {e}", env_sh.display()),
1459 }
1460}
1461
1462fn print_docker_env_hints(is_zsh: bool) {
1463 if is_zsh || !crate::shell::is_container() {
1464 return;
1465 }
1466 let env_sh = crate::core::data_dir::lean_ctx_data_dir()
1467 .map(|d| d.join("env.sh").to_string_lossy().to_string())
1468 .unwrap_or_else(|_| "/root/.lean-ctx/env.sh".to_string());
1469
1470 let has_bash_env = std::env::var("BASH_ENV").is_ok();
1471 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
1472
1473 if has_bash_env && has_claude_env {
1474 return;
1475 }
1476
1477 eprintln!();
1478 eprintln!(" \x1b[33m⚠ Docker detected — environment hints:\x1b[0m");
1479
1480 if !has_bash_env {
1481 eprintln!(" For generic bash -c usage (non-interactive shells):");
1482 eprintln!(" \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
1483 }
1484 if !has_claude_env {
1485 eprintln!(" For Claude Code (sources before each command):");
1486 eprintln!(" \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
1487 }
1488 eprintln!();
1489}
1490
1491fn remove_lean_ctx_block(content: &str) -> String {
1492 if content.contains("# lean-ctx shell hook — end") {
1494 return remove_lean_ctx_block_by_marker(content);
1495 }
1496 remove_lean_ctx_block_legacy(content)
1497}
1498
1499fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1500 let mut result = String::new();
1501 let mut in_block = false;
1502
1503 for line in content.lines() {
1504 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1505 in_block = true;
1506 continue;
1507 }
1508 if in_block {
1509 if line.trim() == "# lean-ctx shell hook — end" {
1510 in_block = false;
1511 }
1512 continue;
1513 }
1514 result.push_str(line);
1515 result.push('\n');
1516 }
1517 result
1518}
1519
1520fn remove_lean_ctx_block_legacy(content: &str) -> String {
1521 let mut result = String::new();
1522 let mut in_block = false;
1523
1524 for line in content.lines() {
1525 if line.contains("lean-ctx shell hook") {
1526 in_block = true;
1527 continue;
1528 }
1529 if in_block {
1530 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1531 if line.trim() == "fi" || line.trim() == "end" {
1532 in_block = false;
1533 }
1534 continue;
1535 }
1536 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1537 in_block = false;
1538 result.push_str(line);
1539 result.push('\n');
1540 }
1541 continue;
1542 }
1543 result.push_str(line);
1544 result.push('\n');
1545 }
1546 result
1547}
1548
1549pub fn load_shell_history_pub() -> Vec<String> {
1550 load_shell_history()
1551}
1552
1553fn load_shell_history() -> Vec<String> {
1554 let shell = std::env::var("SHELL").unwrap_or_default();
1555 let home = match dirs::home_dir() {
1556 Some(h) => h,
1557 None => return Vec::new(),
1558 };
1559
1560 let history_file = if shell.contains("zsh") {
1561 home.join(".zsh_history")
1562 } else if shell.contains("fish") {
1563 home.join(".local/share/fish/fish_history")
1564 } else if cfg!(windows) && shell.is_empty() {
1565 home.join("AppData")
1566 .join("Roaming")
1567 .join("Microsoft")
1568 .join("Windows")
1569 .join("PowerShell")
1570 .join("PSReadLine")
1571 .join("ConsoleHost_history.txt")
1572 } else {
1573 home.join(".bash_history")
1574 };
1575
1576 match std::fs::read_to_string(&history_file) {
1577 Ok(content) => content
1578 .lines()
1579 .filter_map(|l| {
1580 let trimmed = l.trim();
1581 if trimmed.starts_with(':') {
1582 trimmed.split(';').nth(1).map(|s| s.to_string())
1583 } else {
1584 Some(trimmed.to_string())
1585 }
1586 })
1587 .filter(|l| !l.is_empty())
1588 .collect(),
1589 Err(_) => Vec::new(),
1590 }
1591}
1592
1593fn print_savings(original: usize, sent: usize) {
1594 let saved = original.saturating_sub(sent);
1595 if original > 0 && saved > 0 {
1596 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1597 println!("[{saved} tok saved ({pct}%)]");
1598 }
1599}
1600
1601pub fn cmd_theme(args: &[String]) {
1602 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1603 let r = theme::rst();
1604 let b = theme::bold();
1605 let d = theme::dim();
1606
1607 match sub {
1608 "list" => {
1609 let cfg = config::Config::load();
1610 let active = cfg.theme.as_str();
1611 println!();
1612 println!(" {b}Available themes:{r}");
1613 println!(" {ln}", ln = "─".repeat(40));
1614 for name in theme::PRESET_NAMES {
1615 let marker = if *name == active { " ◀ active" } else { "" };
1616 let t = theme::from_preset(name).unwrap();
1617 let preview = format!(
1618 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1619 p = t.primary.fg(),
1620 s = t.secondary.fg(),
1621 a = t.accent.fg(),
1622 sc = t.success.fg(),
1623 w = t.warning.fg(),
1624 );
1625 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1626 }
1627 if let Some(path) = theme::theme_file_path() {
1628 if path.exists() {
1629 let custom = theme::load_theme("_custom_");
1630 let preview = format!(
1631 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1632 p = custom.primary.fg(),
1633 s = custom.secondary.fg(),
1634 a = custom.accent.fg(),
1635 sc = custom.success.fg(),
1636 w = custom.warning.fg(),
1637 );
1638 let marker = if active == "custom" {
1639 " ◀ active"
1640 } else {
1641 ""
1642 };
1643 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1644 }
1645 }
1646 println!();
1647 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1648 println!();
1649 }
1650 "set" => {
1651 if args.len() < 2 {
1652 eprintln!("Usage: lean-ctx theme set <name>");
1653 std::process::exit(1);
1654 }
1655 let name = &args[1];
1656 if theme::from_preset(name).is_none() && name != "custom" {
1657 eprintln!(
1658 "Unknown theme '{name}'. Available: {}",
1659 theme::PRESET_NAMES.join(", ")
1660 );
1661 std::process::exit(1);
1662 }
1663 let mut cfg = config::Config::load();
1664 cfg.theme = name.to_string();
1665 match cfg.save() {
1666 Ok(()) => {
1667 let t = theme::load_theme(name);
1668 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1669 let preview = t.gradient_bar(0.75, 30);
1670 println!(" {preview}");
1671 }
1672 Err(e) => eprintln!("Error: {e}"),
1673 }
1674 }
1675 "export" => {
1676 let cfg = config::Config::load();
1677 let t = theme::load_theme(&cfg.theme);
1678 println!("{}", t.to_toml());
1679 }
1680 "import" => {
1681 if args.len() < 2 {
1682 eprintln!("Usage: lean-ctx theme import <path>");
1683 std::process::exit(1);
1684 }
1685 let path = std::path::Path::new(&args[1]);
1686 if !path.exists() {
1687 eprintln!("File not found: {}", args[1]);
1688 std::process::exit(1);
1689 }
1690 match std::fs::read_to_string(path) {
1691 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1692 Ok(imported) => match theme::save_theme(&imported) {
1693 Ok(()) => {
1694 let mut cfg = config::Config::load();
1695 cfg.theme = "custom".to_string();
1696 let _ = cfg.save();
1697 println!(
1698 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1699 sc = imported.success.fg(),
1700 name = imported.name,
1701 );
1702 println!(" Config updated: theme = custom");
1703 }
1704 Err(e) => eprintln!("Error saving theme: {e}"),
1705 },
1706 Err(e) => eprintln!("Invalid theme file: {e}"),
1707 },
1708 Err(e) => eprintln!("Error reading file: {e}"),
1709 }
1710 }
1711 "preview" => {
1712 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1713 let t = match theme::from_preset(name) {
1714 Some(t) => t,
1715 None => {
1716 eprintln!("Unknown theme: {name}");
1717 std::process::exit(1);
1718 }
1719 };
1720 println!();
1721 println!(
1722 " {icon} {title} {d}Theme Preview: {name}{r}",
1723 icon = t.header_icon(),
1724 title = t.brand_title(),
1725 );
1726 println!(" {ln}", ln = t.border_line(50));
1727 println!();
1728 println!(
1729 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1730 sc = t.success.fg(),
1731 sec = t.secondary.fg(),
1732 wrn = t.warning.fg(),
1733 acc = t.accent.fg(),
1734 );
1735 println!(" {d} tokens saved compression commands USD saved{r}");
1736 println!();
1737 println!(
1738 " {b}{txt}Gradient Bar{r} {bar}",
1739 txt = t.text.fg(),
1740 bar = t.gradient_bar(0.85, 30),
1741 );
1742 println!(
1743 " {b}{txt}Sparkline{r} {spark}",
1744 txt = t.text.fg(),
1745 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1746 );
1747 println!();
1748 println!(" {top}", top = t.box_top(50));
1749 println!(
1750 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1751 side = t.box_side(),
1752 side_r = t.box_side(),
1753 txt = t.text.fg(),
1754 );
1755 println!(" {bot}", bot = t.box_bottom(50));
1756 println!();
1757 }
1758 _ => {
1759 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1760 std::process::exit(1);
1761 }
1762 }
1763}
1764
1765#[cfg(test)]
1766mod tests {
1767 use super::*;
1768 use tempfile;
1769
1770 #[test]
1771 fn test_remove_lean_ctx_block_posix() {
1772 let input = r#"# existing config
1773export PATH="$HOME/bin:$PATH"
1774
1775# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1776if [ -z "$LEAN_CTX_ACTIVE" ]; then
1777alias git='lean-ctx -c git'
1778alias npm='lean-ctx -c npm'
1779fi
1780
1781# other stuff
1782export EDITOR=vim
1783"#;
1784 let result = remove_lean_ctx_block(input);
1785 assert!(!result.contains("lean-ctx"), "block should be removed");
1786 assert!(result.contains("export PATH"), "other content preserved");
1787 assert!(
1788 result.contains("export EDITOR"),
1789 "trailing content preserved"
1790 );
1791 }
1792
1793 #[test]
1794 fn test_remove_lean_ctx_block_fish() {
1795 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";
1796 let result = remove_lean_ctx_block(input);
1797 assert!(!result.contains("lean-ctx"), "block should be removed");
1798 assert!(result.contains("set -x FOO"), "other content preserved");
1799 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1800 }
1801
1802 #[test]
1803 fn test_remove_lean_ctx_block_ps() {
1804 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";
1805 let result = remove_lean_ctx_block_ps(input);
1806 assert!(
1807 !result.contains("lean-ctx shell hook"),
1808 "block should be removed"
1809 );
1810 assert!(result.contains("$env:FOO"), "other content preserved");
1811 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1812 }
1813
1814 #[test]
1815 fn test_remove_lean_ctx_block_ps_nested() {
1816 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";
1817 let result = remove_lean_ctx_block_ps(input);
1818 assert!(
1819 !result.contains("lean-ctx shell hook"),
1820 "block should be removed"
1821 );
1822 assert!(!result.contains("_lc"), "function should be removed");
1823 assert!(result.contains("$env:FOO"), "other content preserved");
1824 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1825 }
1826
1827 #[test]
1828 fn test_remove_block_no_lean_ctx() {
1829 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1830 let result = remove_lean_ctx_block(input);
1831 assert!(result.contains("export PATH"), "content unchanged");
1832 }
1833
1834 #[test]
1835 fn test_bash_hook_contains_pipe_guard() {
1836 let binary = "/usr/local/bin/lean-ctx";
1837 let hook = format!(
1838 r#"_lc() {{
1839 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1840 command "$@"
1841 return
1842 fi
1843 '{binary}' -t "$@"
1844}}"#
1845 );
1846 assert!(
1847 hook.contains("! -t 1"),
1848 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1849 );
1850 assert!(
1851 hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1852 "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1853 );
1854 }
1855
1856 #[test]
1857 fn test_lc_uses_track_mode_by_default() {
1858 let binary = "/usr/local/bin/lean-ctx";
1859 let alias_list = crate::rewrite_registry::shell_alias_list();
1860 let aliases = format!(
1861 r#"_lc() {{
1862 '{binary}' -t "$@"
1863}}
1864_lc_compress() {{
1865 '{binary}' -c "$@"
1866}}"#
1867 );
1868 assert!(
1869 aliases.contains("-t \"$@\""),
1870 "_lc must use -t (track mode) by default"
1871 );
1872 assert!(
1873 aliases.contains("-c \"$@\""),
1874 "_lc_compress must use -c (compress mode)"
1875 );
1876 let _ = alias_list;
1877 }
1878
1879 #[test]
1880 fn test_posix_shell_has_lean_ctx_mode() {
1881 let alias_list = crate::rewrite_registry::shell_alias_list();
1882 let aliases = r#"
1883lean-ctx-mode() {{
1884 case "${{1:-}}" in
1885 compress) echo compress ;;
1886 track) echo track ;;
1887 off) echo off ;;
1888 esac
1889}}
1890"#
1891 .to_string();
1892 assert!(
1893 aliases.contains("lean-ctx-mode()"),
1894 "lean-ctx-mode function must exist"
1895 );
1896 assert!(
1897 aliases.contains("compress"),
1898 "compress mode must be available"
1899 );
1900 assert!(aliases.contains("track"), "track mode must be available");
1901 let _ = alias_list;
1902 }
1903
1904 #[test]
1905 fn test_fish_hook_contains_pipe_guard() {
1906 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";
1907 assert!(
1908 hook.contains("isatty stdout"),
1909 "fish hook must contain pipe guard (isatty stdout)"
1910 );
1911 }
1912
1913 #[test]
1914 fn test_powershell_hook_contains_pipe_guard() {
1915 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1916 assert!(
1917 hook.contains("IsOutputRedirected"),
1918 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1919 );
1920 }
1921
1922 #[test]
1923 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1924 let input = r#"# existing config
1925export PATH="$HOME/bin:$PATH"
1926
1927# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1928_lean_ctx_cmds=(git npm pnpm)
1929
1930lean-ctx-on() {
1931 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1932 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1933 done
1934 export LEAN_CTX_ENABLED=1
1935 echo "lean-ctx: ON"
1936}
1937
1938lean-ctx-off() {
1939 unset LEAN_CTX_ENABLED
1940 echo "lean-ctx: OFF"
1941}
1942
1943if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1944 lean-ctx-on
1945fi
1946# lean-ctx shell hook — end
1947
1948# other stuff
1949export EDITOR=vim
1950"#;
1951 let result = remove_lean_ctx_block(input);
1952 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1953 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1954 assert!(result.contains("export PATH"), "other content preserved");
1955 assert!(
1956 result.contains("export EDITOR"),
1957 "trailing content preserved"
1958 );
1959 }
1960
1961 #[test]
1962 fn env_sh_for_containers_includes_self_heal() {
1963 let _g = crate::core::data_dir::test_env_lock();
1964 let tmp = tempfile::tempdir().expect("tempdir");
1965 let data_dir = tmp.path().join("data");
1966 std::fs::create_dir_all(&data_dir).expect("mkdir data");
1967 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1968
1969 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1970 let env_sh = data_dir.join("env.sh");
1971 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1972 assert!(content.contains("lean-ctx docker self-heal"));
1973 assert!(content.contains("claude mcp list"));
1974 assert!(content.contains("lean-ctx init --agent claude"));
1975
1976 std::env::remove_var("LEAN_CTX_DATA_DIR");
1977 }
1978}