lean_ctx/cli/dispatch/
mod.rs1use crate::{
2 core, doctor, heatmap, hook_handlers, report, setup, shell, status, token_report, uninstall,
3};
4
5mod analytics;
6mod help;
7mod lifecycle;
8mod network;
9mod server;
10
11#[allow(clippy::wildcard_imports)]
12use analytics::*;
13#[allow(clippy::wildcard_imports)]
14use help::*;
15#[allow(clippy::wildcard_imports)]
16use lifecycle::*;
17#[allow(clippy::wildcard_imports)]
18use network::*;
19#[allow(clippy::wildcard_imports)]
20use server::*;
21
22pub fn run() {
23 let mut args: Vec<String> = std::env::args().collect();
24
25 if args.get(1).is_some_and(|a| a == "(deleted)") {
29 args.remove(1);
30 }
31
32 let enters_mcp = args.len() == 1 || args.get(1).is_some_and(|a| a == "mcp");
33 if !enters_mcp {
34 crate::core::logging::init_logging();
35 }
36
37 if args.len() > 1 {
38 let rest = args[2..].to_vec();
39
40 match args[1].as_str() {
41 "-c" | "exec" => {
42 let raw = rest.first().is_some_and(|a| a == "--raw");
43 let cmd_args = if raw { &args[3..] } else { &args[2..] };
44 let command = if cmd_args.len() == 1 {
45 cmd_args[0].clone()
46 } else {
47 shell::join_command(cmd_args)
48 };
49 if std::env::var("LEAN_CTX_ACTIVE").is_ok()
50 || std::env::var("LEAN_CTX_DISABLED").is_ok()
51 {
52 passthrough(&command);
53 }
54 if raw {
55 std::env::set_var("LEAN_CTX_RAW", "1");
56 } else {
57 std::env::set_var("LEAN_CTX_COMPRESS", "1");
58 }
59 let code = shell::exec(&command);
60 core::stats::flush();
61 core::heatmap::flush();
62 std::process::exit(code);
63 }
64 "-t" | "--track" => {
65 let cmd_args = &args[2..];
66 let code = if cmd_args.len() > 1 {
67 shell::exec_argv(cmd_args)
68 } else {
69 let command = cmd_args[0].clone();
70 if std::env::var("LEAN_CTX_ACTIVE").is_ok()
71 || std::env::var("LEAN_CTX_DISABLED").is_ok()
72 {
73 passthrough(&command);
74 }
75 shell::exec(&command)
76 };
77 core::stats::flush();
78 core::heatmap::flush();
79 std::process::exit(code);
80 }
81 "shell" | "--shell" => {
82 shell::interactive();
83 return;
84 }
85 "gain" => {
86 cmd_gain(&rest);
87 return;
88 }
89 "savings" => {
90 cmd_savings(&rest);
91 return;
92 }
93 "token-report" | "report-tokens" => {
94 let code = token_report::run_cli(&rest);
95 if code != 0 {
96 std::process::exit(code);
97 }
98 return;
99 }
100 "pack" => {
101 crate::cli::cmd_pack(&rest);
102 return;
103 }
104 "plugin" | "plugins" => {
105 crate::cli::plugin_cmd::cmd_plugin(&rest);
106 return;
107 }
108 "rules" => {
109 crate::cli::rules_cmd::cmd_rules(&rest);
110 return;
111 }
112 "proof" => {
113 crate::cli::cmd_proof(&rest);
114 return;
115 }
116 "verify" => {
117 crate::cli::cmd_verify(&rest);
118 return;
119 }
120 "visualize" => {
121 super::cmd_visualize(&rest);
122 return;
123 }
124 "audit" => {
125 println!("{}", crate::cli::audit_report::generate_report());
126 return;
127 }
128 "instructions" => {
129 crate::cli::cmd_instructions(&rest);
130 return;
131 }
132 "index" => {
133 crate::cli::cmd_index(&rest);
134 return;
135 }
136 "semantic-search" | "search-code" => {
137 crate::cli::cmd_semantic_search(&rest);
138 core::stats::flush();
139 return;
140 }
141 "repomap" | "repo-map" => {
142 crate::cli::cmd_repomap(&rest);
143 core::stats::flush();
144 return;
145 }
146 "cep" => {
147 println!("{}", core::stats::format_cep_report());
148 return;
149 }
150 "dashboard" => {
151 cmd_dashboard(&rest);
152 return;
153 }
154 "team" => {
155 cmd_team(&rest);
156 return;
157 }
158 "provider" => {
159 cmd_provider(&rest);
160 return;
161 }
162 "serve" => {
163 cmd_serve(&rest);
164 return;
165 }
166 "watch" => {
167 cmd_watch(&rest);
168 return;
169 }
170 "proxy" => {
171 cmd_proxy(&rest);
172 return;
173 }
174 "daemon" => {
175 cmd_daemon(&rest);
176 return;
177 }
178 "init" => {
179 super::cmd_init(&rest);
180 return;
181 }
182 "setup" => {
183 let non_interactive = rest.iter().any(|a| a == "--non-interactive");
184 let yes = rest.iter().any(|a| a == "--yes" || a == "-y");
185 let fix = rest.iter().any(|a| a == "--fix");
186 let json = rest.iter().any(|a| a == "--json");
187 let no_auto_approve = rest.iter().any(|a| a == "--no-auto-approve");
188 let skip_rules = rest.iter().any(|a| a == "--skip-rules");
189
190 if non_interactive || fix || json || yes {
191 let opts = setup::SetupOptions {
192 non_interactive,
193 yes,
194 fix,
195 json,
196 no_auto_approve,
197 skip_rules,
198 ..Default::default()
199 };
200 match setup::run_setup_with_options(opts) {
201 Ok(report) => {
202 if json {
203 println!(
204 "{}",
205 serde_json::to_string_pretty(&report)
206 .unwrap_or_else(|_| "{}".to_string())
207 );
208 }
209 if !report.success {
210 std::process::exit(1);
211 }
212 }
213 Err(e) => {
214 eprintln!("{e}");
215 std::process::exit(1);
216 }
217 }
218 } else {
219 setup::run_setup();
220 }
221 return;
222 }
223 "onboard" => {
224 setup::run_onboard();
225 return;
226 }
227 "install" => {
228 let repair = rest.iter().any(|a| a == "--repair" || a == "--fix");
233 let json = rest.iter().any(|a| a == "--json");
234 if !repair {
235 setup::run_setup();
236 return;
237 }
238 let opts = setup::SetupOptions {
239 non_interactive: true,
240 yes: true,
241 fix: true,
242 json,
243 ..Default::default()
244 };
245 match setup::run_setup_with_options(opts) {
246 Ok(report) => {
247 if json {
248 println!(
249 "{}",
250 serde_json::to_string_pretty(&report)
251 .unwrap_or_else(|_| "{}".to_string())
252 );
253 }
254 if !report.success {
255 std::process::exit(1);
256 }
257 }
258 Err(e) => {
259 eprintln!("{e}");
260 std::process::exit(1);
261 }
262 }
263 return;
264 }
265 "bootstrap" => {
266 let json = rest.iter().any(|a| a == "--json");
267 let opts = setup::SetupOptions {
268 non_interactive: true,
269 yes: true,
270 fix: true,
271 json,
272 ..Default::default()
273 };
274 match setup::run_setup_with_options(opts) {
275 Ok(report) => {
276 if json {
277 println!(
278 "{}",
279 serde_json::to_string_pretty(&report)
280 .unwrap_or_else(|_| "{}".to_string())
281 );
282 }
283 if !report.success {
284 std::process::exit(1);
285 }
286 }
287 Err(e) => {
288 eprintln!("{e}");
289 std::process::exit(1);
290 }
291 }
292 return;
293 }
294 "status" => {
295 let code = status::run_cli(&rest);
296 if code != 0 {
297 std::process::exit(code);
298 }
299 return;
300 }
301 "read" => {
302 super::cmd_read(&rest);
303 core::stats::flush();
304 return;
305 }
306 "diff" => {
307 super::cmd_diff(&rest);
308 core::stats::flush();
309 return;
310 }
311 "grep" => {
312 super::cmd_grep(&rest);
313 core::stats::flush();
314 return;
315 }
316 "find" => {
317 super::cmd_find(&rest);
318 core::stats::flush();
319 return;
320 }
321 "ls" => {
322 super::cmd_ls(&rest);
323 core::stats::flush();
324 return;
325 }
326 "deps" => {
327 super::cmd_deps(&rest);
328 core::stats::flush();
329 return;
330 }
331 "discover" => {
332 super::cmd_discover(&rest);
333 return;
334 }
335 "ghost" => {
336 super::cmd_ghost(&rest);
337 return;
338 }
339 "filter" => {
340 super::cmd_filter(&rest);
341 return;
342 }
343 "heatmap" => {
344 heatmap::cmd_heatmap(&rest);
345 return;
346 }
347 "graph" => {
348 cmd_graph(&rest);
349 return;
350 }
351 "smells" => {
352 cmd_smells(&rest);
353 return;
354 }
355 "session" => {
356 super::cmd_session_action(&rest);
357 return;
358 }
359 "ledger" => {
360 super::cmd_ledger(&rest);
361 return;
362 }
363 "control" | "context-control" => {
364 super::cmd_control(&rest);
365 return;
366 }
367 "plan" | "context-plan" => {
368 super::cmd_plan(&rest);
369 return;
370 }
371 "compile" | "context-compile" => {
372 super::cmd_compile(&rest);
373 return;
374 }
375 "knowledge" => {
376 super::cmd_knowledge(&rest);
377 return;
378 }
379 "overview" => {
380 super::cmd_overview(&rest);
381 return;
382 }
383 "compress" => {
384 super::cmd_compress(&rest);
385 return;
386 }
387 "wrapped" => {
388 eprintln!("'lean-ctx wrapped' has been removed. Use: lean-ctx gain --wrapped");
389 std::process::exit(1);
390 }
391 "sessions" | "session-store" => {
392 super::cmd_sessions(&rest);
393 return;
394 }
395 "benchmark" => {
396 super::cmd_benchmark(&rest);
397 return;
398 }
399 "compact" => {
400 cmd_compact(&rest);
401 return;
402 }
403 "profile" => {
404 super::cmd_profile(&rest);
405 return;
406 }
407 "tools" => {
408 let mut forwarded = vec!["tools".to_string()];
412 forwarded.extend(rest.iter().cloned());
413 super::cmd_profile(&forwarded);
414 return;
415 }
416 "config" => {
417 super::cmd_config(&rest);
418 return;
419 }
420 "allow" => {
421 super::cmd_allow(&rest);
422 return;
423 }
424 "stats" => {
425 super::cmd_stats(&rest);
426 return;
427 }
428 "cache" => {
429 super::cmd_cache(&rest);
430 return;
431 }
432 "theme" => {
433 super::cmd_theme(&rest);
434 return;
435 }
436 "tee" => {
437 super::cmd_tee(&rest);
438 return;
439 }
440 "terse" | "compression" => {
441 super::cmd_compression(&rest);
442 return;
443 }
444 "slow-log" => {
445 super::cmd_slow_log(&rest);
446 return;
447 }
448 "update" | "--self-update" => {
449 core::updater::run(&rest);
450 return;
451 }
452 "restart" => {
453 cmd_restart();
454 return;
455 }
456 "stop" => {
457 cmd_stop();
458 return;
459 }
460 "dev-install" => {
461 cmd_dev_install();
462 return;
463 }
464 "doctor" => {
465 let code = doctor::run_cli(&rest);
466 if code != 0 {
467 std::process::exit(code);
468 }
469 return;
470 }
471 "harden" => {
472 super::harden::run(&rest);
473 return;
474 }
475 "export-rules" => {
476 super::export_rules::run(&rest);
477 return;
478 }
479 "gotchas" | "bugs" => {
480 super::cloud::cmd_gotchas(&rest);
481 return;
482 }
483 "learn" => {
484 super::cmd_learn(&rest);
485 return;
486 }
487 "buddy" | "pet" => {
488 super::cloud::cmd_buddy(&rest);
489 return;
490 }
491 "hook" => {
492 hook_handlers::mark_hook_environment();
493 hook_handlers::arm_watchdog(std::time::Duration::from_secs(5));
494 let action = rest.first().map_or("help", std::string::String::as_str);
495 match action {
496 "rewrite" => hook_handlers::handle_rewrite(),
497 "redirect" => hook_handlers::handle_redirect(),
498 "observe" => hook_handlers::handle_observe(),
499 "copilot" => hook_handlers::handle_copilot(),
500 "codex-pretooluse" => hook_handlers::handle_codex_pretooluse(),
501 "codex-session-start" => hook_handlers::handle_codex_session_start(),
502 "rewrite-inline" => hook_handlers::handle_rewrite_inline(),
503 _ => {
504 eprintln!("Usage: lean-ctx hook <rewrite|redirect|observe|copilot|codex-pretooluse|codex-session-start|rewrite-inline>");
505 eprintln!(" Internal commands used by agent hooks (Claude, Cursor, Copilot, etc.)");
506 std::process::exit(1);
507 }
508 }
509 return;
510 }
511 "report-issue" | "report" => {
512 report::run(&rest);
513 return;
514 }
515 "uninstall" => {
516 let dry_run = rest.iter().any(|a| a == "--dry-run");
517 let keep_config = rest.iter().any(|a| a == "--keep-config");
518 let keep_binary = rest.iter().any(|a| a == "--keep-binary");
519 uninstall::run(dry_run, keep_config, keep_binary);
520 return;
521 }
522 "bypass" => {
523 if rest.is_empty() {
524 eprintln!("Usage: lean-ctx bypass \"command\"");
525 eprintln!("Runs the command with zero compression (raw passthrough).");
526 std::process::exit(1);
527 }
528 let command = if rest.len() == 1 {
529 rest[0].clone()
530 } else {
531 shell::join_command(&args[2..])
532 };
533 std::env::set_var("LEAN_CTX_RAW", "1");
534 let code = shell::exec(&command);
535 std::process::exit(code);
536 }
537 "safety-levels" | "safety" => {
538 println!("{}", core::compression_safety::format_safety_table());
539 return;
540 }
541 "cheat" | "cheatsheet" | "cheat-sheet" => {
542 super::cmd_cheatsheet();
543 return;
544 }
545 "login" => {
546 super::cloud::cmd_login(&rest);
547 return;
548 }
549 "register" => {
550 super::cloud::cmd_register(&rest);
551 return;
552 }
553 "forgot-password" => {
554 super::cloud::cmd_forgot_password(&rest);
555 return;
556 }
557 "sync" => {
558 super::cloud::cmd_sync();
559 return;
560 }
561 "contribute" => {
562 super::cloud::cmd_contribute();
563 return;
564 }
565 "cloud" => {
566 super::cloud::cmd_cloud(&rest);
567 return;
568 }
569 "upgrade" => {
570 super::cloud::cmd_upgrade();
571 return;
572 }
573 "--version" | "-V" => {
574 println!("{}", core::integrity::origin_line());
575 return;
576 }
577 "help" => {
578 let want_all = rest
579 .iter()
580 .any(|a| matches!(a.as_str(), "all" | "full" | "--all" | "-a"));
581 if want_all {
582 print_help();
583 } else {
584 print_help_concise();
585 }
586 return;
587 }
588 "--help" | "-h" => {
589 if rest
590 .iter()
591 .any(|a| matches!(a.as_str(), "all" | "full" | "--all" | "-a"))
592 {
593 print_help();
594 } else {
595 print_help_concise();
596 }
597 return;
598 }
599 "mcp" => {}
600 _ => {
601 tracing::error!("lean-ctx: unknown command '{}'", args[1]);
602 print_help_concise();
603 std::process::exit(1);
604 }
605 }
606 }
607
608 if args.len() == 1 && std::io::IsTerminal::is_terminal(&std::io::stdin()) {
614 print_quickstart();
615 return;
616 }
617
618 if let Err(e) = run_mcp_server() {
619 tracing::error!("lean-ctx: {e}");
620 std::process::exit(1);
621 }
622}
623
624fn passthrough(command: &str) -> ! {
625 let (shell, flag) = shell::shell_and_flag();
626 let mut cmd = std::process::Command::new(&shell);
627 cmd.arg(&flag).arg(command).env("LEAN_CTX_ACTIVE", "1");
628 shell::platform::apply_utf8_locale(&mut cmd);
629 let status = cmd.status().map_or(127, |s| s.code().unwrap_or(1));
630 std::process::exit(status);
631}
632
633pub(super) fn run_async<F: std::future::Future>(future: F) -> F::Output {
634 tokio::runtime::Runtime::new()
635 .expect("failed to create async runtime")
636 .block_on(future)
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use serial_test::serial;
643
644 #[test]
645 fn quickstart_is_short_and_points_to_setup() {
646 let q = quickstart_text();
647 assert!(
648 q.contains("lean-ctx onboard"),
649 "quickstart must point to onboard"
650 );
651 assert!(q.contains("lean-ctx help"), "quickstart must point to help");
652 assert!(
654 q.lines().count() <= 16,
655 "quickstart should be short; got {} lines",
656 q.lines().count()
657 );
658 assert!(
659 !q.contains("COMMANDS:"),
660 "quickstart must not inline the full command reference"
661 );
662 }
663
664 #[test]
665 fn concise_help_is_short_and_points_to_full() {
666 let h = concise_help_text();
667 assert!(h.contains("lean-ctx onboard"), "must lead with onboard");
668 assert!(
669 h.contains("lean-ctx help all"),
670 "must point to full reference"
671 );
672 assert!(
673 h.contains("lean-ctx tools"),
674 "must surface the tools profile command"
675 );
676 assert!(
678 h.lines().count() <= 40,
679 "concise help should stay short; got {} lines",
680 h.lines().count()
681 );
682 assert!(
683 !h.contains("SHELL HOOK PATTERNS"),
684 "concise help must not inline the full pattern catalog"
685 );
686 }
687
688 #[test]
689 fn capability_banner_tool_count_matches_registry() {
690 let n = crate::server::registry::tool_count();
691 let banner = capability_banner();
692 assert!(
693 banner.contains(&format!("{n} MCP tools")),
694 "banner must show the live registry count ({n}); got: {banner}"
695 );
696 }
697
698 #[test]
699 #[serial]
700 fn worker_threads_default_clamps_low() {
701 std::env::remove_var("LEAN_CTX_WORKER_THREADS");
702 assert_eq!(resolve_worker_threads(1), 1);
703 }
704
705 #[test]
706 #[serial]
707 fn worker_threads_default_clamps_high() {
708 std::env::remove_var("LEAN_CTX_WORKER_THREADS");
709 assert_eq!(resolve_worker_threads(32), 4);
710 }
711
712 #[test]
713 #[serial]
714 fn worker_threads_default_passthrough() {
715 std::env::remove_var("LEAN_CTX_WORKER_THREADS");
716 assert_eq!(resolve_worker_threads(3), 3);
717 }
718
719 #[test]
720 #[serial]
721 fn worker_threads_env_override() {
722 std::env::set_var("LEAN_CTX_WORKER_THREADS", "12");
723 assert_eq!(resolve_worker_threads(2), 12);
724 std::env::remove_var("LEAN_CTX_WORKER_THREADS");
725 }
726
727 #[test]
728 #[serial]
729 fn worker_threads_env_invalid_falls_back() {
730 std::env::set_var("LEAN_CTX_WORKER_THREADS", "not_a_number");
731 assert_eq!(resolve_worker_threads(3), 3);
732 std::env::remove_var("LEAN_CTX_WORKER_THREADS");
733 }
734}