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