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