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_config(args: &[String]) {
481 let cfg = config::Config::load();
482
483 if args.is_empty() {
484 println!("{}", cfg.show());
485 return;
486 }
487
488 match args[0].as_str() {
489 "init" | "create" => {
490 let default = config::Config::default();
491 match default.save() {
492 Ok(()) => {
493 let path = config::Config::path()
494 .map(|p| p.to_string_lossy().to_string())
495 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
496 println!("Created default config at {path}");
497 }
498 Err(e) => eprintln!("Error: {e}"),
499 }
500 }
501 "set" => {
502 if args.len() < 3 {
503 eprintln!("Usage: lean-ctx config set <key> <value>");
504 std::process::exit(1);
505 }
506 let mut cfg = cfg;
507 let key = &args[1];
508 let val = &args[2];
509 match key.as_str() {
510 "ultra_compact" => cfg.ultra_compact = val == "true",
511 "tee_on_error" => cfg.tee_on_error = val == "true",
512 "checkpoint_interval" => {
513 cfg.checkpoint_interval = val.parse().unwrap_or(15);
514 }
515 "theme" => {
516 if theme::from_preset(val).is_some() || val == "custom" {
517 cfg.theme = val.to_string();
518 } else {
519 eprintln!(
520 "Unknown theme '{val}'. Available: {}",
521 theme::PRESET_NAMES.join(", ")
522 );
523 std::process::exit(1);
524 }
525 }
526 _ => {
527 eprintln!("Unknown config key: {key}");
528 std::process::exit(1);
529 }
530 }
531 match cfg.save() {
532 Ok(()) => println!("Updated {key} = {val}"),
533 Err(e) => eprintln!("Error saving config: {e}"),
534 }
535 }
536 _ => {
537 eprintln!("Usage: lean-ctx config [init|set <key> <value>]");
538 std::process::exit(1);
539 }
540 }
541}
542
543pub fn cmd_cheatsheet() {
544 println!(
545 "\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m
546\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
547\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m
548
549\x1b[1;33m━━━ BEFORE YOU START ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
550 ctx_session load \x1b[2m# restore previous session\x1b[0m
551 ctx_overview task=\"...\" \x1b[2m# task-aware file map\x1b[0m
552 ctx_graph action=build \x1b[2m# index project (first time)\x1b[0m
553 ctx_knowledge action=recall \x1b[2m# check stored project facts\x1b[0m
554
555\x1b[1;32m━━━ WHILE CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
556 ctx_read mode=full \x1b[2m# first read (cached, re-reads: 99% saved)\x1b[0m
557 ctx_read mode=map \x1b[2m# context-only files (~93% saved)\x1b[0m
558 ctx_read mode=diff \x1b[2m# after editing (~98% saved)\x1b[0m
559 ctx_read mode=sigs \x1b[2m# API surface of large files (~95%)\x1b[0m
560 ctx_multi_read \x1b[2m# read multiple files at once\x1b[0m
561 ctx_search \x1b[2m# search with compressed results (~70%)\x1b[0m
562 ctx_shell \x1b[2m# run CLI with compressed output (~60-90%)\x1b[0m
563
564\x1b[1;35m━━━ AFTER CODING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
565 ctx_session finding \"...\" \x1b[2m# record what you discovered\x1b[0m
566 ctx_session decision \"...\" \x1b[2m# record architectural choices\x1b[0m
567 ctx_knowledge action=remember \x1b[2m# store permanent project facts\x1b[0m
568 ctx_knowledge action=consolidate \x1b[2m# auto-extract session insights\x1b[0m
569 ctx_metrics \x1b[2m# see session statistics\x1b[0m
570
571\x1b[1;34m━━━ MULTI-AGENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
572 ctx_agent action=register \x1b[2m# announce yourself\x1b[0m
573 ctx_agent action=list \x1b[2m# see other active agents\x1b[0m
574 ctx_agent action=post \x1b[2m# share findings\x1b[0m
575 ctx_agent action=read \x1b[2m# check messages\x1b[0m
576
577\x1b[1;31m━━━ READ MODE DECISION TREE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
578 Will edit? → \x1b[1mfull\x1b[0m (re-reads: 13 tokens) → after edit: \x1b[1mdiff\x1b[0m
579 API only? → \x1b[1msignatures\x1b[0m
580 Deps/exports? → \x1b[1mmap\x1b[0m
581 Very large? → \x1b[1mentropy\x1b[0m (information-dense lines)
582 Browsing? → \x1b[1maggressive\x1b[0m (syntax stripped)
583
584\x1b[1;36m━━━ MONITORING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m
585 lean-ctx gain \x1b[2m# visual savings dashboard\x1b[0m
586 lean-ctx gain --live \x1b[2m# live auto-updating (Ctrl+C)\x1b[0m
587 lean-ctx dashboard \x1b[2m# web dashboard with charts\x1b[0m
588 lean-ctx wrapped \x1b[2m# weekly savings report\x1b[0m
589 lean-ctx discover \x1b[2m# find uncompressed commands\x1b[0m
590 lean-ctx doctor \x1b[2m# diagnose installation\x1b[0m
591 lean-ctx update \x1b[2m# self-update to latest\x1b[0m
592
593\x1b[2m Full guide: https://leanctx.com/docs/workflow\x1b[0m"
594 );
595}
596
597pub fn cmd_slow_log(args: &[String]) {
598 use crate::core::slow_log;
599
600 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
601 match action {
602 "list" | "ls" | "" => println!("{}", slow_log::list()),
603 "clear" | "purge" => println!("{}", slow_log::clear()),
604 _ => {
605 eprintln!("Usage: lean-ctx slow-log [list|clear]");
606 std::process::exit(1);
607 }
608 }
609}
610
611pub fn cmd_tee(args: &[String]) {
612 let tee_dir = match dirs::home_dir() {
613 Some(h) => h.join(".lean-ctx").join("tee"),
614 None => {
615 eprintln!("Cannot determine home directory");
616 std::process::exit(1);
617 }
618 };
619
620 let action = args.first().map(|s| s.as_str()).unwrap_or("list");
621 match action {
622 "list" | "ls" => {
623 if !tee_dir.exists() {
624 println!("No tee logs found (~/.lean-ctx/tee/ does not exist)");
625 return;
626 }
627 let mut entries: Vec<_> = std::fs::read_dir(&tee_dir)
628 .unwrap_or_else(|e| {
629 eprintln!("Error: {e}");
630 std::process::exit(1);
631 })
632 .filter_map(|e| e.ok())
633 .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("log"))
634 .collect();
635 entries.sort_by_key(|e| e.file_name());
636
637 if entries.is_empty() {
638 println!("No tee logs found.");
639 return;
640 }
641
642 println!("Tee logs ({}):\n", entries.len());
643 for entry in &entries {
644 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
645 let name = entry.file_name();
646 let size_str = if size > 1024 {
647 format!("{}K", size / 1024)
648 } else {
649 format!("{}B", size)
650 };
651 println!(" {:<60} {}", name.to_string_lossy(), size_str);
652 }
653 println!("\nUse 'lean-ctx tee clear' to delete all logs.");
654 }
655 "clear" | "purge" => {
656 if !tee_dir.exists() {
657 println!("No tee logs to clear.");
658 return;
659 }
660 let mut count = 0u32;
661 if let Ok(entries) = std::fs::read_dir(&tee_dir) {
662 for entry in entries.flatten() {
663 if entry.path().extension().and_then(|x| x.to_str()) == Some("log")
664 && std::fs::remove_file(entry.path()).is_ok()
665 {
666 count += 1;
667 }
668 }
669 }
670 println!("Cleared {count} tee log(s) from {}", tee_dir.display());
671 }
672 "show" => {
673 let filename = args.get(1);
674 if filename.is_none() {
675 eprintln!("Usage: lean-ctx tee show <filename>");
676 std::process::exit(1);
677 }
678 let path = tee_dir.join(filename.unwrap());
679 match crate::tools::ctx_read::read_file_lossy(&path.to_string_lossy()) {
680 Ok(content) => print!("{content}"),
681 Err(e) => {
682 eprintln!("Error reading {}: {e}", path.display());
683 std::process::exit(1);
684 }
685 }
686 }
687 _ => {
688 eprintln!("Usage: lean-ctx tee [list|clear|show <file>]");
689 std::process::exit(1);
690 }
691 }
692}
693
694pub fn cmd_init(args: &[String]) {
695 let global = args.iter().any(|a| a == "--global" || a == "-g");
696
697 let agents: Vec<&str> = args
698 .windows(2)
699 .filter(|w| w[0] == "--agent")
700 .map(|w| w[1].as_str())
701 .collect();
702
703 if !agents.is_empty() {
704 for agent_name in &agents {
705 crate::hooks::install_agent_hook(agent_name, global);
706 }
707 println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
708 return;
709 }
710
711 let shell_name = std::env::var("SHELL").unwrap_or_default();
712 let is_zsh = shell_name.contains("zsh");
713 let is_fish = shell_name.contains("fish");
714 let is_powershell = cfg!(windows) && shell_name.is_empty();
715
716 let binary = std::env::current_exe()
717 .map(|p| p.to_string_lossy().to_string())
718 .unwrap_or_else(|_| "lean-ctx".to_string());
719
720 if is_powershell {
721 init_powershell(&binary);
722 } else {
723 let bash_binary = to_bash_compatible_path(&binary);
724 if is_fish {
725 init_fish(&bash_binary);
726 } else {
727 init_posix(is_zsh, &bash_binary);
728 }
729 }
730
731 let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
732 if let Some(dir) = lean_dir {
733 if !dir.exists() {
734 let _ = std::fs::create_dir_all(&dir);
735 println!("Created {}", dir.display());
736 }
737 }
738
739 if global && !is_powershell {
740 let rc = if is_fish {
741 "config.fish"
742 } else if is_zsh {
743 ".zshrc"
744 } else {
745 ".bashrc"
746 };
747 println!("\nRestart your shell or run: source ~/{rc}");
748 } else if global && is_powershell {
749 println!("\nRestart PowerShell or run: . $PROFILE");
750 }
751
752 println!("\nlean-ctx init complete. (23 aliases installed)");
753 println!("Binary: {binary}");
754 println!("\nFor AI tool integration, use: lean-ctx init --agent <tool>");
755 println!(" Supported: claude, cursor, gemini, codex, windsurf, cline, copilot, pi");
756 println!("\nRun 'lean-ctx gain' after using some commands to see your savings.");
757 println!("Run 'lean-ctx discover' to find missed savings in your shell history.");
758}
759
760fn init_powershell(binary: &str) {
761 let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
762 let profile_path = match profile_dir {
763 Some(dir) => {
764 let _ = std::fs::create_dir_all(&dir);
765 dir.join("Microsoft.PowerShell_profile.ps1")
766 }
767 None => {
768 eprintln!("Could not resolve PowerShell profile directory");
769 return;
770 }
771 };
772
773 let binary_escaped = binary.replace('\\', "\\\\");
774 let functions = format!(
775 r#"
776# lean-ctx shell hook — transparent CLI compression (90+ patterns)
777if (-not $env:LEAN_CTX_ACTIVE) {{
778 $LeanCtxBin = "{binary_escaped}"
779 function git {{ & $LeanCtxBin -c "git $($args -join ' ')" }}
780 function npm {{ & $LeanCtxBin -c "npm $($args -join ' ')" }}
781 function pnpm {{ & $LeanCtxBin -c "pnpm $($args -join ' ')" }}
782 function yarn {{ & $LeanCtxBin -c "yarn $($args -join ' ')" }}
783 function cargo {{ & $LeanCtxBin -c "cargo $($args -join ' ')" }}
784 function docker {{ & $LeanCtxBin -c "docker $($args -join ' ')" }}
785 function kubectl {{ & $LeanCtxBin -c "kubectl $($args -join ' ')" }}
786 function gh {{ & $LeanCtxBin -c "gh $($args -join ' ')" }}
787 function pip {{ & $LeanCtxBin -c "pip $($args -join ' ')" }}
788 function pip3 {{ & $LeanCtxBin -c "pip3 $($args -join ' ')" }}
789 function ruff {{ & $LeanCtxBin -c "ruff $($args -join ' ')" }}
790 function go {{ & $LeanCtxBin -c "go $($args -join ' ')" }}
791 function eslint {{ & $LeanCtxBin -c "eslint $($args -join ' ')" }}
792 function prettier {{ & $LeanCtxBin -c "prettier $($args -join ' ')" }}
793 function tsc {{ & $LeanCtxBin -c "tsc $($args -join ' ')" }}
794 function curl {{ & $LeanCtxBin -c "curl $($args -join ' ')" }}
795 function wget {{ & $LeanCtxBin -c "wget $($args -join ' ')" }}
796}}
797"#
798 );
799
800 if let Ok(existing) = std::fs::read_to_string(&profile_path) {
801 if existing.contains("lean-ctx shell hook") {
802 let cleaned = remove_lean_ctx_block_ps(&existing);
803 match std::fs::write(&profile_path, format!("{cleaned}{functions}")) {
804 Ok(()) => {
805 println!("Updated lean-ctx functions in {}", profile_path.display());
806 println!(" Binary: {binary}");
807 return;
808 }
809 Err(e) => {
810 eprintln!("Error updating {}: {e}", profile_path.display());
811 return;
812 }
813 }
814 }
815 }
816
817 match std::fs::OpenOptions::new()
818 .append(true)
819 .create(true)
820 .open(&profile_path)
821 {
822 Ok(mut f) => {
823 use std::io::Write;
824 let _ = f.write_all(functions.as_bytes());
825 println!("Added lean-ctx functions to {}", profile_path.display());
826 println!(" Binary: {binary}");
827 }
828 Err(e) => eprintln!("Error writing {}: {e}", profile_path.display()),
829 }
830}
831
832fn remove_lean_ctx_block_ps(content: &str) -> String {
833 let mut result = String::new();
834 let mut in_block = false;
835 let mut brace_depth = 0i32;
836
837 for line in content.lines() {
838 if line.contains("lean-ctx shell hook") {
839 in_block = true;
840 continue;
841 }
842 if in_block {
843 brace_depth += line.matches('{').count() as i32;
844 brace_depth -= line.matches('}').count() as i32;
845 if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
846 if line.trim() == "}" {
847 in_block = false;
848 brace_depth = 0;
849 }
850 continue;
851 }
852 continue;
853 }
854 result.push_str(line);
855 result.push('\n');
856 }
857 result
858}
859
860fn init_fish(binary: &str) {
861 let config = dirs::home_dir()
862 .map(|h| h.join(".config/fish/config.fish"))
863 .unwrap_or_default();
864
865 let aliases = format!(
866 "\n# lean-ctx shell hook — transparent CLI compression (90+ patterns)\n\
867 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\
868 \n\
869 function lean-ctx-on\n\
870 \tfor _lc_cmd in $_lean_ctx_cmds\n\
871 \t\talias $_lc_cmd '{binary} -c '$_lc_cmd\n\
872 \tend\n\
873 \talias k '{binary} -c kubectl'\n\
874 \tset -gx LEAN_CTX_ENABLED 1\n\
875 \techo 'lean-ctx: ON'\n\
876 end\n\
877 \n\
878 function lean-ctx-off\n\
879 \tfor _lc_cmd in $_lean_ctx_cmds\n\
880 \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
881 \tend\n\
882 \tfunctions --erase k 2>/dev/null; true\n\
883 \tset -e LEAN_CTX_ENABLED\n\
884 \techo 'lean-ctx: OFF'\n\
885 end\n\
886 \n\
887 function lean-ctx-status\n\
888 \tif set -q LEAN_CTX_ENABLED\n\
889 \t\techo 'lean-ctx: ON'\n\
890 \telse\n\
891 \t\techo 'lean-ctx: OFF'\n\
892 \tend\n\
893 end\n\
894 \n\
895 if not set -q LEAN_CTX_ACTIVE; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
896 \tlean-ctx-on\n\
897 end\n\
898 # lean-ctx shell hook — end\n"
899 );
900
901 if let Ok(existing) = std::fs::read_to_string(&config) {
902 if existing.contains("lean-ctx shell hook") {
903 let cleaned = remove_lean_ctx_block(&existing);
904 match std::fs::write(&config, format!("{cleaned}{aliases}")) {
905 Ok(()) => {
906 println!("Updated lean-ctx aliases in {}", config.display());
907 println!(" Binary: {binary}");
908 return;
909 }
910 Err(e) => {
911 eprintln!("Error updating {}: {e}", config.display());
912 return;
913 }
914 }
915 }
916 }
917
918 match std::fs::OpenOptions::new()
919 .append(true)
920 .create(true)
921 .open(&config)
922 {
923 Ok(mut f) => {
924 use std::io::Write;
925 let _ = f.write_all(aliases.as_bytes());
926 println!("Added lean-ctx aliases to {}", config.display());
927 println!(" Binary: {binary}");
928 }
929 Err(e) => eprintln!("Error writing {}: {e}", config.display()),
930 }
931}
932
933fn init_posix(is_zsh: bool, binary: &str) {
934 let rc_file = if is_zsh {
935 dirs::home_dir()
936 .map(|h| h.join(".zshrc"))
937 .unwrap_or_default()
938 } else {
939 dirs::home_dir()
940 .map(|h| h.join(".bashrc"))
941 .unwrap_or_default()
942 };
943
944 let aliases = format!(
945 r#"
946# lean-ctx shell hook — transparent CLI compression (90+ patterns)
947_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)
948
949lean-ctx-on() {{
950 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
951 # shellcheck disable=SC2139
952 alias "$_lc_cmd"='{binary} -c '"$_lc_cmd"
953 done
954 alias k='{binary} -c kubectl'
955 export LEAN_CTX_ENABLED=1
956 echo "lean-ctx: ON"
957}}
958
959lean-ctx-off() {{
960 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
961 unalias "$_lc_cmd" 2>/dev/null || true
962 done
963 unalias k 2>/dev/null || true
964 unset LEAN_CTX_ENABLED
965 echo "lean-ctx: OFF"
966}}
967
968lean-ctx-status() {{
969 if [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
970 echo "lean-ctx: ON"
971 else
972 echo "lean-ctx: OFF"
973 fi
974}}
975
976if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
977 lean-ctx-on
978fi
979# lean-ctx shell hook — end
980"#
981 );
982
983 if let Ok(existing) = std::fs::read_to_string(&rc_file) {
984 if existing.contains("lean-ctx shell hook") {
985 let cleaned = remove_lean_ctx_block(&existing);
986 match std::fs::write(&rc_file, format!("{cleaned}{aliases}")) {
987 Ok(()) => {
988 println!("Updated lean-ctx aliases in {}", rc_file.display());
989 println!(" Binary: {binary}");
990 return;
991 }
992 Err(e) => {
993 eprintln!("Error updating {}: {e}", rc_file.display());
994 return;
995 }
996 }
997 }
998 }
999
1000 match std::fs::OpenOptions::new()
1001 .append(true)
1002 .create(true)
1003 .open(&rc_file)
1004 {
1005 Ok(mut f) => {
1006 use std::io::Write;
1007 let _ = f.write_all(aliases.as_bytes());
1008 println!("Added lean-ctx aliases to {}", rc_file.display());
1009 println!(" Binary: {binary}");
1010 }
1011 Err(e) => eprintln!("Error writing {}: {e}", rc_file.display()),
1012 }
1013}
1014
1015fn remove_lean_ctx_block(content: &str) -> String {
1016 if content.contains("# lean-ctx shell hook — end") {
1018 return remove_lean_ctx_block_by_marker(content);
1019 }
1020 remove_lean_ctx_block_legacy(content)
1021}
1022
1023fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1024 let mut result = String::new();
1025 let mut in_block = false;
1026
1027 for line in content.lines() {
1028 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1029 in_block = true;
1030 continue;
1031 }
1032 if in_block {
1033 if line.trim() == "# lean-ctx shell hook — end" {
1034 in_block = false;
1035 }
1036 continue;
1037 }
1038 result.push_str(line);
1039 result.push('\n');
1040 }
1041 result
1042}
1043
1044fn remove_lean_ctx_block_legacy(content: &str) -> String {
1045 let mut result = String::new();
1046 let mut in_block = false;
1047
1048 for line in content.lines() {
1049 if line.contains("lean-ctx shell hook") {
1050 in_block = true;
1051 continue;
1052 }
1053 if in_block {
1054 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1055 if line.trim() == "fi" || line.trim() == "end" {
1056 in_block = false;
1057 }
1058 continue;
1059 }
1060 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1061 in_block = false;
1062 result.push_str(line);
1063 result.push('\n');
1064 }
1065 continue;
1066 }
1067 result.push_str(line);
1068 result.push('\n');
1069 }
1070 result
1071}
1072
1073pub fn load_shell_history_pub() -> Vec<String> {
1074 load_shell_history()
1075}
1076
1077fn load_shell_history() -> Vec<String> {
1078 let shell = std::env::var("SHELL").unwrap_or_default();
1079 let home = match dirs::home_dir() {
1080 Some(h) => h,
1081 None => return Vec::new(),
1082 };
1083
1084 let history_file = if shell.contains("zsh") {
1085 home.join(".zsh_history")
1086 } else if shell.contains("fish") {
1087 home.join(".local/share/fish/fish_history")
1088 } else if cfg!(windows) && shell.is_empty() {
1089 home.join("AppData")
1090 .join("Roaming")
1091 .join("Microsoft")
1092 .join("Windows")
1093 .join("PowerShell")
1094 .join("PSReadLine")
1095 .join("ConsoleHost_history.txt")
1096 } else {
1097 home.join(".bash_history")
1098 };
1099
1100 match std::fs::read_to_string(&history_file) {
1101 Ok(content) => content
1102 .lines()
1103 .filter_map(|l| {
1104 let trimmed = l.trim();
1105 if trimmed.starts_with(':') {
1106 trimmed.split(';').nth(1).map(|s| s.to_string())
1107 } else {
1108 Some(trimmed.to_string())
1109 }
1110 })
1111 .filter(|l| !l.is_empty())
1112 .collect(),
1113 Err(_) => Vec::new(),
1114 }
1115}
1116
1117fn print_savings(original: usize, sent: usize) {
1118 let saved = original.saturating_sub(sent);
1119 if original > 0 && saved > 0 {
1120 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
1121 println!("[{saved} tok saved ({pct}%)]");
1122 }
1123}
1124
1125pub fn cmd_theme(args: &[String]) {
1126 let sub = args.first().map(|s| s.as_str()).unwrap_or("list");
1127 let r = theme::rst();
1128 let b = theme::bold();
1129 let d = theme::dim();
1130
1131 match sub {
1132 "list" => {
1133 let cfg = config::Config::load();
1134 let active = cfg.theme.as_str();
1135 println!();
1136 println!(" {b}Available themes:{r}");
1137 println!(" {ln}", ln = "─".repeat(40));
1138 for name in theme::PRESET_NAMES {
1139 let marker = if *name == active { " ◀ active" } else { "" };
1140 let t = theme::from_preset(name).unwrap();
1141 let preview = format!(
1142 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1143 p = t.primary.fg(),
1144 s = t.secondary.fg(),
1145 a = t.accent.fg(),
1146 sc = t.success.fg(),
1147 w = t.warning.fg(),
1148 );
1149 println!(" {preview} {b}{name:<12}{r}{d}{marker}{r}");
1150 }
1151 if let Some(path) = theme::theme_file_path() {
1152 if path.exists() {
1153 let custom = theme::load_theme("_custom_");
1154 let preview = format!(
1155 "{p}██{r}{s}██{r}{a}██{r}{sc}██{r}{w}██{r}",
1156 p = custom.primary.fg(),
1157 s = custom.secondary.fg(),
1158 a = custom.accent.fg(),
1159 sc = custom.success.fg(),
1160 w = custom.warning.fg(),
1161 );
1162 let marker = if active == "custom" {
1163 " ◀ active"
1164 } else {
1165 ""
1166 };
1167 println!(" {preview} {b}{:<12}{r}{d}{marker}{r}", custom.name,);
1168 }
1169 }
1170 println!();
1171 println!(" {d}Set theme: lean-ctx theme set <name>{r}");
1172 println!();
1173 }
1174 "set" => {
1175 if args.len() < 2 {
1176 eprintln!("Usage: lean-ctx theme set <name>");
1177 std::process::exit(1);
1178 }
1179 let name = &args[1];
1180 if theme::from_preset(name).is_none() && name != "custom" {
1181 eprintln!(
1182 "Unknown theme '{name}'. Available: {}",
1183 theme::PRESET_NAMES.join(", ")
1184 );
1185 std::process::exit(1);
1186 }
1187 let mut cfg = config::Config::load();
1188 cfg.theme = name.to_string();
1189 match cfg.save() {
1190 Ok(()) => {
1191 let t = theme::load_theme(name);
1192 println!(" {sc}✓{r} Theme set to {b}{name}{r}", sc = t.success.fg(),);
1193 let preview = t.gradient_bar(0.75, 30);
1194 println!(" {preview}");
1195 }
1196 Err(e) => eprintln!("Error: {e}"),
1197 }
1198 }
1199 "export" => {
1200 let cfg = config::Config::load();
1201 let t = theme::load_theme(&cfg.theme);
1202 println!("{}", t.to_toml());
1203 }
1204 "import" => {
1205 if args.len() < 2 {
1206 eprintln!("Usage: lean-ctx theme import <path>");
1207 std::process::exit(1);
1208 }
1209 let path = std::path::Path::new(&args[1]);
1210 if !path.exists() {
1211 eprintln!("File not found: {}", args[1]);
1212 std::process::exit(1);
1213 }
1214 match std::fs::read_to_string(path) {
1215 Ok(content) => match toml::from_str::<theme::Theme>(&content) {
1216 Ok(imported) => match theme::save_theme(&imported) {
1217 Ok(()) => {
1218 let mut cfg = config::Config::load();
1219 cfg.theme = "custom".to_string();
1220 let _ = cfg.save();
1221 println!(
1222 " {sc}✓{r} Imported theme '{name}' → ~/.lean-ctx/theme.toml",
1223 sc = imported.success.fg(),
1224 name = imported.name,
1225 );
1226 println!(" Config updated: theme = custom");
1227 }
1228 Err(e) => eprintln!("Error saving theme: {e}"),
1229 },
1230 Err(e) => eprintln!("Invalid theme file: {e}"),
1231 },
1232 Err(e) => eprintln!("Error reading file: {e}"),
1233 }
1234 }
1235 "preview" => {
1236 let name = args.get(1).map(|s| s.as_str()).unwrap_or("default");
1237 let t = match theme::from_preset(name) {
1238 Some(t) => t,
1239 None => {
1240 eprintln!("Unknown theme: {name}");
1241 std::process::exit(1);
1242 }
1243 };
1244 println!();
1245 println!(
1246 " {icon} {title} {d}Theme Preview: {name}{r}",
1247 icon = t.header_icon(),
1248 title = t.brand_title(),
1249 );
1250 println!(" {ln}", ln = t.border_line(50));
1251 println!();
1252 println!(
1253 " {b}{sc} 1.2M {r} {b}{sec} 87.3% {r} {b}{wrn} 4,521 {r} {b}{acc} $12.50 {r}",
1254 sc = t.success.fg(),
1255 sec = t.secondary.fg(),
1256 wrn = t.warning.fg(),
1257 acc = t.accent.fg(),
1258 );
1259 println!(" {d} tokens saved compression commands USD saved{r}");
1260 println!();
1261 println!(
1262 " {b}{txt}Gradient Bar{r} {bar}",
1263 txt = t.text.fg(),
1264 bar = t.gradient_bar(0.85, 30),
1265 );
1266 println!(
1267 " {b}{txt}Sparkline{r} {spark}",
1268 txt = t.text.fg(),
1269 spark = t.gradient_sparkline(&[20, 40, 30, 80, 60, 90, 70]),
1270 );
1271 println!();
1272 println!(" {top}", top = t.box_top(50));
1273 println!(
1274 " {side} {b}{txt}Box content with themed borders{r} {side_r}",
1275 side = t.box_side(),
1276 side_r = t.box_side(),
1277 txt = t.text.fg(),
1278 );
1279 println!(" {bot}", bot = t.box_bottom(50));
1280 println!();
1281 }
1282 _ => {
1283 eprintln!("Usage: lean-ctx theme [list|set|export|import|preview]");
1284 std::process::exit(1);
1285 }
1286 }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292
1293 #[test]
1294 fn test_remove_lean_ctx_block_posix() {
1295 let input = r#"# existing config
1296export PATH="$HOME/bin:$PATH"
1297
1298# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1299if [ -z "$LEAN_CTX_ACTIVE" ]; then
1300alias git='lean-ctx -c git'
1301alias npm='lean-ctx -c npm'
1302fi
1303
1304# other stuff
1305export EDITOR=vim
1306"#;
1307 let result = remove_lean_ctx_block(input);
1308 assert!(!result.contains("lean-ctx"), "block should be removed");
1309 assert!(result.contains("export PATH"), "other content preserved");
1310 assert!(
1311 result.contains("export EDITOR"),
1312 "trailing content preserved"
1313 );
1314 }
1315
1316 #[test]
1317 fn test_remove_lean_ctx_block_fish() {
1318 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";
1319 let result = remove_lean_ctx_block(input);
1320 assert!(!result.contains("lean-ctx"), "block should be removed");
1321 assert!(result.contains("set -x FOO"), "other content preserved");
1322 assert!(result.contains("set -x BAZ"), "trailing content preserved");
1323 }
1324
1325 #[test]
1326 fn test_remove_lean_ctx_block_ps() {
1327 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";
1328 let result = remove_lean_ctx_block_ps(input);
1329 assert!(
1330 !result.contains("lean-ctx shell hook"),
1331 "block should be removed"
1332 );
1333 assert!(result.contains("$env:FOO"), "other content preserved");
1334 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1335 }
1336
1337 #[test]
1338 fn test_remove_block_no_lean_ctx() {
1339 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1340 let result = remove_lean_ctx_block(input);
1341 assert!(result.contains("export PATH"), "content unchanged");
1342 }
1343
1344 #[test]
1345 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1346 let input = r#"# existing config
1347export PATH="$HOME/bin:$PATH"
1348
1349# lean-ctx shell hook — transparent CLI compression (90+ patterns)
1350_lean_ctx_cmds=(git npm pnpm)
1351
1352lean-ctx-on() {
1353 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1354 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1355 done
1356 export LEAN_CTX_ENABLED=1
1357 echo "lean-ctx: ON"
1358}
1359
1360lean-ctx-off() {
1361 unset LEAN_CTX_ENABLED
1362 echo "lean-ctx: OFF"
1363}
1364
1365if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1366 lean-ctx-on
1367fi
1368# lean-ctx shell hook — end
1369
1370# other stuff
1371export EDITOR=vim
1372"#;
1373 let result = remove_lean_ctx_block(input);
1374 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1375 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1376 assert!(result.contains("export PATH"), "other content preserved");
1377 assert!(
1378 result.contains("export EDITOR"),
1379 "trailing content preserved"
1380 );
1381 }
1382}