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