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