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