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