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            "token-report" | "report-tokens" => {
91                let code = token_report::run_cli(&rest);
92                if code != 0 {
93                    std::process::exit(code);
94                }
95                return;
96            }
97            "pack" => {
98                crate::cli::cmd_pack(&rest);
99                return;
100            }
101            "proof" => {
102                crate::cli::cmd_proof(&rest);
103                return;
104            }
105            "verify" => {
106                crate::cli::cmd_verify(&rest);
107                return;
108            }
109            "audit" => {
110                println!("{}", crate::cli::audit_report::generate_report());
111                return;
112            }
113            "instructions" => {
114                crate::cli::cmd_instructions(&rest);
115                return;
116            }
117            "index" => {
118                crate::cli::cmd_index(&rest);
119                return;
120            }
121            "cep" => {
122                println!("{}", tools::ctx_gain::handle("score", None, None, Some(10)));
123                return;
124            }
125            "dashboard" => {
126                cmd_dashboard(&rest);
127                return;
128            }
129            "team" => {
130                cmd_team(&rest);
131                return;
132            }
133            "provider" => {
134                cmd_provider(&rest);
135                return;
136            }
137            "serve" => {
138                cmd_serve(&rest);
139                return;
140            }
141            "watch" => {
142                cmd_watch(&rest);
143                return;
144            }
145            "proxy" => {
146                cmd_proxy(&rest);
147                return;
148            }
149            "daemon" => {
150                cmd_daemon(&rest);
151                return;
152            }
153            "init" => {
154                super::cmd_init(&rest);
155                return;
156            }
157            "setup" => {
158                let non_interactive = rest.iter().any(|a| a == "--non-interactive");
159                let yes = rest.iter().any(|a| a == "--yes" || a == "-y");
160                let fix = rest.iter().any(|a| a == "--fix");
161                let json = rest.iter().any(|a| a == "--json");
162                let no_auto_approve = rest.iter().any(|a| a == "--no-auto-approve");
163
164                if non_interactive || fix || json || yes {
165                    let opts = setup::SetupOptions {
166                        non_interactive,
167                        yes,
168                        fix,
169                        json,
170                        no_auto_approve,
171                        ..Default::default()
172                    };
173                    match setup::run_setup_with_options(opts) {
174                        Ok(report) => {
175                            if json {
176                                println!(
177                                    "{}",
178                                    serde_json::to_string_pretty(&report)
179                                        .unwrap_or_else(|_| "{}".to_string())
180                                );
181                            }
182                            if !report.success {
183                                std::process::exit(1);
184                            }
185                        }
186                        Err(e) => {
187                            eprintln!("{e}");
188                            std::process::exit(1);
189                        }
190                    }
191                } else {
192                    setup::run_setup();
193                }
194                return;
195            }
196            "install" => {
197                let repair = rest.iter().any(|a| a == "--repair" || a == "--fix");
198                let json = rest.iter().any(|a| a == "--json");
199                if !repair {
200                    eprintln!("Usage: lean-ctx install --repair [--json]");
201                    std::process::exit(1);
202                }
203                let opts = setup::SetupOptions {
204                    non_interactive: true,
205                    yes: true,
206                    fix: true,
207                    json,
208                    ..Default::default()
209                };
210                match setup::run_setup_with_options(opts) {
211                    Ok(report) => {
212                        if json {
213                            println!(
214                                "{}",
215                                serde_json::to_string_pretty(&report)
216                                    .unwrap_or_else(|_| "{}".to_string())
217                            );
218                        }
219                        if !report.success {
220                            std::process::exit(1);
221                        }
222                    }
223                    Err(e) => {
224                        eprintln!("{e}");
225                        std::process::exit(1);
226                    }
227                }
228                return;
229            }
230            "bootstrap" => {
231                let json = rest.iter().any(|a| a == "--json");
232                let opts = setup::SetupOptions {
233                    non_interactive: true,
234                    yes: true,
235                    fix: true,
236                    json,
237                    ..Default::default()
238                };
239                match setup::run_setup_with_options(opts) {
240                    Ok(report) => {
241                        if json {
242                            println!(
243                                "{}",
244                                serde_json::to_string_pretty(&report)
245                                    .unwrap_or_else(|_| "{}".to_string())
246                            );
247                        }
248                        if !report.success {
249                            std::process::exit(1);
250                        }
251                    }
252                    Err(e) => {
253                        eprintln!("{e}");
254                        std::process::exit(1);
255                    }
256                }
257                return;
258            }
259            "status" => {
260                let code = status::run_cli(&rest);
261                if code != 0 {
262                    std::process::exit(code);
263                }
264                return;
265            }
266            "read" => {
267                super::cmd_read(&rest);
268                core::stats::flush();
269                return;
270            }
271            "diff" => {
272                super::cmd_diff(&rest);
273                core::stats::flush();
274                return;
275            }
276            "grep" => {
277                super::cmd_grep(&rest);
278                core::stats::flush();
279                return;
280            }
281            "find" => {
282                super::cmd_find(&rest);
283                core::stats::flush();
284                return;
285            }
286            "ls" => {
287                super::cmd_ls(&rest);
288                core::stats::flush();
289                return;
290            }
291            "deps" => {
292                super::cmd_deps(&rest);
293                core::stats::flush();
294                return;
295            }
296            "discover" => {
297                super::cmd_discover(&rest);
298                return;
299            }
300            "ghost" => {
301                super::cmd_ghost(&rest);
302                return;
303            }
304            "filter" => {
305                super::cmd_filter(&rest);
306                return;
307            }
308            "heatmap" => {
309                heatmap::cmd_heatmap(&rest);
310                return;
311            }
312            "graph" => {
313                cmd_graph(&rest);
314                return;
315            }
316            "smells" => {
317                cmd_smells(&rest);
318                return;
319            }
320            "session" => {
321                super::cmd_session_action(&rest);
322                return;
323            }
324            "ledger" => {
325                super::cmd_ledger(&rest);
326                return;
327            }
328            "control" | "context-control" => {
329                super::cmd_control(&rest);
330                return;
331            }
332            "plan" | "context-plan" => {
333                super::cmd_plan(&rest);
334                return;
335            }
336            "compile" | "context-compile" => {
337                super::cmd_compile(&rest);
338                return;
339            }
340            "knowledge" => {
341                super::cmd_knowledge(&rest);
342                return;
343            }
344            "overview" => {
345                super::cmd_overview(&rest);
346                return;
347            }
348            "compress" => {
349                super::cmd_compress(&rest);
350                return;
351            }
352            "wrapped" => {
353                super::cmd_wrapped(&rest);
354                return;
355            }
356            "sessions" => {
357                super::cmd_sessions(&rest);
358                return;
359            }
360            "benchmark" => {
361                super::cmd_benchmark(&rest);
362                return;
363            }
364            "compact" => {
365                cmd_compact(&rest);
366                return;
367            }
368            "profile" => {
369                super::cmd_profile(&rest);
370                return;
371            }
372            "config" => {
373                super::cmd_config(&rest);
374                return;
375            }
376            "stats" => {
377                super::cmd_stats(&rest);
378                return;
379            }
380            "cache" => {
381                super::cmd_cache(&rest);
382                return;
383            }
384            "theme" => {
385                super::cmd_theme(&rest);
386                return;
387            }
388            "tee" => {
389                super::cmd_tee(&rest);
390                return;
391            }
392            "terse" | "compression" => {
393                super::cmd_compression(&rest);
394                return;
395            }
396            "slow-log" => {
397                super::cmd_slow_log(&rest);
398                return;
399            }
400            "update" | "--self-update" => {
401                core::updater::run(&rest);
402                return;
403            }
404            "restart" => {
405                cmd_restart();
406                return;
407            }
408            "stop" => {
409                cmd_stop();
410                return;
411            }
412            "dev-install" => {
413                cmd_dev_install();
414                return;
415            }
416            "doctor" => {
417                let code = doctor::run_cli(&rest);
418                if code != 0 {
419                    std::process::exit(code);
420                }
421                return;
422            }
423            "harden" => {
424                super::harden::run(&rest);
425                return;
426            }
427            "export-rules" => {
428                super::export_rules::run(&rest);
429                return;
430            }
431            "gotchas" | "bugs" => {
432                super::cloud::cmd_gotchas(&rest);
433                return;
434            }
435            "learn" => {
436                super::cmd_learn(&rest);
437                return;
438            }
439            "buddy" | "pet" => {
440                super::cloud::cmd_buddy(&rest);
441                return;
442            }
443            "hook" => {
444                hook_handlers::mark_hook_environment();
445                hook_handlers::arm_watchdog(std::time::Duration::from_secs(5));
446                let action = rest.first().map_or("help", std::string::String::as_str);
447                match action {
448                    "rewrite" => hook_handlers::handle_rewrite(),
449                    "redirect" => hook_handlers::handle_redirect(),
450                    "observe" => hook_handlers::handle_observe(),
451                    "copilot" => hook_handlers::handle_copilot(),
452                    "codex-pretooluse" => hook_handlers::handle_codex_pretooluse(),
453                    "codex-session-start" => hook_handlers::handle_codex_session_start(),
454                    "rewrite-inline" => hook_handlers::handle_rewrite_inline(),
455                    _ => {
456                        eprintln!("Usage: lean-ctx hook <rewrite|redirect|observe|copilot|codex-pretooluse|codex-session-start|rewrite-inline>");
457                        eprintln!("  Internal commands used by agent hooks (Claude, Cursor, Copilot, etc.)");
458                        std::process::exit(1);
459                    }
460                }
461                return;
462            }
463            "report-issue" | "report" => {
464                report::run(&rest);
465                return;
466            }
467            "uninstall" => {
468                let dry_run = rest.iter().any(|a| a == "--dry-run");
469                let keep_config = rest.iter().any(|a| a == "--keep-config");
470                uninstall::run(dry_run, keep_config);
471                return;
472            }
473            "bypass" => {
474                if rest.is_empty() {
475                    eprintln!("Usage: lean-ctx bypass \"command\"");
476                    eprintln!("Runs the command with zero compression (raw passthrough).");
477                    std::process::exit(1);
478                }
479                let command = if rest.len() == 1 {
480                    rest[0].clone()
481                } else {
482                    shell::join_command(&args[2..])
483                };
484                std::env::set_var("LEAN_CTX_RAW", "1");
485                let code = shell::exec(&command);
486                std::process::exit(code);
487            }
488            "safety-levels" | "safety" => {
489                println!("{}", core::compression_safety::format_safety_table());
490                return;
491            }
492            "cheat" | "cheatsheet" | "cheat-sheet" => {
493                super::cmd_cheatsheet();
494                return;
495            }
496            "login" => {
497                super::cloud::cmd_login(&rest);
498                return;
499            }
500            "register" => {
501                super::cloud::cmd_register(&rest);
502                return;
503            }
504            "forgot-password" => {
505                super::cloud::cmd_forgot_password(&rest);
506                return;
507            }
508            "sync" => {
509                super::cloud::cmd_sync();
510                return;
511            }
512            "contribute" => {
513                super::cloud::cmd_contribute();
514                return;
515            }
516            "cloud" => {
517                super::cloud::cmd_cloud(&rest);
518                return;
519            }
520            "upgrade" => {
521                super::cloud::cmd_upgrade();
522                return;
523            }
524            "--version" | "-V" => {
525                println!("{}", core::integrity::origin_line());
526                return;
527            }
528            "--help" | "-h" => {
529                print_help();
530                return;
531            }
532            "mcp" => {}
533            _ => {
534                tracing::error!("lean-ctx: unknown command '{}'", args[1]);
535                print_help();
536                std::process::exit(1);
537            }
538        }
539    }
540
541    // Bare `lean-ctx` in an interactive terminal: a human almost certainly did
542    // not mean to start a silent stdio MCP server (which just hangs waiting for
543    // JSON-RPC). Show a short quickstart instead. MCP clients pipe stdin (not a
544    // TTY) so they still get the server, and explicit `lean-ctx mcp` always
545    // serves regardless of TTY.
546    if args.len() == 1 && std::io::IsTerminal::is_terminal(&std::io::stdin()) {
547        print_quickstart();
548        return;
549    }
550
551    if let Err(e) = run_mcp_server() {
552        tracing::error!("lean-ctx: {e}");
553        std::process::exit(1);
554    }
555}
556
557fn passthrough(command: &str) -> ! {
558    let (shell, flag) = shell::shell_and_flag();
559    let mut cmd = std::process::Command::new(&shell);
560    cmd.arg(&flag).arg(command).env("LEAN_CTX_ACTIVE", "1");
561    shell::platform::apply_utf8_locale(&mut cmd);
562    let status = cmd.status().map_or(127, |s| s.code().unwrap_or(1));
563    std::process::exit(status);
564}
565
566pub(super) fn run_async<F: std::future::Future>(future: F) -> F::Output {
567    tokio::runtime::Runtime::new()
568        .expect("failed to create async runtime")
569        .block_on(future)
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use serial_test::serial;
576
577    #[test]
578    fn quickstart_is_short_and_points_to_setup() {
579        let q = quickstart_text();
580        assert!(
581            q.contains("lean-ctx setup"),
582            "quickstart must point to setup"
583        );
584        assert!(q.contains("--help"), "quickstart must point to full help");
585        // Must stay a *quickstart*, not the full reference — keep it tight.
586        assert!(
587            q.lines().count() <= 16,
588            "quickstart should be short; got {} lines",
589            q.lines().count()
590        );
591        assert!(
592            !q.contains("COMMANDS:"),
593            "quickstart must not inline the full command reference"
594        );
595    }
596
597    #[test]
598    fn capability_banner_tool_count_matches_registry() {
599        let n = crate::server::registry::tool_count();
600        let banner = capability_banner();
601        assert!(
602            banner.contains(&format!("{n} MCP tools")),
603            "banner must show the live registry count ({n}); got: {banner}"
604        );
605    }
606
607    #[test]
608    #[serial]
609    fn worker_threads_default_clamps_low() {
610        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
611        assert_eq!(resolve_worker_threads(1), 1);
612    }
613
614    #[test]
615    #[serial]
616    fn worker_threads_default_clamps_high() {
617        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
618        assert_eq!(resolve_worker_threads(32), 4);
619    }
620
621    #[test]
622    #[serial]
623    fn worker_threads_default_passthrough() {
624        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
625        assert_eq!(resolve_worker_threads(3), 3);
626    }
627
628    #[test]
629    #[serial]
630    fn worker_threads_env_override() {
631        std::env::set_var("LEAN_CTX_WORKER_THREADS", "12");
632        assert_eq!(resolve_worker_threads(2), 12);
633        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
634    }
635
636    #[test]
637    #[serial]
638    fn worker_threads_env_invalid_falls_back() {
639        std::env::set_var("LEAN_CTX_WORKER_THREADS", "not_a_number");
640        assert_eq!(resolve_worker_threads(3), 3);
641        std::env::remove_var("LEAN_CTX_WORKER_THREADS");
642    }
643}