Skip to main content

lean_ctx/cli/dispatch/
mod.rs

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