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