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