Skip to main content

lean_ctx/cli/dispatch/
mod.rs

1use 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    // On Linux, if the binary was replaced while running, systemd may write
27    // the path with " (deleted)" suffix into ExecStart, causing "(deleted)"
28    // to appear as an argument. Strip it defensively.
29    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                // Plain `lean-ctx install` is a natural thing to type after
220                // installing the binary — treat it as the guided setup rather
221                // than failing with a usage error. `--repair`/`--fix` keeps the
222                // non-interactive, merge-based repair path.
223                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                // Canonical, unambiguous entry point for MCP *tool* profiles
400                // (how many tools the agent sees). Disambiguates from
401                // `lean-ctx profile`, which manages *context* profiles.
402                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    // Bare `lean-ctx` in an interactive terminal: a human almost certainly did
596    // not mean to start a silent stdio MCP server (which just hangs waiting for
597    // JSON-RPC). Show a short quickstart instead. MCP clients pipe stdin (not a
598    // TTY) so they still get the server, and explicit `lean-ctx mcp` always
599    // serves regardless of TTY.
600    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        // Must stay a *quickstart*, not the full reference — keep it tight.
640        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        // Concise means concise — keep it well under the full reference.
664        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}