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            "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                // Plain `lean-ctx install` is a natural thing to type after
219                // installing the binary — treat it as the guided setup rather
220                // than failing with a usage error. `--repair`/`--fix` keeps the
221                // non-interactive, merge-based repair path.
222                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                // Canonical, unambiguous entry point for MCP *tool* profiles
399                // (how many tools the agent sees). Disambiguates from
400                // `lean-ctx profile`, which manages *context* profiles.
401                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    // Bare `lean-ctx` in an interactive terminal: a human almost certainly did
599    // not mean to start a silent stdio MCP server (which just hangs waiting for
600    // JSON-RPC). Show a short quickstart instead. MCP clients pipe stdin (not a
601    // TTY) so they still get the server, and explicit `lean-ctx mcp` always
602    // serves regardless of TTY.
603    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        // Must stay a *quickstart*, not the full reference — keep it tight.
643        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        // Concise means concise — keep it well under the full reference.
667        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}