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