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
736 let agents: Vec<&str> = args
737 .windows(2)
738 .filter(|w| w[0] == "--agent")
739 .map(|w| w[1].as_str())
740 .collect();
741
742 if !agents.is_empty() {
743 for agent_name in &agents {
744 crate::hooks::install_agent_hook(agent_name, global);
745 }
746 println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
747 return;
748 }
749
750 let shell_name = std::env::var("SHELL").unwrap_or_default();
751 let is_zsh = shell_name.contains("zsh");
752 let is_fish = shell_name.contains("fish");
753 let is_powershell = cfg!(windows) && shell_name.is_empty();
754
755 let binary = std::env::current_exe()
756 .map(|p| p.to_string_lossy().to_string())
757 .unwrap_or_else(|_| "lean-ctx".to_string());
758
759 if is_powershell {
760 init_powershell(&binary);
761 } else {
762 let bash_binary = to_bash_compatible_path(&binary);
763 if is_fish {
764 init_fish(&bash_binary);
765 } else {
766 init_posix(is_zsh, &bash_binary);
767 }
768 }
769
770 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
771 if let Some(dir) = lean_dir {
772 if !dir.exists() {
773 let _ = std::fs::create_dir_all(&dir);
774 println!("Created {}", dir.display());
775 }
776 }
777
778 if global && !is_powershell {
779 let rc = if is_fish {
780 "config.fish"
781 } else if is_zsh {
782 ".zshrc"
783 } else {
784 ".bashrc"
785 };
786 println!("\nRestart your shell or run: source ~/{rc}");
787 } else if global && is_powershell {
788 println!("\nRestart PowerShell or run: . $PROFILE");
789 }
790
791 println!("\nlean-ctx init complete. (23 aliases installed)");
792 println!("Binary: {binary}");
793 println!("\nFor AI tool integration, use: lean-ctx init --agent <tool>");
794 println!(" Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, pi");
795 println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
796 println!("Run 'lean-ctx discover' to find missed savings in your shell history.");
797}
798
799fn init_powershell(binary: &str) {
800 let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
801 let profile_path = match profile_dir {
802 Some(dir) => {
803 let _ = std::fs::create_dir_all(&dir);
804 dir.join("Microsoft.PowerShell_profile.ps1")
805 }
806 None => {
807 eprintln!("Could not resolve PowerShell profile directory");
808 return;
809 }
810 };
811
812 let binary_escaped = binary.replace('\\', "\\\\");
813 let functions = format!(
814 r#"
815# lean-ctx shell hook — transparent CLI compression (90+ patterns)
816if (-not $env:LEAN_CTX_ACTIVE) {{
817 $LeanCtxBin = "{binary_escaped}"
818 function git {{ & $LeanCtxBin -c "git $($args -join ' ')" }}
819 function npm {{ & $LeanCtxBin -c "npm.cmd $($args -join ' ')" }}
820 function pnpm {{ & $LeanCtxBin -c "pnpm.cmd $($args -join ' ')" }}
821 function yarn {{ & $LeanCtxBin -c "yarn.cmd $($args -join ' ')" }}
822 function cargo {{ & $LeanCtxBin -c "cargo $($args -join ' ')" }}
823 function docker {{ & $LeanCtxBin -c "docker $($args -join ' ')" }}
824 function kubectl {{ & $LeanCtxBin -c "kubectl $($args -join ' ')" }}
825 function gh {{ & $LeanCtxBin -c "gh $($args -join ' ')" }}
826 function pip {{ & $LeanCtxBin -c "pip $($args -join ' ')" }}
827 function pip3 {{ & $LeanCtxBin -c "pip3 $($args -join ' ')" }}
828 function ruff {{ & $LeanCtxBin -c "ruff $($args -join ' ')" }}
829 function go {{ & $LeanCtxBin -c "go $($args -join ' ')" }}
830 function eslint {{ & $LeanCtxBin -c "eslint.cmd $($args -join ' ')" }}
831 function prettier {{ & $LeanCtxBin -c "prettier.cmd $($args -join ' ')" }}
832 function tsc {{ & $LeanCtxBin -c "tsc.cmd $($args -join ' ')" }}
833 function curl {{ & $LeanCtxBin -c "curl $($args -join ' ')" }}
834 function wget {{ & $LeanCtxBin -c "wget $($args -join ' ')" }}
835}}
836"#
837 );
838
839 if let Ok(existing) = std::fs::read_to_string(&profile_path) {
840 if existing.contains("lean-ctx shell hook") {
841 let cleaned = remove_lean_ctx_block_ps(&existing);
842 match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
843 Ok(()) => {
844 println!("Updated lean-ctx functions in {}", profile_path.display());
845 println!(" Binary: {binary}");
846 return;
847 }
848 Err(e) => {
849 eprintln!("Error updating {}: {e}", profile_path.display());
850 return;
851 }
852 }
853 }
854 }
855
856 match std::fs::OpenOptions::new()
857 .append(true)
858 .create(true)
859 .open(&profile_path)
860 {
861 Ok(mut f) => {
862 use std::io::Write;
863 let _ = f.write_all(functions.as_bytes());
864 println!("Added lean-ctx functions to {}", profile_path.display());
865 println!(" Binary: {binary}");
866 }
867 Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
868 }
869}
870
871fn remove_lean_ctx_block_ps(content: &str) -> String {
872 let mut result = String::new();
873 let mut in_block = false;
874 let mut brace_depth = 0i32;
875
876 for line in content.lines() {
877 if line.contains("lean-ctx shell hook") {
878 in_block = true;
879 continue;
880 }
881 if in_block {
882 brace_depth += line.matches('{').count() as i32;
883 brace_depth -= line.matches('}').count() as i32;
884 if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
885 if line.trim() == "}" {
886 in_block = false;
887 brace_depth = 0;
888 }
889 continue;
890 }
891 continue;
892 }
893 result.push_str(line);
894 result.push('\n');
895 }
896 result
897}
898
899fn init_fish(binary: &str) {
900 let config = dirs::home_dir()
901 .map(|h| h.join(".config/fish/config.fish"))
902 .unwrap_or_default();
903
904 let aliases = format!(
905 "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
906 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\
907 \n\
908 function lean-ctx-on\n\
909 \tfor _lc_cmd in $_lean_ctx_cmds\n\
910 \t\talias $_lc_cmd '{binary} -c '$_lc_cmd\n\
911 \tend\n\
912 \talias k '{binary} -c kubectl'\n\
913 \tset -gx LEAN_CTX_ENABLED 1\n\
914 \techo 'lean-ctx: ON'\n\
915 end\n\
916 \n\
917 function lean-ctx-off\n\
918 \tfor _lc_cmd in $_lean_ctx_cmds\n\
919 \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
920 \tend\n\
921 \tfunctions --erase k 2>/dev/null; true\n\
922 \tset -e LEAN_CTX_ENABLED\n\
923 \techo 'lean-ctx: OFF'\n\
924 end\n\
925 \n\
926 function lean-ctx-status\n\
927 \tif set -q LEAN_CTX_ENABLED\n\
928 \t\techo 'lean-ctx: ON'\n\
929 \telse\n\
930 \t\techo 'lean-ctx: OFF'\n\
931 \tend\n\
932 end\n\
933 \n\
934 if not set -q LEAN_CTX_ACTIVE; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
935 \tlean-ctx-on\n\
936 end\n\
937 # lean-ctx shell hook — end\n"
938 );
939
940 if let Ok(existing) = std::fs::read_to_string(&config) {
941 if existing.contains("lean-ctx shell hook") {
942 let cleaned = remove_lean_ctx_block(&existing);
943 match std::fs::write(&config, format!("{cleaned}{aliases}")) {
944 Ok(()) => {
945 println!("Updated lean-ctx aliases in {}", config.display());
946 println!(" Binary: {binary}");
947 return;
948 }
949 Err(e) => {
950 eprintln!("Error updating {}: {e}", config.display());
951 return;
952 }
953 }
954 }
955 }
956
957 match std::fs::OpenOptions::new()
958 .append(true)
959 .create(true)
960 .open(&config)
961 {
962 Ok(mut f) => {
963 use std::io::Write;
964 let _ = f.write_all(aliases.as_bytes());
965 println!("Added lean-ctx aliases to {}", config.display());
966 println!(" Binary: {binary}");
967 }
968 Err(e) => eprintln!("Error writing {}: {e}", config.display()),
969 }
970}
971
972fn init_posix(is_zsh: bool, binary: &str) {
973 let rc_file = if is_zsh {
974 dirs::home_dir()
975 .map(|h| h.join(".zshrc"))
976 .unwrap_or_default()
977 } else {
978 dirs::home_dir()
979 .map(|h| h.join(".bashrc"))
980 .unwrap_or_default()
981 };
982
983 let aliases = format!(
984 r#"
985# lean-ctx shell hook — transparent CLI compression (90+ patterns)
986_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)
987
988lean-ctx-on() {{
989 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
990 # shellcheck disable=SC2139
991 alias "$_lc_cmd"='{binary} -c '"$_lc_cmd"
992 done
993 alias k='{binary} -c kubectl'
994 export LEAN_CTX_ENABLED=1
995 echo "lean-ctx: ON"
996}}
997
998lean-ctx-off() {{
999 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
1000 unalias "$_lc_cmd" 2>/dev/null || true
1001 done
1002 unalias k 2>/dev/null || true
1003 unset LEAN_CTX_ENABLED
1004 echo "lean-ctx: OFF"
1005}}
1006
1007lean-ctx-status() {{
1008 if [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
1009 echo "lean-ctx: ON"
1010 else
1011 echo "lean-ctx: OFF"
1012 fi
1013}}
1014
1015if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
1016 lean-ctx-on
1017fi
1018# lean-ctx shell hook — end
1019"#
1020 );
1021
1022 if let Ok(existing) = std::fs::read_to_string(&rc_file) {
1023 if existing.contains("lean-ctx shell hook") {
1024 let cleaned = remove_lean_ctx_block(&existing);
1025 match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
1026 Ok(()) => {
1027 println!("Updated lean-ctx aliases in {}", rc_file.display());
1028 println!(" Binary: {binary}");
1029 return;
1030 }
1031 Err(e) => {
1032 eprintln!("Error updating {}: {e}", rc_file.display());
1033 return;
1034 }
1035 }
1036 }
1037 }
1038
1039 match std::fs::OpenOptions::new()
1040 .append(true)
1041 .create(true)
1042 .open(&rc_file)
1043 {
1044 Ok(mut f) => {
1045 use std::io::Write;
1046 let _ = f.write_all(aliases.as_bytes());
1047 println!("Added lean-ctx aliases to {}", rc_file.display());
1048 println!(" Binary: {binary}");
1049 }
1050 Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1051 }
1052}
1053
1054fn remove_lean_ctx_block(content: &str) -> String {
1055 if content.contains("# lean-ctx shell hook — end") {
1057 return remove_lean_ctx_block_by_marker(content);
1058 }
1059 remove_lean_ctx_block_legacy(content)
1060}
1061
1062fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1063 let mut result = String::new();
1064 let mut in_block = false;
1065
1066 for line in content.lines() {
1067 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1068 in_block = true;
1069 continue;
1070 }
1071 if in_block {
1072 if line.trim() == "# lean-ctx shell hook — end" {
1073 in_block = false;
1074 }
1075 continue;
1076 }
1077 result.push_str(line);
1078 result.push('\n');
1079 }
1080 result
1081}
1082
1083fn remove_lean_ctx_block_legacy(content: &str) -> String {
1084 let mut result = String::new();
1085 let mut in_block = false;
1086
1087 for line in content.lines() {
1088 if line.contains("lean-ctx shell hook") {
1089 in_block = true;
1090 continue;
1091 }
1092 if in_block {
1093 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1094 if line.trim() == "fi" || line.trim() == "end" {
1095 in_block = false;
1096 }
1097 continue;
1098 }
1099 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1100 in_block = false;
1101 result.push_str(line);
1102 result.push('\n');
1103 }
1104 continue;
1105 }
1106 result.push_str(line);
1107 result.push('\n');
1108 }
1109 result
1110}
1111
1112pub fn load_shell_history_pub() -> Vec<String> {
1113 load_shell_history()
1114}
1115
1116fn load_shell_history() -> Vec<String> {
1117 let shell = std::env::var("SHELL").unwrap_or_default();
1118 let home = match dirs::home_dir() {
1119 Some(h) => h,
1120 None => return Vec::new(),
1121 };
1122
1123 let history_file = if shell.contains("zsh") {
1124 home.join(".zsh_history")
1125 } else if shell.contains("fish") {
1126 home.join(".local/share/fish/fish_history")
1127 } else if cfg!(windows) && shell.is_empty() {
1128 home.join("AppData")
1129 .join("Roaming")
1130 .join("Microsoft")
1131 .join("Windows")
1132 .join("PowerShell")
1133 .join("PSReadLine")
1134 .join("ConsoleHost_history.txt")
1135 } else {
1136 home.join(".bash_history")
1137 };
1138
1139 match std::fs::read_to_string(&history_file) {
1140 Ok(content) => content
1141 .lines()
1142 .filter_map(|l| {
1143 let trimmed = l.trim();
1144 if trimmed.starts_with(':') {
1145 trimmed.split(';').nth(1).map(|s| s.to_string())
1146 } else {
1147 Some(trimmed.to_string())
1148 }
1149 })
1150 .filter(|l| !l.is_empty())
1151 .collect(),
1152 Err(_) => Vec::new(),
1153 }
1154}
1155
1156fn print_savings(original: usize, sent: usize) {
1157 let saved = original.saturating_sub(sent);
1158 if original > 0 && saved > 0 {
1159 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1160 println!("[{saved} tok saved ({pct}%)]");
1161 }
1162}
1163
1164pub fn cmd_theme(args: &[String]) {
1165 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1166 let r = theme::rst();
1167 let b = theme::bold();
1168 let d = theme::dim();
1169
1170 match sub {
1171 "list" => {
1172 let cfg = config::Config::load();
1173 let active = cfg.theme.as_str();
1174 println!();
1175 println!(" {b}Available themes:{r}");
1176 println!(" {ln}", ln = "─".repeat(40));
1177 for name in theme::PRESET_NAMES {
1178 let marker = if *name == active { " ◀ active" } else { "" };
1179 let t = theme::from_preset(name).unwrap();
1180 let preview = format!(
1181 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1182 p = t.primary.fg(),
1183 s = t.secondary.fg(),
1184 a = t.accent.fg(),
1185 sc = t.success.fg(),
1186 w = t.warning.fg(),
1187 );
1188 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1189 }
1190 if let Some(path) = theme::theme_file_path() {
1191 if path.exists() {
1192 let custom = theme::load_theme("_custom_");
1193 let preview = format!(
1194 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1195 p = custom.primary.fg(),
1196 s = custom.secondary.fg(),
1197 a = custom.accent.fg(),
1198 sc = custom.success.fg(),
1199 w = custom.warning.fg(),
1200 );
1201 let marker = if active == "custom" {
1202 " ◀ active"
1203 } else {
1204 ""
1205 };
1206 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1207 }
1208 }
1209 println!();
1210 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1211 println!();
1212 }
1213 "set" => {
1214 if args.len() < 2 {
1215 eprintln!("Usage: lean-ctx theme set <name>");
1216 std::process::exit(1);
1217 }
1218 let name = &args[1];
1219 if theme::from_preset(name).is_none() && name != "custom" {
1220 eprintln!(
1221 "Unknown theme '{name}'. Available: {}",
1222 theme::PRESET_NAMES.join(", ")
1223 );
1224 std::process::exit(1);
1225 }
1226 let mut cfg = config::Config::load();
1227 cfg.theme = name.to_string();
1228 match cfg.save() {
1229 Ok(()) => {
1230 let t = theme::load_theme(name);
1231 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1232 let preview = t.gradient_bar(0.75, 30);
1233 println!(" {preview}");
1234 }
1235 Err(e) => eprintln!("Error: {e}"),
1236 }
1237 }
1238 "export" => {
1239 let cfg = config::Config::load();
1240 let t = theme::load_theme(&cfg.theme);
1241 println!("{}", t.to_toml());
1242 }
1243 "import" => {
1244 if args.len() < 2 {
1245 eprintln!("Usage: lean-ctx theme import <path>");
1246 std::process::exit(1);
1247 }
1248 let path = std::path::Path::new(&args[1]);
1249 if !path.exists() {
1250 eprintln!("File not found: {}", args[1]);
1251 std::process::exit(1);
1252 }
1253 match std::fs::read_to_string(path) {
1254 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1255 Ok(imported) => match theme::save_theme(&imported) {
1256 Ok(()) => {
1257 let mut cfg = config::Config::load();
1258 cfg.theme = "custom".to_string();
1259 let _ = cfg.save();
1260 println!(
1261 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1262 sc = imported.success.fg(),
1263 name = imported.name,
1264 );
1265 println!(" Config updated: theme = custom");
1266 }
1267 Err(e) => eprintln!("Error saving theme: {e}"),
1268 },
1269 Err(e) => eprintln!("Invalid theme file: {e}"),
1270 },
1271 Err(e) => eprintln!("Error reading file: {e}"),
1272 }
1273 }
1274 "preview" => {
1275 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1276 let t = match theme::from_preset(name) {
1277 Some(t) => t,
1278 None => {
1279 eprintln!("Unknown theme: {name}");
1280 std::process::exit(1);
1281 }
1282 };
1283 println!();
1284 println!(
1285 " {icon} {title} {d}Theme Preview: {name}{r}",
1286 icon = t.header_icon(),
1287 title = t.brand_title(),
1288 );
1289 println!(" {ln}", ln = t.border_line(50));
1290 println!();
1291 println!(
1292 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1293 sc = t.success.fg(),
1294 sec = t.secondary.fg(),
1295 wrn = t.warning.fg(),
1296 acc = t.accent.fg(),
1297 );
1298 println!(" {d} tokens saved compression commands USD saved{r}");
1299 println!();
1300 println!(
1301 " {b}{txt}Gradient Bar{r} {bar}",
1302 txt = t.text.fg(),
1303 bar = t.gradient_bar(0.85, 30),
1304 );
1305 println!(
1306 " {b}{txt}Sparkline{r} {spark}",
1307 txt = t.text.fg(),
1308 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1309 );
1310 println!();
1311 println!(" {top}", top = t.box_top(50));
1312 println!(
1313 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1314 side = t.box_side(),
1315 side_r = t.box_side(),
1316 txt = t.text.fg(),
1317 );
1318 println!(" {bot}", bot = t.box_bottom(50));
1319 println!();
1320 }
1321 _ => {
1322 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1323 std::process::exit(1);
1324 }
1325 }
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330 use super::*;
1331
1332 #[test]
1333 fn test_remove_lean_ctx_block_posix() {
1334 let input = r#"# existing config
1335export PATH="$HOME/bin:$PATH"
1336
1337# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1338if [ -z "$LEAN_CTX_ACTIVE" ]; then
1339alias git='lean-ctx -c git'
1340alias npm='lean-ctx -c npm'
1341fi
1342
1343# other stuff
1344export EDITOR=vim
1345"#;
1346 let result = remove_lean_ctx_block(input);
1347 assert!(!result.contains("lean-ctx"), "block should be removed");
1348 assert!(result.contains("export PATH"), "other content preserved");
1349 assert!(
1350 result.contains("export EDITOR"),
1351 "trailing content preserved"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_remove_lean_ctx_block_fish() {
1357 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";
1358 let result = remove_lean_ctx_block(input);
1359 assert!(!result.contains("lean-ctx"), "block should be removed");
1360 assert!(result.contains("set -x FOO"), "other content preserved");
1361 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1362 }
1363
1364 #[test]
1365 fn test_remove_lean_ctx_block_ps() {
1366 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";
1367 let result = remove_lean_ctx_block_ps(input);
1368 assert!(
1369 !result.contains("lean-ctx shell hook"),
1370 "block should be removed"
1371 );
1372 assert!(result.contains("$env:FOO"), "other content preserved");
1373 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1374 }
1375
1376 #[test]
1377 fn test_remove_block_no_lean_ctx() {
1378 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1379 let result = remove_lean_ctx_block(input);
1380 assert!(result.contains("export PATH"), "content unchanged");
1381 }
1382
1383 #[test]
1384 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1385 let input = r#"# existing config
1386export PATH="$HOME/bin:$PATH"
1387
1388# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1389_lean_ctx_cmds=(git npm pnpm)
1390
1391lean-ctx-on() {
1392 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1393 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1394 done
1395 export LEAN_CTX_ENABLED=1
1396 echo "lean-ctx: ON"
1397}
1398
1399lean-ctx-off() {
1400 unset LEAN_CTX_ENABLED
1401 echo "lean-ctx: OFF"
1402}
1403
1404if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1405 lean-ctx-on
1406fi
1407# lean-ctx shell hook — end
1408
1409# other stuff
1410export EDITOR=vim
1411"#;
1412 let result = remove_lean_ctx_block(input);
1413 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1414 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1415 assert!(result.contains("export PATH"), "other content preserved");
1416 assert!(
1417 result.contains("export EDITOR"),
1418 "trailing content preserved"
1419 );
1420 }
1421}