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 "excluded_commands" => {
661 cfg.excluded_commands = val
662 .split(',')
663 .map(|s| s.trim().to_string())
664 .filter(|s| !s.is_empty())
665 .collect();
666 }
667 "rules_scope" => match val.as_str() {
668 "global" | "project" | "both" => {
669 cfg.rules_scope = Some(val.to_string());
670 }
671 _ => {
672 eprintln!("Valid rules_scope values: global, project, both");
673 std::process::exit(1);
674 }
675 },
676 _ => {
677 eprintln!("Unknown config key: {key}");
678 std::process::exit(1);
679 }
680 }
681 match cfg.save() {
682 Ok(()) => println!("Updated {key} = {val}"),
683 Err(e) => eprintln!("Error saving config: {e}"),
684 }
685 }
686 _ => {
687 eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
688 std::process::exit(1);
689 }
690 }
691}
692
693pub fn cmd_cheatsheet() {
694 let ver = env!("CARGO_PKG_VERSION");
695 let ver_pad = format!("v{ver}");
696 let header = format!(
697 "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
698\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
699\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m");
700 println!(
701 "{header}
702
703\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
704 ctx_session load \x1b[2m# restore previous session\x1b[0m
705 ctx_overview task=\"...\" \x1b[2m# task-aware file map\x1b[0m
706 ctx_graph action=build \x1b[2m# index project (first time)\x1b[0m
707 ctx_knowledge action=recall \x1b[2m# check stored project facts\x1b[0m
708
709\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
710 ctx_read mode=full \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
711 ctx_read mode=map \x1b[2m# context-only files (~93% saved)\x1b[0m
712 ctx_read mode=diff \x1b[2m# after editing (~98% saved)\x1b[0m
713 ctx_read mode=sigs \x1b[2m# API surface of large files (~95%)\x1b[0m
714 ctx_multi_read \x1b[2m# read multiple files at once\x1b[0m
715 ctx_search \x1b[2m# search with compressed results (~70%)\x1b[0m
716 ctx_shell \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
717
718\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
719 ctx_session finding \"...\" \x1b[2m# record what you discovered\x1b[0m
720 ctx_session decision \"...\" \x1b[2m# record architectural choices\x1b[0m
721 ctx_knowledge action=remember \x1b[2m# store permanent project facts\x1b[0m
722 ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
723 ctx_metrics \x1b[2m# see session statistics\x1b[0m
724
725\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
726 ctx_agent action=register \x1b[2m# announce yourself\x1b[0m
727 ctx_agent action=list \x1b[2m# see other active agents\x1b[0m
728 ctx_agent action=post \x1b[2m# share findings\x1b[0m
729 ctx_agent action=read \x1b[2m# check messages\x1b[0m
730
731\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
732 Will edit? → \x1b[1mfull\x1b[0m (re-reads: 13 tokens) → after edit: \x1b[1mdiff\x1b[0m
733 API only? → \x1b[1msignatures\x1b[0m
734 Deps/exports? → \x1b[1mmap\x1b[0m
735 Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
736 Browsing? → \x1b[1maggressive\x1b[0m (syntax stripped)
737
738\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
739 lean-ctx gain \x1b[2m# visual savings dashboard\x1b[0m
740 lean-ctx gain --live \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
741 lean-ctx dashboard \x1b[2m# web dashboard with charts\x1b[0m
742 lean-ctx wrapped \x1b[2m# weekly savings report\x1b[0m
743 lean-ctx discover \x1b[2m# find uncompressed commands\x1b[0m
744 lean-ctx doctor \x1b[2m# diagnose installation\x1b[0m
745 lean-ctx update \x1b[2m# self-update to latest\x1b[0m
746
747\x1b[2m Full guide: https://leanctx.com/docs/workflow\x1b[0m"
748 );
749}
750
751pub fn cmd_terse(args: &[String]) {
752 use crate::core::config::{Config, TerseAgent};
753
754 let action = args.first().map(|s| s.as_str());
755 match action {
756 Some("off" | "lite" | "full" | "ultra") => {
757 let level = action.unwrap();
758 let mut cfg = Config::load();
759 cfg.terse_agent = match level {
760 "lite" => TerseAgent::Lite,
761 "full" => TerseAgent::Full,
762 "ultra" => TerseAgent::Ultra,
763 _ => TerseAgent::Off,
764 };
765 if let Err(e) = cfg.save() {
766 eprintln!("Error saving config: {e}");
767 std::process::exit(1);
768 }
769 let desc = match level {
770 "lite" => "concise responses, bullet points over paragraphs",
771 "full" => "maximum density, diff-only code, 1-sentence explanations",
772 "ultra" => "expert pair-programmer mode, minimal narration",
773 _ => "normal verbose output",
774 };
775 println!("Terse agent mode: {level} ({desc})");
776 println!("Restart your agent/IDE for changes to take effect.");
777 }
778 _ => {
779 let cfg = Config::load();
780 let effective = TerseAgent::effective(&cfg.terse_agent);
781 let name = match &effective {
782 TerseAgent::Off => "off",
783 TerseAgent::Lite => "lite",
784 TerseAgent::Full => "full",
785 TerseAgent::Ultra => "ultra",
786 };
787 println!("Terse agent mode: {name}");
788 println!();
789 println!("Usage: lean-ctx terse <off|lite|full|ultra>");
790 println!(" off — Normal verbose output (default)");
791 println!(" lite — Concise: bullet points, skip narration");
792 println!(" full — Dense: diff-only, 1-sentence max");
793 println!(" ultra — Expert: minimal narration, code speaks");
794 println!();
795 println!("Override per session: LEAN_CTX_TERSE_AGENT=full");
796 println!("Override per project: terse_agent = \"full\" in .lean-ctx.toml");
797 }
798 }
799}
800
801pub fn cmd_slow_log(args: &[String]) {
802 use crate::core::slow_log;
803
804 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
805 match action {
806 "list" | "ls" | "" => println!("{}", slow_log::list()),
807 "clear" | "purge" => println!("{}", slow_log::clear()),
808 _ => {
809 eprintln!("Usage: lean-ctx slow-log [list|clear]");
810 std::process::exit(1);
811 }
812 }
813}
814
815pub fn cmd_tee(args: &[String]) {
816 let tee_dir = match dirs::home_dir() {
817 Some(h) => h.join(".lean-ctx").join("tee"),
818 None => {
819 eprintln!("Cannot determine home directory");
820 std::process::exit(1);
821 }
822 };
823
824 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
825 match action {
826 "list" | "ls" => {
827 if !tee_dir.exists() {
828 println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
829 return;
830 }
831 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
832 .unwrap_or_else(|e| {
833 eprintln!("Error: {e}");
834 std::process::exit(1);
835 })
836 .filter_map(|e| e.ok())
837 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
838 .collect();
839 entries.sort_by_key(|e| e.file_name());
840
841 if entries.is_empty() {
842 println!("No tee logs found.");
843 return;
844 }
845
846 println!("Tee logs ({}):\n", entries.len());
847 for entry in &entries {
848 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
849 let name = entry.file_name();
850 let size_str = if size > 1024 {
851 format!("{}K", size / 1024)
852 } else {
853 format!("{}B", size)
854 };
855 println!(" {:<60} {}", name.to_string_lossy(), size_str);
856 }
857 println!("\nUse 'lean-ctx tee clear' to delete all logs.");
858 }
859 "clear" | "purge" => {
860 if !tee_dir.exists() {
861 println!("No tee logs to clear.");
862 return;
863 }
864 let mut count = 0u32;
865 if let Ok(entries) = std::fs::read_dir(&tee_dir) {
866 for entry in entries.flatten() {
867 if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
868 && std::fs::remove_file(entry.path()).is_ok()
869 {
870 count += 1;
871 }
872 }
873 }
874 println!("Cleared {count} tee log(s) from {}", tee_dir.display());
875 }
876 "show" => {
877 let filename = args.get(1);
878 if filename.is_none() {
879 eprintln!("Usage: lean-ctx tee show <filename>");
880 std::process::exit(1);
881 }
882 let path = tee_dir.join(filename.unwrap());
883 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
884 Ok(content) => print!("{content}"),
885 Err(e) => {
886 eprintln!("Error reading {}: {e}", path.display());
887 std::process::exit(1);
888 }
889 }
890 }
891 "last" => {
892 if !tee_dir.exists() {
893 println!("No tee logs found.");
894 return;
895 }
896 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
897 .ok()
898 .into_iter()
899 .flat_map(|d| d.filter_map(|e| e.ok()))
900 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
901 .collect();
902 entries.sort_by_key(|e| {
903 e.metadata()
904 .and_then(|m| m.modified())
905 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
906 });
907 match entries.last() {
908 Some(entry) => {
909 let path = entry.path();
910 println!(
911 "--- {} ---\n",
912 path.file_name().unwrap_or_default().to_string_lossy()
913 );
914 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
915 Ok(content) => print!("{content}"),
916 Err(e) => eprintln!("Error: {e}"),
917 }
918 }
919 None => println!("No tee logs found."),
920 }
921 }
922 _ => {
923 eprintln!("Usage: lean-ctx tee [list|clear|show <file>|last]");
924 std::process::exit(1);
925 }
926 }
927}
928
929pub fn cmd_filter(args: &[String]) {
930 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
931 match action {
932 "list" | "ls" => match crate::core::filters::FilterEngine::load() {
933 Some(engine) => {
934 let rules = engine.list_rules();
935 println!("Loaded {} filter rule(s):\n", rules.len());
936 for rule in &rules {
937 println!("{rule}");
938 }
939 }
940 None => {
941 println!("No custom filters found.");
942 println!("Create one: lean-ctx filter init");
943 }
944 },
945 "validate" => {
946 let path = args.get(1);
947 if path.is_none() {
948 eprintln!("Usage: lean-ctx filter validate <file.toml>");
949 std::process::exit(1);
950 }
951 match crate::core::filters::validate_filter_file(path.unwrap()) {
952 Ok(count) => println!("Valid: {count} rule(s) parsed successfully."),
953 Err(e) => {
954 eprintln!("Validation failed: {e}");
955 std::process::exit(1);
956 }
957 }
958 }
959 "init" => match crate::core::filters::create_example_filter() {
960 Ok(path) => {
961 println!("Created example filter: {path}");
962 println!("Edit it to add your custom compression rules.");
963 }
964 Err(e) => {
965 eprintln!("{e}");
966 std::process::exit(1);
967 }
968 },
969 _ => {
970 eprintln!("Usage: lean-ctx filter [list|validate <file>|init]");
971 std::process::exit(1);
972 }
973 }
974}
975
976fn quiet_enabled() -> bool {
977 matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
978}
979
980macro_rules! qprintln {
981 ($($t:tt)*) => {
982 if !quiet_enabled() {
983 println!($($t)*);
984 }
985 };
986}
987
988pub fn cmd_init(args: &[String]) {
989 let global = args.iter().any(|a| a == "--global" || a == "-g");
990 let dry_run = args.iter().any(|a| a == "--dry-run");
991
992 let agents: Vec<&str> = args
993 .windows(2)
994 .filter(|w| w[0] == "--agent")
995 .map(|w| w[1].as_str())
996 .collect();
997
998 if !agents.is_empty() {
999 for agent_name in &agents {
1000 crate::hooks::install_agent_hook(agent_name, global);
1001 if let Err(e) = crate::setup::configure_agent_mcp(agent_name) {
1002 eprintln!("MCP config for '{agent_name}' not updated: {e}");
1003 }
1004 }
1005 if !global {
1006 crate::hooks::install_project_rules();
1007 }
1008 qprintln!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
1009 return;
1010 }
1011
1012 let eval_shell = args
1013 .iter()
1014 .find(|a| matches!(a.as_str(), "bash" | "zsh" | "fish" | "powershell" | "pwsh"));
1015 if let Some(shell) = eval_shell {
1016 if !global {
1017 shell_init::print_hook_stdout(shell);
1018 return;
1019 }
1020 }
1021
1022 let shell_name = std::env::var("SHELL").unwrap_or_default();
1023 let is_zsh = shell_name.contains("zsh");
1024 let is_fish = shell_name.contains("fish");
1025 let is_powershell = cfg!(windows) && shell_name.is_empty();
1026
1027 let binary = crate::core::portable_binary::resolve_portable_binary();
1028
1029 if dry_run {
1030 let rc = if is_powershell {
1031 "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string()
1032 } else if is_fish {
1033 "~/.config/fish/config.fish".to_string()
1034 } else if is_zsh {
1035 "~/.zshrc".to_string()
1036 } else {
1037 "~/.bashrc".to_string()
1038 };
1039 qprintln!("\nlean-ctx init --dry-run\n");
1040 qprintln!(" Would modify: {rc}");
1041 qprintln!(" Would backup: {rc}.lean-ctx.bak");
1042 qprintln!(" Would alias: git npm pnpm yarn cargo docker docker-compose kubectl");
1043 qprintln!(" gh pip pip3 ruff go golangci-lint eslint prettier tsc");
1044 qprintln!(" curl wget php composer (24 commands + k)");
1045 qprintln!(" Would create: ~/.lean-ctx/");
1046 qprintln!(" Binary: {binary}");
1047 qprintln!("\n Safety: aliases auto-fallback to original command if lean-ctx is removed.");
1048 qprintln!("\n Run without --dry-run to apply.");
1049 return;
1050 }
1051
1052 if is_powershell {
1053 init_powershell(&binary);
1054 } else {
1055 let bash_binary = to_bash_compatible_path(&binary);
1056 if is_fish {
1057 init_fish(&bash_binary);
1058 } else {
1059 init_posix(is_zsh, &bash_binary);
1060 }
1061 }
1062
1063 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
1064 if let Some(dir) = lean_dir {
1065 if !dir.exists() {
1066 let _ = std::fs::create_dir_all(&dir);
1067 qprintln!("Created {}", dir.display());
1068 }
1069 }
1070
1071 let rc = if is_powershell {
1072 "$PROFILE"
1073 } else if is_fish {
1074 "config.fish"
1075 } else if is_zsh {
1076 ".zshrc"
1077 } else {
1078 ".bashrc"
1079 };
1080
1081 qprintln!("\nlean-ctx init complete (24 aliases installed)");
1082 qprintln!();
1083 qprintln!(" Disable temporarily: lean-ctx-off");
1084 qprintln!(" Re-enable: lean-ctx-on");
1085 qprintln!(" Check status: lean-ctx-status");
1086 qprintln!(" Full uninstall: lean-ctx uninstall");
1087 qprintln!(" Diagnose issues: lean-ctx doctor");
1088 qprintln!(" Preview changes: lean-ctx init --global --dry-run");
1089 qprintln!();
1090 if is_powershell {
1091 qprintln!(" Restart PowerShell or run: . {rc}");
1092 } else {
1093 qprintln!(" Restart your shell or run: source ~/{rc}");
1094 }
1095 qprintln!();
1096 qprintln!("For AI tool integration: lean-ctx init --agent <tool>");
1097 qprintln!(" Supported: aider, amazonq, amp, antigravity, claude, cline, codex, copilot,");
1098 qprintln!(" crush, cursor, emacs, gemini, hermes, jetbrains, kiro, neovim, opencode,");
1099 qprintln!(" pi, qwen, roo, sublime, trae, verdent, windsurf");
1100}
1101
1102pub fn cmd_init_quiet(args: &[String]) {
1103 std::env::set_var("LEAN_CTX_QUIET", "1");
1104 cmd_init(args);
1105 std::env::remove_var("LEAN_CTX_QUIET");
1106}
1107
1108pub fn load_shell_history_pub() -> Vec<String> {
1109 load_shell_history()
1110}
1111
1112fn load_shell_history() -> Vec<String> {
1113 let shell = std::env::var("SHELL").unwrap_or_default();
1114 let home = match dirs::home_dir() {
1115 Some(h) => h,
1116 None => return Vec::new(),
1117 };
1118
1119 let history_file = if shell.contains("zsh") {
1120 home.join(".zsh_history")
1121 } else if shell.contains("fish") {
1122 home.join(".local/share/fish/fish_history")
1123 } else if cfg!(windows) && shell.is_empty() {
1124 home.join("AppData")
1125 .join("Roaming")
1126 .join("Microsoft")
1127 .join("Windows")
1128 .join("PowerShell")
1129 .join("PSReadLine")
1130 .join("ConsoleHost_history.txt")
1131 } else {
1132 home.join(".bash_history")
1133 };
1134
1135 match std::fs::read_to_string(&history_file) {
1136 Ok(content) => content
1137 .lines()
1138 .filter_map(|l| {
1139 let trimmed = l.trim();
1140 if trimmed.starts_with(':') {
1141 trimmed.split(';').nth(1).map(|s| s.to_string())
1142 } else {
1143 Some(trimmed.to_string())
1144 }
1145 })
1146 .filter(|l| !l.is_empty())
1147 .collect(),
1148 Err(_) => Vec::new(),
1149 }
1150}
1151
1152fn print_savings(original: usize, sent: usize) {
1153 let saved = original.saturating_sub(sent);
1154 if original > 0 && saved > 0 {
1155 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1156 println!("[{saved} tok saved ({pct}%)]");
1157 }
1158}
1159
1160pub fn cmd_theme(args: &[String]) {
1161 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1162 let r = theme::rst();
1163 let b = theme::bold();
1164 let d = theme::dim();
1165
1166 match sub {
1167 "list" => {
1168 let cfg = config::Config::load();
1169 let active = cfg.theme.as_str();
1170 println!();
1171 println!(" {b}Available themes:{r}");
1172 println!(" {ln}", ln = "─".repeat(40));
1173 for name in theme::PRESET_NAMES {
1174 let marker = if *name == active { " ◀ active" } else { "" };
1175 let t = theme::from_preset(name).unwrap();
1176 let preview = format!(
1177 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1178 p = t.primary.fg(),
1179 s = t.secondary.fg(),
1180 a = t.accent.fg(),
1181 sc = t.success.fg(),
1182 w = t.warning.fg(),
1183 );
1184 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1185 }
1186 if let Some(path) = theme::theme_file_path() {
1187 if path.exists() {
1188 let custom = theme::load_theme("_custom_");
1189 let preview = format!(
1190 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1191 p = custom.primary.fg(),
1192 s = custom.secondary.fg(),
1193 a = custom.accent.fg(),
1194 sc = custom.success.fg(),
1195 w = custom.warning.fg(),
1196 );
1197 let marker = if active == "custom" {
1198 " ◀ active"
1199 } else {
1200 ""
1201 };
1202 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1203 }
1204 }
1205 println!();
1206 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1207 println!();
1208 }
1209 "set" => {
1210 if args.len() < 2 {
1211 eprintln!("Usage: lean-ctx theme set <name>");
1212 std::process::exit(1);
1213 }
1214 let name = &args[1];
1215 if theme::from_preset(name).is_none() && name != "custom" {
1216 eprintln!(
1217 "Unknown theme '{name}'. Available: {}",
1218 theme::PRESET_NAMES.join(", ")
1219 );
1220 std::process::exit(1);
1221 }
1222 let mut cfg = config::Config::load();
1223 cfg.theme = name.to_string();
1224 match cfg.save() {
1225 Ok(()) => {
1226 let t = theme::load_theme(name);
1227 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1228 let preview = t.gradient_bar(0.75, 30);
1229 println!(" {preview}");
1230 }
1231 Err(e) => eprintln!("Error: {e}"),
1232 }
1233 }
1234 "export" => {
1235 let cfg = config::Config::load();
1236 let t = theme::load_theme(&cfg.theme);
1237 println!("{}", t.to_toml());
1238 }
1239 "import" => {
1240 if args.len() < 2 {
1241 eprintln!("Usage: lean-ctx theme import <path>");
1242 std::process::exit(1);
1243 }
1244 let path = std::path::Path::new(&args[1]);
1245 if !path.exists() {
1246 eprintln!("File not found: {}", args[1]);
1247 std::process::exit(1);
1248 }
1249 match std::fs::read_to_string(path) {
1250 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1251 Ok(imported) => match theme::save_theme(&imported) {
1252 Ok(()) => {
1253 let mut cfg = config::Config::load();
1254 cfg.theme = "custom".to_string();
1255 let _ = cfg.save();
1256 println!(
1257 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1258 sc = imported.success.fg(),
1259 name = imported.name,
1260 );
1261 println!(" Config updated: theme = custom");
1262 }
1263 Err(e) => eprintln!("Error saving theme: {e}"),
1264 },
1265 Err(e) => eprintln!("Invalid theme file: {e}"),
1266 },
1267 Err(e) => eprintln!("Error reading file: {e}"),
1268 }
1269 }
1270 "preview" => {
1271 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1272 let t = match theme::from_preset(name) {
1273 Some(t) => t,
1274 None => {
1275 eprintln!("Unknown theme: {name}");
1276 std::process::exit(1);
1277 }
1278 };
1279 println!();
1280 println!(
1281 " {icon} {title} {d}Theme Preview: {name}{r}",
1282 icon = t.header_icon(),
1283 title = t.brand_title(),
1284 );
1285 println!(" {ln}", ln = t.border_line(50));
1286 println!();
1287 println!(
1288 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1289 sc = t.success.fg(),
1290 sec = t.secondary.fg(),
1291 wrn = t.warning.fg(),
1292 acc = t.accent.fg(),
1293 );
1294 println!(" {d} tokens saved compression commands USD saved{r}");
1295 println!();
1296 println!(
1297 " {b}{txt}Gradient Bar{r} {bar}",
1298 txt = t.text.fg(),
1299 bar = t.gradient_bar(0.85, 30),
1300 );
1301 println!(
1302 " {b}{txt}Sparkline{r} {spark}",
1303 txt = t.text.fg(),
1304 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1305 );
1306 println!();
1307 println!(" {top}", top = t.box_top(50));
1308 println!(
1309 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1310 side = t.box_side(),
1311 side_r = t.box_side(),
1312 txt = t.text.fg(),
1313 );
1314 println!(" {bot}", bot = t.box_bottom(50));
1315 println!();
1316 }
1317 _ => {
1318 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1319 std::process::exit(1);
1320 }
1321 }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::*;
1327 use tempfile;
1328
1329 #[test]
1330 fn test_remove_lean_ctx_block_posix() {
1331 let input = r#"# existing config
1332export PATH="$HOME/bin:$PATH"
1333
1334# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1335if [ -z "$LEAN_CTX_ACTIVE" ]; then
1336alias git='lean-ctx -c git'
1337alias npm='lean-ctx -c npm'
1338fi
1339
1340# other stuff
1341export EDITOR=vim
1342"#;
1343 let result = remove_lean_ctx_block(input);
1344 assert!(!result.contains("lean-ctx"), "block should be removed");
1345 assert!(result.contains("export PATH"), "other content preserved");
1346 assert!(
1347 result.contains("export EDITOR"),
1348 "trailing content preserved"
1349 );
1350 }
1351
1352 #[test]
1353 fn test_remove_lean_ctx_block_fish() {
1354 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";
1355 let result = remove_lean_ctx_block(input);
1356 assert!(!result.contains("lean-ctx"), "block should be removed");
1357 assert!(result.contains("set -x FOO"), "other content preserved");
1358 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1359 }
1360
1361 #[test]
1362 fn test_remove_lean_ctx_block_ps() {
1363 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";
1364 let result = remove_lean_ctx_block_ps(input);
1365 assert!(
1366 !result.contains("lean-ctx shell hook"),
1367 "block should be removed"
1368 );
1369 assert!(result.contains("$env:FOO"), "other content preserved");
1370 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1371 }
1372
1373 #[test]
1374 fn test_remove_lean_ctx_block_ps_nested() {
1375 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";
1376 let result = remove_lean_ctx_block_ps(input);
1377 assert!(
1378 !result.contains("lean-ctx shell hook"),
1379 "block should be removed"
1380 );
1381 assert!(!result.contains("_lc"), "function should be removed");
1382 assert!(result.contains("$env:FOO"), "other content preserved");
1383 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1384 }
1385
1386 #[test]
1387 fn test_remove_block_no_lean_ctx() {
1388 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1389 let result = remove_lean_ctx_block(input);
1390 assert!(result.contains("export PATH"), "content unchanged");
1391 }
1392
1393 #[test]
1394 fn test_bash_hook_contains_pipe_guard() {
1395 let binary = "/usr/local/bin/lean-ctx";
1396 let hook = format!(
1397 r#"_lc() {{
1398 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
1399 command "$@"
1400 return
1401 fi
1402 '{binary}' -t "$@"
1403}}"#
1404 );
1405 assert!(
1406 hook.contains("! -t 1"),
1407 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1408 );
1409 assert!(
1410 hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
1411 "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_lc_uses_track_mode_by_default() {
1417 let binary = "/usr/local/bin/lean-ctx";
1418 let alias_list = crate::rewrite_registry::shell_alias_list();
1419 let aliases = format!(
1420 r#"_lc() {{
1421 '{binary}' -t "$@"
1422}}
1423_lc_compress() {{
1424 '{binary}' -c "$@"
1425}}"#
1426 );
1427 assert!(
1428 aliases.contains("-t \"$@\""),
1429 "_lc must use -t (track mode) by default"
1430 );
1431 assert!(
1432 aliases.contains("-c \"$@\""),
1433 "_lc_compress must use -c (compress mode)"
1434 );
1435 let _ = alias_list;
1436 }
1437
1438 #[test]
1439 fn test_posix_shell_has_lean_ctx_mode() {
1440 let alias_list = crate::rewrite_registry::shell_alias_list();
1441 let aliases = r#"
1442lean-ctx-mode() {{
1443 case "${{1:-}}" in
1444 compress) echo compress ;;
1445 track) echo track ;;
1446 off) echo off ;;
1447 esac
1448}}
1449"#
1450 .to_string();
1451 assert!(
1452 aliases.contains("lean-ctx-mode()"),
1453 "lean-ctx-mode function must exist"
1454 );
1455 assert!(
1456 aliases.contains("compress"),
1457 "compress mode must be available"
1458 );
1459 assert!(aliases.contains("track"), "track mode must be available");
1460 let _ = alias_list;
1461 }
1462
1463 #[test]
1464 fn test_fish_hook_contains_pipe_guard() {
1465 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";
1466 assert!(
1467 hook.contains("isatty stdout"),
1468 "fish hook must contain pipe guard (isatty stdout)"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_powershell_hook_contains_pipe_guard() {
1474 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1475 assert!(
1476 hook.contains("IsOutputRedirected"),
1477 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1478 );
1479 }
1480
1481 #[test]
1482 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1483 let input = r#"# existing config
1484export PATH="$HOME/bin:$PATH"
1485
1486# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1487_lean_ctx_cmds=(git npm pnpm)
1488
1489lean-ctx-on() {
1490 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1491 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1492 done
1493 export LEAN_CTX_ENABLED=1
1494 [ -t 1 ] && echo "lean-ctx: ON"
1495}
1496
1497lean-ctx-off() {
1498 unset LEAN_CTX_ENABLED
1499 [ -t 1 ] && echo "lean-ctx: OFF"
1500}
1501
1502if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1503 lean-ctx-on
1504fi
1505# lean-ctx shell hook — end
1506
1507# other stuff
1508export EDITOR=vim
1509"#;
1510 let result = remove_lean_ctx_block(input);
1511 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1512 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1513 assert!(result.contains("export PATH"), "other content preserved");
1514 assert!(
1515 result.contains("export EDITOR"),
1516 "trailing content preserved"
1517 );
1518 }
1519
1520 #[test]
1521 fn env_sh_for_containers_includes_self_heal() {
1522 let _g = crate::core::data_dir::test_env_lock();
1523 let tmp = tempfile::tempdir().expect("tempdir");
1524 let data_dir = tmp.path().join("data");
1525 std::fs::create_dir_all(&data_dir).expect("mkdir data");
1526 std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
1527
1528 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1529 let env_sh = data_dir.join("env.sh");
1530 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1531 assert!(content.contains("lean-ctx docker self-heal"));
1532 assert!(content.contains("claude mcp list"));
1533 assert!(content.contains("lean-ctx init --agent claude"));
1534
1535 std::env::remove_var("LEAN_CTX_DATA_DIR");
1536 }
1537}