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, msg.len());
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 pattern = args[0].to_lowercase();
230 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
231
232 let mut found = false;
233 for entry in ignore::WalkBuilder::new(path)
234 .hidden(true)
235 .git_ignore(true)
236 .git_global(true)
237 .git_exclude(true)
238 .max_depth(Some(10))
239 .build()
240 .flatten()
241 {
242 let name = entry.file_name().to_string_lossy().to_lowercase();
243 if name.contains(&pattern) {
244 println!("{}", entry.path().display());
245 found = true;
246 }
247 }
248
249 if !found {
250 std::process::exit(1);
251 }
252}
253
254pub fn cmd_ls(args: &[String]) {
255 let path = args.first().map(|s| s.as_str()).unwrap_or(".");
256 let command = if cfg!(windows) {
257 format!("dir {}", path.replace('/', "\\"))
258 } else {
259 format!("ls -la {path}")
260 };
261 let code = crate::shell::exec(&command);
262 std::process::exit(code);
263}
264
265pub fn cmd_deps(args: &[String]) {
266 let path = args.first().map(|s| s.as_str()).unwrap_or(".");
267
268 match deps_cmd::detect_and_compress(path) {
269 Some(result) => println!("{result}"),
270 None => {
271 eprintln!("No dependency file found in {path}");
272 std::process::exit(1);
273 }
274 }
275}
276
277pub fn cmd_discover(_args: &[String]) {
278 let history = load_shell_history();
279 if history.is_empty() {
280 println!("No shell history found.");
281 return;
282 }
283
284 let result = crate::tools::ctx_discover::analyze_history(&history, 20);
285 println!("{}", crate::tools::ctx_discover::format_cli_output(&result));
286}
287
288pub fn cmd_session() {
289 let history = load_shell_history();
290 let gain = stats::load_stats();
291
292 let compressible_commands = [
293 "git ",
294 "npm ",
295 "yarn ",
296 "pnpm ",
297 "cargo ",
298 "docker ",
299 "kubectl ",
300 "gh ",
301 "pip ",
302 "pip3 ",
303 "eslint",
304 "prettier",
305 "ruff ",
306 "go ",
307 "golangci-lint",
308 "curl ",
309 "wget ",
310 "grep ",
311 "rg ",
312 "find ",
313 "ls ",
314 ];
315
316 let mut total = 0u32;
317 let mut via_hook = 0u32;
318
319 for line in &history {
320 let cmd = line.trim().to_lowercase();
321 if cmd.starts_with("lean-ctx") {
322 via_hook += 1;
323 total += 1;
324 } else {
325 for p in &compressible_commands {
326 if cmd.starts_with(p) {
327 total += 1;
328 break;
329 }
330 }
331 }
332 }
333
334 let pct = if total > 0 {
335 (via_hook as f64 / total as f64 * 100.0).round() as u32
336 } else {
337 0
338 };
339
340 println!("lean-ctx session statistics\n");
341 println!(
342 "Adoption: {}% ({}/{} compressible commands)",
343 pct, via_hook, total
344 );
345 println!("Saved: {} tokens total", gain.total_saved);
346 println!("Calls: {} compressed", gain.total_calls);
347
348 if total > via_hook {
349 let missed = total - via_hook;
350 let est = missed * 150;
351 println!(
352 "Missed: {} commands (~{} tokens saveable)",
353 missed, est
354 );
355 }
356
357 println!("\nRun 'lean-ctx discover' for details on missed commands.");
358}
359
360pub fn cmd_wrapped(args: &[String]) {
361 let period = if args.iter().any(|a| a == "--month") {
362 "month"
363 } else if args.iter().any(|a| a == "--all") {
364 "all"
365 } else {
366 "week"
367 };
368
369 let report = crate::core::wrapped::WrappedReport::generate(period);
370 println!("{}", report.format_ascii());
371}
372
373pub fn cmd_sessions(args: &[String]) {
374 use crate::core::session::SessionState;
375
376 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
377
378 match action {
379 "list" | "ls" => {
380 let sessions = SessionState::list_sessions();
381 if sessions.is_empty() {
382 println!("No sessions found.");
383 return;
384 }
385 println!("Sessions ({}):\n", sessions.len());
386 for s in sessions.iter().take(20) {
387 let task = s.task.as_deref().unwrap_or("(no task)");
388 let task_short: String = task.chars().take(50).collect();
389 let date = s.updated_at.format("%Y-%m-%d %H:%M");
390 println!(
391 " {} | v{:3} | {:5} calls | {:>8} tok | {} | {}",
392 s.id,
393 s.version,
394 s.tool_calls,
395 format_tokens_cli(s.tokens_saved),
396 date,
397 task_short
398 );
399 }
400 if sessions.len() > 20 {
401 println!(" ... +{} more", sessions.len() - 20);
402 }
403 }
404 "show" => {
405 let id = args.get(1);
406 let session = if let Some(id) = id {
407 SessionState::load_by_id(id)
408 } else {
409 SessionState::load_latest()
410 };
411 match session {
412 Some(s) => println!("{}", s.format_compact()),
413 None => println!("Session not found."),
414 }
415 }
416 "cleanup" => {
417 let days = args.get(1).and_then(|s| s.parse::<i64>().ok()).unwrap_or(7);
418 let removed = SessionState::cleanup_old_sessions(days);
419 println!("Cleaned up {removed} session(s) older than {days} days.");
420 }
421 _ => {
422 eprintln!("Usage: lean-ctx sessions [list|show [id]|cleanup [days]]");
423 std::process::exit(1);
424 }
425 }
426}
427
428pub fn cmd_benchmark(args: &[String]) {
429 use crate::core::benchmark;
430
431 let action = args.first().map(|s| s.as_str()).unwrap_or("run");
432
433 match action {
434 "run" => {
435 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
436 let is_json = args.iter().any(|a| a == "--json");
437
438 let result = benchmark::run_project_benchmark(path);
439 if is_json {
440 println!("{}", benchmark::format_json(&result));
441 } else {
442 println!("{}", benchmark::format_terminal(&result));
443 }
444 }
445 "report" => {
446 let path = args.get(1).map(|s| s.as_str()).unwrap_or(".");
447 let result = benchmark::run_project_benchmark(path);
448 println!("{}", benchmark::format_markdown(&result));
449 }
450 _ => {
451 if std::path::Path::new(action).exists() {
452 let result = benchmark::run_project_benchmark(action);
453 println!("{}", benchmark::format_terminal(&result));
454 } else {
455 eprintln!("Usage: lean-ctx benchmark run [path] [--json]");
456 eprintln!(" lean-ctx benchmark report [path]");
457 std::process::exit(1);
458 }
459 }
460 }
461}
462
463fn format_tokens_cli(tokens: u64) -> String {
464 if tokens >= 1_000_000 {
465 format!("{:.1}M", tokens as f64 / 1_000_000.0)
466 } else if tokens >= 1_000 {
467 format!("{:.1}K", tokens as f64 / 1_000.0)
468 } else {
469 format!("{tokens}")
470 }
471}
472
473pub fn cmd_stats(args: &[String]) {
474 match args.first().map(|s| s.as_str()) {
475 Some("reset-cep") => {
476 crate::core::stats::reset_cep();
477 println!("CEP stats reset. Shell hook data preserved.");
478 }
479 Some("json") => {
480 let store = crate::core::stats::load();
481 println!(
482 "{}",
483 serde_json::to_string_pretty(&store).unwrap_or_else(|_| "{}".to_string())
484 );
485 }
486 _ => {
487 let store = crate::core::stats::load();
488 let input_saved = store
489 .total_input_tokens
490 .saturating_sub(store.total_output_tokens);
491 let pct = if store.total_input_tokens > 0 {
492 input_saved as f64 / store.total_input_tokens as f64 * 100.0
493 } else {
494 0.0
495 };
496 println!("Commands: {}", store.total_commands);
497 println!("Input: {} tokens", store.total_input_tokens);
498 println!("Output: {} tokens", store.total_output_tokens);
499 println!("Saved: {} tokens ({:.1}%)", input_saved, pct);
500 println!();
501 println!("CEP sessions: {}", store.cep.sessions);
502 println!(
503 "CEP tokens: {} → {}",
504 store.cep.total_tokens_original, store.cep.total_tokens_compressed
505 );
506 println!();
507 println!("Subcommands: stats reset-cep | stats json");
508 }
509 }
510}
511
512pub fn cmd_cache(args: &[String]) {
513 use crate::core::cli_cache;
514 match args.first().map(|s| s.as_str()) {
515 Some("clear") => {
516 let count = cli_cache::clear();
517 println!("Cleared {count} cached entries.");
518 }
519 Some("stats") => {
520 let (hits, reads, entries) = cli_cache::stats();
521 let rate = if reads > 0 {
522 (hits as f64 / reads as f64 * 100.0).round() as u32
523 } else {
524 0
525 };
526 println!("CLI Cache Stats:");
527 println!(" Entries: {entries}");
528 println!(" Reads: {reads}");
529 println!(" Hits: {hits}");
530 println!(" Hit Rate: {rate}%");
531 }
532 Some("invalidate") => {
533 if args.len() < 2 {
534 eprintln!("Usage: lean-ctx cache invalidate <path>");
535 std::process::exit(1);
536 }
537 cli_cache::invalidate(&args[1]);
538 println!("Invalidated cache for {}", args[1]);
539 }
540 _ => {
541 let (hits, reads, entries) = cli_cache::stats();
542 let rate = if reads > 0 {
543 (hits as f64 / reads as f64 * 100.0).round() as u32
544 } else {
545 0
546 };
547 println!("CLI File Cache: {entries} entries, {hits}/{reads} hits ({rate}%)");
548 println!();
549 println!("Subcommands:");
550 println!(" cache stats Show detailed stats");
551 println!(" cache clear Clear all cached entries");
552 println!(" cache invalidate Remove specific file from cache");
553 }
554 }
555}
556
557pub fn cmd_config(args: &[String]) {
558 let cfg = config::Config::load();
559
560 if args.is_empty() {
561 println!("{}", cfg.show());
562 return;
563 }
564
565 match args[0].as_str() {
566 "init" | "create" => {
567 let default = config::Config::default();
568 match default.save() {
569 Ok(()) => {
570 let path = config::Config::path()
571 .map(|p| p.to_string_lossy().to_string())
572 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
573 println!("Created default config at {path}");
574 }
575 Err(e) => eprintln!("Error: {e}"),
576 }
577 }
578 "set" => {
579 if args.len() < 3 {
580 eprintln!("Usage: lean-ctx config set <key> <value>");
581 std::process::exit(1);
582 }
583 let mut cfg = cfg;
584 let key = &args[1];
585 let val = &args[2];
586 match key.as_str() {
587 "ultra_compact" => cfg.ultra_compact = val == "true",
588 "tee_on_error" | "tee_mode" => {
589 cfg.tee_mode = match val.as_str() {
590 "true" | "failures" => config::TeeMode::Failures,
591 "always" => config::TeeMode::Always,
592 "false" | "never" => config::TeeMode::Never,
593 _ => {
594 eprintln!("Valid tee_mode values: always, failures, never");
595 std::process::exit(1);
596 }
597 };
598 }
599 "checkpoint_interval" => {
600 cfg.checkpoint_interval = val.parse().unwrap_or(15);
601 }
602 "theme" => {
603 if theme::from_preset(val).is_some() || val == "custom" {
604 cfg.theme = val.to_string();
605 } else {
606 eprintln!(
607 "Unknown theme '{val}'. Available: {}",
608 theme::PRESET_NAMES.join(", ")
609 );
610 std::process::exit(1);
611 }
612 }
613 "slow_command_threshold_ms" => {
614 cfg.slow_command_threshold_ms = val.parse().unwrap_or(5000);
615 }
616 "passthrough_urls" => {
617 cfg.passthrough_urls = val.split(',').map(|s| s.trim().to_string()).collect();
618 }
619 _ => {
620 eprintln!("Unknown config key: {key}");
621 std::process::exit(1);
622 }
623 }
624 match cfg.save() {
625 Ok(()) => println!("Updated {key} = {val}"),
626 Err(e) => eprintln!("Error saving config: {e}"),
627 }
628 }
629 _ => {
630 eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
631 std::process::exit(1);
632 }
633 }
634}
635
636pub fn cmd_cheatsheet() {
637 println!(
638 "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
639\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
640\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m
641
642\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
643 ctx_session load \x1b[2m# restore previous session\x1b[0m
644 ctx_overview task=\"...\" \x1b[2m# task-aware file map\x1b[0m
645 ctx_graph action=build \x1b[2m# index project (first time)\x1b[0m
646 ctx_knowledge action=recall \x1b[2m# check stored project facts\x1b[0m
647
648\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
649 ctx_read mode=full \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
650 ctx_read mode=map \x1b[2m# context-only files (~93% saved)\x1b[0m
651 ctx_read mode=diff \x1b[2m# after editing (~98% saved)\x1b[0m
652 ctx_read mode=sigs \x1b[2m# API surface of large files (~95%)\x1b[0m
653 ctx_multi_read \x1b[2m# read multiple files at once\x1b[0m
654 ctx_search \x1b[2m# search with compressed results (~70%)\x1b[0m
655 ctx_shell \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
656
657\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
658 ctx_session finding \"...\" \x1b[2m# record what you discovered\x1b[0m
659 ctx_session decision \"...\" \x1b[2m# record architectural choices\x1b[0m
660 ctx_knowledge action=remember \x1b[2m# store permanent project facts\x1b[0m
661 ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
662 ctx_metrics \x1b[2m# see session statistics\x1b[0m
663
664\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
665 ctx_agent action=register \x1b[2m# announce yourself\x1b[0m
666 ctx_agent action=list \x1b[2m# see other active agents\x1b[0m
667 ctx_agent action=post \x1b[2m# share findings\x1b[0m
668 ctx_agent action=read \x1b[2m# check messages\x1b[0m
669
670\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
671 Will edit? → \x1b[1mfull\x1b[0m (re-reads: 13 tokens) → after edit: \x1b[1mdiff\x1b[0m
672 API only? → \x1b[1msignatures\x1b[0m
673 Deps/exports? → \x1b[1mmap\x1b[0m
674 Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
675 Browsing? → \x1b[1maggressive\x1b[0m (syntax stripped)
676
677\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
678 lean-ctx gain \x1b[2m# visual savings dashboard\x1b[0m
679 lean-ctx gain --live \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
680 lean-ctx dashboard \x1b[2m# web dashboard with charts\x1b[0m
681 lean-ctx wrapped \x1b[2m# weekly savings report\x1b[0m
682 lean-ctx discover \x1b[2m# find uncompressed commands\x1b[0m
683 lean-ctx doctor \x1b[2m# diagnose installation\x1b[0m
684 lean-ctx update \x1b[2m# self-update to latest\x1b[0m
685
686\x1b[2m Full guide: https://leanctx.com/docs/workflow\x1b[0m"
687 );
688}
689
690pub fn cmd_slow_log(args: &[String]) {
691 use crate::core::slow_log;
692
693 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
694 match action {
695 "list" | "ls" | "" => println!("{}", slow_log::list()),
696 "clear" | "purge" => println!("{}", slow_log::clear()),
697 _ => {
698 eprintln!("Usage: lean-ctx slow-log [list|clear]");
699 std::process::exit(1);
700 }
701 }
702}
703
704pub fn cmd_tee(args: &[String]) {
705 let tee_dir = match dirs::home_dir() {
706 Some(h) => h.join(".lean-ctx").join("tee"),
707 None => {
708 eprintln!("Cannot determine home directory");
709 std::process::exit(1);
710 }
711 };
712
713 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
714 match action {
715 "list" | "ls" => {
716 if !tee_dir.exists() {
717 println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
718 return;
719 }
720 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
721 .unwrap_or_else(|e| {
722 eprintln!("Error: {e}");
723 std::process::exit(1);
724 })
725 .filter_map(|e| e.ok())
726 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
727 .collect();
728 entries.sort_by_key(|e| e.file_name());
729
730 if entries.is_empty() {
731 println!("No tee logs found.");
732 return;
733 }
734
735 println!("Tee logs ({}):\n", entries.len());
736 for entry in &entries {
737 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
738 let name = entry.file_name();
739 let size_str = if size > 1024 {
740 format!("{}K", size / 1024)
741 } else {
742 format!("{}B", size)
743 };
744 println!(" {:<60} {}", name.to_string_lossy(), size_str);
745 }
746 println!("\nUse 'lean-ctx tee clear' to delete all logs.");
747 }
748 "clear" | "purge" => {
749 if !tee_dir.exists() {
750 println!("No tee logs to clear.");
751 return;
752 }
753 let mut count = 0u32;
754 if let Ok(entries) = std::fs::read_dir(&tee_dir) {
755 for entry in entries.flatten() {
756 if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
757 && std::fs::remove_file(entry.path()).is_ok()
758 {
759 count += 1;
760 }
761 }
762 }
763 println!("Cleared {count} tee log(s) from {}", tee_dir.display());
764 }
765 "show" => {
766 let filename = args.get(1);
767 if filename.is_none() {
768 eprintln!("Usage: lean-ctx tee show <filename>");
769 std::process::exit(1);
770 }
771 let path = tee_dir.join(filename.unwrap());
772 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
773 Ok(content) => print!("{content}"),
774 Err(e) => {
775 eprintln!("Error reading {}: {e}", path.display());
776 std::process::exit(1);
777 }
778 }
779 }
780 "last" => {
781 if !tee_dir.exists() {
782 println!("No tee logs found.");
783 return;
784 }
785 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
786 .ok()
787 .into_iter()
788 .flat_map(|d| d.filter_map(|e| e.ok()))
789 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
790 .collect();
791 entries.sort_by_key(|e| {
792 e.metadata()
793 .and_then(|m| m.modified())
794 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
795 });
796 match entries.last() {
797 Some(entry) => {
798 let path = entry.path();
799 println!(
800 "--- {} ---\n",
801 path.file_name().unwrap_or_default().to_string_lossy()
802 );
803 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
804 Ok(content) => print!("{content}"),
805 Err(e) => eprintln!("Error: {e}"),
806 }
807 }
808 None => println!("No tee logs found."),
809 }
810 }
811 _ => {
812 eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
813 std::process::exit(1);
814 }
815 }
816}
817
818pub fn cmd_filter(args: &[String]) {
819 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
820 match action {
821 "list" | "ls" => match crate::core::filters::FilterEngine::load() {
822 Some(engine) => {
823 let rules = engine.list_rules();
824 println!("Loaded {} filter rule(s):\n", rules.len());
825 for rule in &rules {
826 println!("{rule}");
827 }
828 }
829 None => {
830 println!("No custom filters found.");
831 println!("Create one: lean-ctx filter init");
832 }
833 },
834 "validate" => {
835 let path = args.get(1);
836 if path.is_none() {
837 eprintln!("Usage: lean-ctx filter validate <file.toml>");
838 std::process::exit(1);
839 }
840 match crate::core::filters::validate_filter_file(path.unwrap()) {
841 Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
842 Err(e) => {
843 eprintln!("Validation failed: {e}");
844 std::process::exit(1);
845 }
846 }
847 }
848 "init" => match crate::core::filters::create_example_filter() {
849 Ok(path) => {
850 println!("Created example filter: {path}");
851 println!("Edit it to add your custom compression rules.");
852 }
853 Err(e) => {
854 eprintln!("{e}");
855 std::process::exit(1);
856 }
857 },
858 _ => {
859 eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
860 std::process::exit(1);
861 }
862 }
863}
864
865pub fn cmd_init(args: &[String]) {
866 let global = args.iter().any(|a| a == "--global" || a == "-g");
867 let dry_run = args.iter().any(|a| a == "--dry-run");
868
869 let agents: Vec<&str> = args
870 .windows(2)
871 .filter(|w| w[0] == "--agent")
872 .map(|w| w[1].as_str())
873 .collect();
874
875 if !agents.is_empty() {
876 for agent_name in &agents {
877 crate::hooks::install_agent_hook(agent_name, global);
878 }
879 if !global {
880 crate::hooks::install_project_rules();
881 }
882 println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
883 return;
884 }
885
886 let shell_name = std::env::var("SHELL").unwrap_or_default();
887 let is_zsh = shell_name.contains("zsh");
888 let is_fish = shell_name.contains("fish");
889 let is_powershell = cfg!(windows) && shell_name.is_empty();
890
891 let binary = std::env::current_exe()
892 .map(|p| p.to_string_lossy().to_string())
893 .unwrap_or_else(|_| "lean-ctx".to_string());
894
895 if dry_run {
896 let rc = if is_powershell {
897 "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
898 } else if is_fish {
899 "~/.config/fish/config.fish".to_string()
900 } else if is_zsh {
901 "~/.zshrc".to_string()
902 } else {
903 "~/.bashrc".to_string()
904 };
905 println!("\nlean-ctx init --dry-run\n");
906 println!(" Would modify: {rc}");
907 println!(" Would backup: {rc}.lean-ctx.bak");
908 println!(" Would alias: git npm pnpm yarn cargo docker docker-compose kubectl");
909 println!(" gh pip pip3 ruff go golangci-lint eslint prettier tsc");
910 println!(" ls find grep curl wget php composer (24 commands + k)");
911 println!(" Would create: ~/.lean-ctx/");
912 println!(" Binary: {binary}");
913 println!("\n Safety: aliases auto-fallback to original command if lean-ctx is removed.");
914 println!("\n Run without --dry-run to apply.");
915 return;
916 }
917
918 if is_powershell {
919 init_powershell(&binary);
920 } else {
921 let bash_binary = to_bash_compatible_path(&binary);
922 if is_fish {
923 init_fish(&bash_binary);
924 } else {
925 init_posix(is_zsh, &bash_binary);
926 }
927 }
928
929 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
930 if let Some(dir) = lean_dir {
931 if !dir.exists() {
932 let _ = std::fs::create_dir_all(&dir);
933 println!("Created {}", dir.display());
934 }
935 }
936
937 let rc = if is_powershell {
938 "$PROFILE"
939 } else if is_fish {
940 "config.fish"
941 } else if is_zsh {
942 ".zshrc"
943 } else {
944 ".bashrc"
945 };
946
947 println!("\nlean-ctx init complete (24 aliases installed)");
948 println!();
949 println!(" Disable temporarily: lean-ctx-off");
950 println!(" Re-enable: lean-ctx-on");
951 println!(" Check status: lean-ctx-status");
952 println!(" Full uninstall: lean-ctx uninstall");
953 println!(" Diagnose issues: lean-ctx doctor");
954 println!(" Preview changes: lean-ctx init --global --dry-run");
955 println!();
956 if is_powershell {
957 println!(" Restart PowerShell or run: . {rc}");
958 } else {
959 println!(" Restart your shell or run: source ~/{rc}");
960 }
961 println!();
962 println!("For AI tool integration: lean-ctx init --agent <tool>");
963 println!(" Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, crush, pi");
964}
965
966fn backup_shell_config(path: &std::path::Path) {
967 if !path.exists() {
968 return;
969 }
970 let bak = path.with_extension("lean-ctx.bak");
971 if std::fs::copy(path, &bak).is_ok() {
972 println!(
973 " Backup: {}",
974 bak.file_name()
975 .map(|n| format!("~/{}", n.to_string_lossy()))
976 .unwrap_or_else(|| bak.display().to_string())
977 );
978 }
979}
980
981fn init_powershell(binary: &str) {
982 let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
983 let profile_path = match profile_dir {
984 Some(dir) => {
985 let _ = std::fs::create_dir_all(&dir);
986 dir.join("Microsoft.PowerShell_profile.ps1")
987 }
988 None => {
989 eprintln!("Could not resolve PowerShell profile directory");
990 return;
991 }
992 };
993
994 let binary_escaped = binary.replace('\\', "\\\\");
995 let functions = format!(
996 r#"
997# lean-ctx shell hook — transparent CLI compression (90+ patterns)
998if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED) {{
999 $LeanCtxBin = "{binary_escaped}"
1000 function _lc {{
1001 if ($env:LEAN_CTX_DISABLED) {{ & $args[0] $args[1..($args.Length)]; return }}
1002 & $LeanCtxBin -c @args
1003 if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
1004 $cmd = $args[0]; $rest = $args[1..($args.Length)]
1005 & $cmd @rest
1006 }}
1007 }}
1008 function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
1009 if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
1010 function git {{ _lc git @args }}
1011 function cargo {{ _lc cargo @args }}
1012 function docker {{ _lc docker @args }}
1013 function kubectl {{ _lc kubectl @args }}
1014 function gh {{ _lc gh @args }}
1015 function pip {{ _lc pip @args }}
1016 function pip3 {{ _lc pip3 @args }}
1017 function ruff {{ _lc ruff @args }}
1018 function go {{ _lc go @args }}
1019 function curl {{ _lc curl @args }}
1020 function wget {{ _lc wget @args }}
1021 foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
1022 $a = Get-Command $c -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
1023 if ($a) {{
1024 Set-Variable -Name "_lc_$c" -Value $a.Source -Scope Script
1025 New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc `$script:_lc_$c @args")) -Force | Out-Null
1026 }}
1027 }}
1028 }}
1029}}
1030"#
1031 );
1032
1033 backup_shell_config(&profile_path);
1034
1035 if let Ok(existing) = std::fs::read_to_string(&profile_path) {
1036 if existing.contains("lean-ctx shell hook") {
1037 let cleaned = remove_lean_ctx_block_ps(&existing);
1038 match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
1039 Ok(()) => {
1040 println!("Updated lean-ctx functions in {}", profile_path.display());
1041 println!(" Binary: {binary}");
1042 return;
1043 }
1044 Err(e) => {
1045 eprintln!("Error updating {}: {e}", profile_path.display());
1046 return;
1047 }
1048 }
1049 }
1050 }
1051
1052 match std::fs::OpenOptions::new()
1053 .append(true)
1054 .create(true)
1055 .open(&profile_path)
1056 {
1057 Ok(mut f) => {
1058 use std::io::Write;
1059 let _ = f.write_all(functions.as_bytes());
1060 println!("Added lean-ctx functions to {}", profile_path.display());
1061 println!(" Binary: {binary}");
1062 }
1063 Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
1064 }
1065}
1066
1067fn remove_lean_ctx_block_ps(content: &str) -> String {
1068 let mut result = String::new();
1069 let mut in_block = false;
1070 let mut brace_depth = 0i32;
1071
1072 for line in content.lines() {
1073 if line.contains("lean-ctx shell hook") {
1074 in_block = true;
1075 continue;
1076 }
1077 if in_block {
1078 brace_depth += line.matches('{').count() as i32;
1079 brace_depth -= line.matches('}').count() as i32;
1080 if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
1081 if line.trim() == "}" {
1082 in_block = false;
1083 brace_depth = 0;
1084 }
1085 continue;
1086 }
1087 continue;
1088 }
1089 result.push_str(line);
1090 result.push('\n');
1091 }
1092 result
1093}
1094
1095fn init_fish(binary: &str) {
1096 let config = dirs::home_dir()
1097 .map(|h| h.join(".config/fish/config.fish"))
1098 .unwrap_or_default();
1099
1100 let aliases = format!(
1101 "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
1102 set -g _lean_ctx_cmds git npm pnpm yarn cargo docker docker-compose kubectl gh pip pip3 ruff go golangci-lint eslint prettier tsc ls find grep curl wget\n\
1103 \n\
1104 function _lc\n\
1105 \tif set -q LEAN_CTX_DISABLED\n\
1106 \t\tcommand $argv\n\
1107 \t\treturn\n\
1108 \tend\n\
1109 \t'{binary}' -c $argv\n\
1110 \tset -l _lc_rc $status\n\
1111 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
1112 \t\tcommand $argv\n\
1113 \telse\n\
1114 \t\treturn $_lc_rc\n\
1115 \tend\n\
1116 end\n\
1117 \n\
1118 function lean-ctx-on\n\
1119 \tfor _lc_cmd in $_lean_ctx_cmds\n\
1120 \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
1121 \tend\n\
1122 \talias k '_lc kubectl'\n\
1123 \tset -gx LEAN_CTX_ENABLED 1\n\
1124 \techo 'lean-ctx: ON'\n\
1125 end\n\
1126 \n\
1127 function lean-ctx-off\n\
1128 \tfor _lc_cmd in $_lean_ctx_cmds\n\
1129 \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
1130 \tend\n\
1131 \tfunctions --erase k 2>/dev/null; true\n\
1132 \tset -e LEAN_CTX_ENABLED\n\
1133 \techo 'lean-ctx: OFF'\n\
1134 end\n\
1135 \n\
1136 function lean-ctx-raw\n\
1137 \tset -lx LEAN_CTX_RAW 1\n\
1138 \tcommand $argv\n\
1139 end\n\
1140 \n\
1141 function lean-ctx-status\n\
1142 \tif set -q LEAN_CTX_DISABLED\n\
1143 \t\techo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
1144 \telse if set -q LEAN_CTX_ENABLED\n\
1145 \t\techo 'lean-ctx: ON'\n\
1146 \telse\n\
1147 \t\techo 'lean-ctx: OFF'\n\
1148 \tend\n\
1149 end\n\
1150 \n\
1151 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\
1152 \tif command -q lean-ctx\n\
1153 \t\tlean-ctx-on\n\
1154 \tend\n\
1155 end\n\
1156 # lean-ctx shell hook — end\n"
1157 );
1158
1159 backup_shell_config(&config);
1160
1161 if let Ok(existing) = std::fs::read_to_string(&config) {
1162 if existing.contains("lean-ctx shell hook") {
1163 let cleaned = remove_lean_ctx_block(&existing);
1164 match std::fs::write(&config, format!("{cleaned}{aliases}")) {
1165 Ok(()) => {
1166 println!("Updated lean-ctx aliases in {}", config.display());
1167 println!(" Binary: {binary}");
1168 return;
1169 }
1170 Err(e) => {
1171 eprintln!("Error updating {}: {e}", config.display());
1172 return;
1173 }
1174 }
1175 }
1176 }
1177
1178 match std::fs::OpenOptions::new()
1179 .append(true)
1180 .create(true)
1181 .open(&config)
1182 {
1183 Ok(mut f) => {
1184 use std::io::Write;
1185 let _ = f.write_all(aliases.as_bytes());
1186 println!("Added lean-ctx aliases to {}", config.display());
1187 println!(" Binary: {binary}");
1188 }
1189 Err(e) => eprintln!("Error writing {}: {e}", config.display()),
1190 }
1191}
1192
1193fn init_posix(is_zsh: bool, binary: &str) {
1194 let rc_file = if is_zsh {
1195 dirs::home_dir()
1196 .map(|h| h.join(".zshrc"))
1197 .unwrap_or_default()
1198 } else {
1199 dirs::home_dir()
1200 .map(|h| h.join(".bashrc"))
1201 .unwrap_or_default()
1202 };
1203
1204 let aliases = format!(
1205 r#"
1206# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1207_lean_ctx_cmds=(git npm pnpm yarn cargo docker docker-compose kubectl gh pip pip3 ruff go golangci-lint eslint prettier tsc ls find grep curl wget php composer)
1208
1209_lc() {{
1210 if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1211 command "$@"
1212 return
1213 fi
1214 '{binary}' -c "$@"
1215 local _lc_rc=$?
1216 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
1217 command "$@"
1218 else
1219 return "$_lc_rc"
1220 fi
1221}}
1222
1223lean-ctx-on() {{
1224 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1225 # shellcheck disable=SC2139
1226 alias "$_lc_cmd"='_lc '"$_lc_cmd"
1227 done
1228 alias k='_lc kubectl'
1229 export LEAN_CTX_ENABLED=1
1230 echo "lean-ctx: ON"
1231}}
1232
1233lean-ctx-off() {{
1234 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1235 unalias "$_lc_cmd" 2>/dev/null || true
1236 done
1237 unalias k 2>/dev/null || true
1238 unset LEAN_CTX_ENABLED
1239 echo "lean-ctx: OFF"
1240}}
1241
1242lean-ctx-raw() {{
1243 LEAN_CTX_RAW=1 command "$@"
1244}}
1245
1246lean-ctx-status() {{
1247 if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
1248 echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
1249 elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1250 echo "lean-ctx: ON"
1251 else
1252 echo "lean-ctx: OFF"
1253 fi
1254}}
1255
1256if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1257 command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
1258fi
1259# lean-ctx shell hook — end
1260"#
1261 );
1262
1263 backup_shell_config(&rc_file);
1264
1265 if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1266 if existing.contains("lean-ctx shell hook") {
1267 let cleaned = remove_lean_ctx_block(&existing);
1268 match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1269 Ok(()) => {
1270 println!("Updated lean-ctx aliases in {}", rc_file.display());
1271 println!(" Binary: {binary}");
1272 return;
1273 }
1274 Err(e) => {
1275 eprintln!("Error updating {}: {e}", rc_file.display());
1276 return;
1277 }
1278 }
1279 }
1280 }
1281
1282 match std::fs::OpenOptions::new()
1283 .append(true)
1284 .create(true)
1285 .open(&rc_file)
1286 {
1287 Ok(mut f) => {
1288 use std::io::Write;
1289 let _ = f.write_all(aliases.as_bytes());
1290 println!("Added lean-ctx aliases to {}", rc_file.display());
1291 println!(" Binary: {binary}");
1292 }
1293 Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1294 }
1295}
1296
1297fn remove_lean_ctx_block(content: &str) -> String {
1298 if content.contains("# lean-ctx shell hook — end") {
1300 return remove_lean_ctx_block_by_marker(content);
1301 }
1302 remove_lean_ctx_block_legacy(content)
1303}
1304
1305fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1306 let mut result = String::new();
1307 let mut in_block = false;
1308
1309 for line in content.lines() {
1310 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1311 in_block = true;
1312 continue;
1313 }
1314 if in_block {
1315 if line.trim() == "# lean-ctx shell hook — end" {
1316 in_block = false;
1317 }
1318 continue;
1319 }
1320 result.push_str(line);
1321 result.push('\n');
1322 }
1323 result
1324}
1325
1326fn remove_lean_ctx_block_legacy(content: &str) -> String {
1327 let mut result = String::new();
1328 let mut in_block = false;
1329
1330 for line in content.lines() {
1331 if line.contains("lean-ctx shell hook") {
1332 in_block = true;
1333 continue;
1334 }
1335 if in_block {
1336 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1337 if line.trim() == "fi" || line.trim() == "end" {
1338 in_block = false;
1339 }
1340 continue;
1341 }
1342 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1343 in_block = false;
1344 result.push_str(line);
1345 result.push('\n');
1346 }
1347 continue;
1348 }
1349 result.push_str(line);
1350 result.push('\n');
1351 }
1352 result
1353}
1354
1355pub fn load_shell_history_pub() -> Vec<String> {
1356 load_shell_history()
1357}
1358
1359fn load_shell_history() -> Vec<String> {
1360 let shell = std::env::var("SHELL").unwrap_or_default();
1361 let home = match dirs::home_dir() {
1362 Some(h) => h,
1363 None => return Vec::new(),
1364 };
1365
1366 let history_file = if shell.contains("zsh") {
1367 home.join(".zsh_history")
1368 } else if shell.contains("fish") {
1369 home.join(".local/share/fish/fish_history")
1370 } else if cfg!(windows) && shell.is_empty() {
1371 home.join("AppData")
1372 .join("Roaming")
1373 .join("Microsoft")
1374 .join("Windows")
1375 .join("PowerShell")
1376 .join("PSReadLine")
1377 .join("ConsoleHost_history.txt")
1378 } else {
1379 home.join(".bash_history")
1380 };
1381
1382 match std::fs::read_to_string(&history_file) {
1383 Ok(content) => content
1384 .lines()
1385 .filter_map(|l| {
1386 let trimmed = l.trim();
1387 if trimmed.starts_with(':') {
1388 trimmed.split(';').nth(1).map(|s| s.to_string())
1389 } else {
1390 Some(trimmed.to_string())
1391 }
1392 })
1393 .filter(|l| !l.is_empty())
1394 .collect(),
1395 Err(_) => Vec::new(),
1396 }
1397}
1398
1399fn print_savings(original: usize, sent: usize) {
1400 let saved = original.saturating_sub(sent);
1401 if original > 0 && saved > 0 {
1402 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1403 println!("[{saved} tok saved ({pct}%)]");
1404 }
1405}
1406
1407pub fn cmd_theme(args: &[String]) {
1408 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1409 let r = theme::rst();
1410 let b = theme::bold();
1411 let d = theme::dim();
1412
1413 match sub {
1414 "list" => {
1415 let cfg = config::Config::load();
1416 let active = cfg.theme.as_str();
1417 println!();
1418 println!(" {b}Available themes:{r}");
1419 println!(" {ln}", ln = "─".repeat(40));
1420 for name in theme::PRESET_NAMES {
1421 let marker = if *name == active { " ◀ active" } else { "" };
1422 let t = theme::from_preset(name).unwrap();
1423 let preview = format!(
1424 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1425 p = t.primary.fg(),
1426 s = t.secondary.fg(),
1427 a = t.accent.fg(),
1428 sc = t.success.fg(),
1429 w = t.warning.fg(),
1430 );
1431 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1432 }
1433 if let Some(path) = theme::theme_file_path() {
1434 if path.exists() {
1435 let custom = theme::load_theme("_custom_");
1436 let preview = format!(
1437 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1438 p = custom.primary.fg(),
1439 s = custom.secondary.fg(),
1440 a = custom.accent.fg(),
1441 sc = custom.success.fg(),
1442 w = custom.warning.fg(),
1443 );
1444 let marker = if active == "custom" {
1445 " ◀ active"
1446 } else {
1447 ""
1448 };
1449 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1450 }
1451 }
1452 println!();
1453 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1454 println!();
1455 }
1456 "set" => {
1457 if args.len() < 2 {
1458 eprintln!("Usage: lean-ctx theme set <name>");
1459 std::process::exit(1);
1460 }
1461 let name = &args[1];
1462 if theme::from_preset(name).is_none() && name != "custom" {
1463 eprintln!(
1464 "Unknown theme '{name}'. Available: {}",
1465 theme::PRESET_NAMES.join(", ")
1466 );
1467 std::process::exit(1);
1468 }
1469 let mut cfg = config::Config::load();
1470 cfg.theme = name.to_string();
1471 match cfg.save() {
1472 Ok(()) => {
1473 let t = theme::load_theme(name);
1474 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1475 let preview = t.gradient_bar(0.75, 30);
1476 println!(" {preview}");
1477 }
1478 Err(e) => eprintln!("Error: {e}"),
1479 }
1480 }
1481 "export" => {
1482 let cfg = config::Config::load();
1483 let t = theme::load_theme(&cfg.theme);
1484 println!("{}", t.to_toml());
1485 }
1486 "import" => {
1487 if args.len() < 2 {
1488 eprintln!("Usage: lean-ctx theme import <path>");
1489 std::process::exit(1);
1490 }
1491 let path = std::path::Path::new(&args[1]);
1492 if !path.exists() {
1493 eprintln!("File not found: {}", args[1]);
1494 std::process::exit(1);
1495 }
1496 match std::fs::read_to_string(path) {
1497 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1498 Ok(imported) => match theme::save_theme(&imported) {
1499 Ok(()) => {
1500 let mut cfg = config::Config::load();
1501 cfg.theme = "custom".to_string();
1502 let _ = cfg.save();
1503 println!(
1504 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1505 sc = imported.success.fg(),
1506 name = imported.name,
1507 );
1508 println!(" Config updated: theme = custom");
1509 }
1510 Err(e) => eprintln!("Error saving theme: {e}"),
1511 },
1512 Err(e) => eprintln!("Invalid theme file: {e}"),
1513 },
1514 Err(e) => eprintln!("Error reading file: {e}"),
1515 }
1516 }
1517 "preview" => {
1518 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1519 let t = match theme::from_preset(name) {
1520 Some(t) => t,
1521 None => {
1522 eprintln!("Unknown theme: {name}");
1523 std::process::exit(1);
1524 }
1525 };
1526 println!();
1527 println!(
1528 " {icon} {title} {d}Theme Preview: {name}{r}",
1529 icon = t.header_icon(),
1530 title = t.brand_title(),
1531 );
1532 println!(" {ln}", ln = t.border_line(50));
1533 println!();
1534 println!(
1535 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1536 sc = t.success.fg(),
1537 sec = t.secondary.fg(),
1538 wrn = t.warning.fg(),
1539 acc = t.accent.fg(),
1540 );
1541 println!(" {d} tokens saved compression commands USD saved{r}");
1542 println!();
1543 println!(
1544 " {b}{txt}Gradient Bar{r} {bar}",
1545 txt = t.text.fg(),
1546 bar = t.gradient_bar(0.85, 30),
1547 );
1548 println!(
1549 " {b}{txt}Sparkline{r} {spark}",
1550 txt = t.text.fg(),
1551 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1552 );
1553 println!();
1554 println!(" {top}", top = t.box_top(50));
1555 println!(
1556 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1557 side = t.box_side(),
1558 side_r = t.box_side(),
1559 txt = t.text.fg(),
1560 );
1561 println!(" {bot}", bot = t.box_bottom(50));
1562 println!();
1563 }
1564 _ => {
1565 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1566 std::process::exit(1);
1567 }
1568 }
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573 use super::*;
1574
1575 #[test]
1576 fn test_remove_lean_ctx_block_posix() {
1577 let input = r#"# existing config
1578export PATH="$HOME/bin:$PATH"
1579
1580# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1581if [ -z "$LEAN_CTX_ACTIVE" ]; then
1582alias git='lean-ctx -c git'
1583alias npm='lean-ctx -c npm'
1584fi
1585
1586# other stuff
1587export EDITOR=vim
1588"#;
1589 let result = remove_lean_ctx_block(input);
1590 assert!(!result.contains("lean-ctx"), "block should be removed");
1591 assert!(result.contains("export PATH"), "other content preserved");
1592 assert!(
1593 result.contains("export EDITOR"),
1594 "trailing content preserved"
1595 );
1596 }
1597
1598 #[test]
1599 fn test_remove_lean_ctx_block_fish() {
1600 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";
1601 let result = remove_lean_ctx_block(input);
1602 assert!(!result.contains("lean-ctx"), "block should be removed");
1603 assert!(result.contains("set -x FOO"), "other content preserved");
1604 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1605 }
1606
1607 #[test]
1608 fn test_remove_lean_ctx_block_ps() {
1609 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";
1610 let result = remove_lean_ctx_block_ps(input);
1611 assert!(
1612 !result.contains("lean-ctx shell hook"),
1613 "block should be removed"
1614 );
1615 assert!(result.contains("$env:FOO"), "other content preserved");
1616 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1617 }
1618
1619 #[test]
1620 fn test_remove_lean_ctx_block_ps_nested() {
1621 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";
1622 let result = remove_lean_ctx_block_ps(input);
1623 assert!(
1624 !result.contains("lean-ctx shell hook"),
1625 "block should be removed"
1626 );
1627 assert!(!result.contains("_lc"), "function should be removed");
1628 assert!(result.contains("$env:FOO"), "other content preserved");
1629 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1630 }
1631
1632 #[test]
1633 fn test_remove_block_no_lean_ctx() {
1634 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1635 let result = remove_lean_ctx_block(input);
1636 assert!(result.contains("export PATH"), "content unchanged");
1637 }
1638
1639 #[test]
1640 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1641 let input = r#"# existing config
1642export PATH="$HOME/bin:$PATH"
1643
1644# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1645_lean_ctx_cmds=(git npm pnpm)
1646
1647lean-ctx-on() {
1648 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1649 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1650 done
1651 export LEAN_CTX_ENABLED=1
1652 echo "lean-ctx: ON"
1653}
1654
1655lean-ctx-off() {
1656 unset LEAN_CTX_ENABLED
1657 echo "lean-ctx: OFF"
1658}
1659
1660if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1661 lean-ctx-on
1662fi
1663# lean-ctx shell hook — end
1664
1665# other stuff
1666export EDITOR=vim
1667"#;
1668 let result = remove_lean_ctx_block(input);
1669 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1670 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1671 assert!(result.contains("export PATH"), "other content preserved");
1672 assert!(
1673 result.contains("export EDITOR"),
1674 "trailing content preserved"
1675 );
1676 }
1677}