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;
10mod suggest;
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    if !is_server_mode(&args) {
34        restore_sigpipe_default();
35    }
36
37    let enters_mcp = args.len() == 1 || args.get(1).is_some_and(|a| a == "mcp");
38    if !enters_mcp {
39        crate::core::logging::init_logging();
40    }
41
42    if args.len() > 1 {
43        let rest = args[2..].to_vec();
44
45        match args[1].as_str() {
46            "-c" | "exec" => {
47                let raw = rest.first().is_some_and(|a| a == "--raw");
48                let cmd_args = if raw { &args[3..] } else { &args[2..] };
49                let command = if cmd_args.len() == 1 {
50                    cmd_args[0].clone()
51                } else {
52                    shell::join_command(cmd_args)
53                };
54                // The `lean-ctx -c` wrapper runs inside the agent shell, which
55                // carries runtime/session vars the MCP server never sees. Bridge
56                // them so ctx_shell can forward them too (#370).
57                core::agent_runtime_env::capture();
58                if crate::shell::reentry::should_pass_through() {
59                    passthrough(&command);
60                }
61                if raw {
62                    // SAFETY: CLI dispatch is single-threaded; this runs before any
63                    // worker threads start (the process hands off to shell::exec below).
64                    unsafe { std::env::set_var("LEAN_CTX_RAW", "1") };
65                } else {
66                    // SAFETY: CLI dispatch is single-threaded; this runs before any
67                    // worker threads start (the process hands off to shell::exec below).
68                    unsafe { std::env::set_var("LEAN_CTX_COMPRESS", "1") };
69                }
70                let code = shell::exec(&command);
71                core::tool_lifecycle::flush_all();
72                std::process::exit(code);
73            }
74            "-t" | "--track" => {
75                let cmd_args = &args[2..];
76                let code = if cmd_args.len() > 1 {
77                    shell::exec_argv(cmd_args)
78                } else {
79                    let command = cmd_args[0].clone();
80                    if crate::shell::reentry::should_pass_through() {
81                        passthrough(&command);
82                    }
83                    shell::exec(&command)
84                };
85                core::tool_lifecycle::flush_all();
86                std::process::exit(code);
87            }
88            "shell" | "--shell" => {
89                shell::interactive();
90                return;
91            }
92            "gain" => {
93                cmd_gain(&rest);
94                return;
95            }
96            "spend" => {
97                cmd_spend(&rest);
98                return;
99            }
100            "savings" => {
101                cmd_savings(&rest);
102                return;
103            }
104            "learning" => {
105                cmd_learning(&rest);
106                return;
107            }
108            "conformance" | "selftest" => {
109                cmd_conformance(&rest);
110                return;
111            }
112            "health" => {
113                let code = crate::cli::health_cmd::cmd_health(&rest);
114                if code != 0 {
115                    std::process::exit(code);
116                }
117                return;
118            }
119            "billing" => {
120                cmd_billing(&rest);
121                return;
122            }
123            "finops" => {
124                cmd_finops(&rest);
125                return;
126            }
127            "roi" => {
128                // Local ROI is individual + free. The team roll-up lives on its own
129                // surface (`savings team` / the web account), not under `roi`.
130                super::cmd_roi(&rest);
131                return;
132            }
133            "output-savings" | "output_savings" => {
134                // #895 Track B: measured (A/B holdout) or estimated output-token
135                // reduction. Local + free, like `roi`.
136                super::cmd_output_savings(&rest);
137                return;
138            }
139            "token-report" | "report-tokens" => {
140                let code = token_report::run_cli(&rest);
141                if code != 0 {
142                    std::process::exit(code);
143                }
144                return;
145            }
146            "pack" => {
147                crate::cli::cmd_pack(&rest);
148                return;
149            }
150            "policy" => {
151                crate::cli::cmd_policy(&rest);
152                return;
153            }
154            "plugin" | "plugins" => {
155                crate::cli::plugin_cmd::cmd_plugin(&rest);
156                return;
157            }
158            "addon" | "addons" => {
159                crate::cli::addon_cmd::cmd_addon(&rest);
160                return;
161            }
162            "rules" => {
163                crate::cli::rules_cmd::cmd_rules(&rest);
164                return;
165            }
166            "proof" => {
167                crate::cli::cmd_proof(&rest);
168                return;
169            }
170            "snapshot" => {
171                crate::cli::cmd_snapshot(&rest);
172                return;
173            }
174            "verify" => {
175                crate::cli::cmd_verify(&rest);
176                return;
177            }
178            "eval" => {
179                crate::cli::eval_cmd::cmd_eval(&rest);
180                return;
181            }
182            "verify-cache" | "cache-selftest" => {
183                let code = crate::cli::verify_cache_cmd::cmd_verify_cache(&rest);
184                if code != 0 {
185                    std::process::exit(code);
186                }
187                return;
188            }
189            "visualize" => {
190                super::cmd_visualize(&rest);
191                return;
192            }
193            "audit" => {
194                if rest.first().map(String::as_str) == Some("evidence") {
195                    crate::cli::audit_report::cmd_evidence(&rest[1..]);
196                } else {
197                    println!("{}", crate::cli::audit_report::generate_report());
198                }
199                return;
200            }
201            "compliance" => {
202                crate::cli::cmd_compliance(&rest);
203                return;
204            }
205            "agent" => {
206                crate::cli::cmd_agent(&rest);
207                return;
208            }
209            "instructions" => {
210                crate::cli::cmd_instructions(&rest);
211                return;
212            }
213            "index" => {
214                crate::cli::cmd_index(&rest);
215                return;
216            }
217            "semantic-search" | "search-code" => {
218                crate::cli::cmd_semantic_search(&rest);
219                core::stats::flush();
220                return;
221            }
222            "explore" => {
223                crate::cli::explore_cmd::cmd_explore(&rest);
224                core::stats::flush();
225                return;
226            }
227            "repomap" | "repo-map" => {
228                crate::cli::cmd_repomap(&rest);
229                core::stats::flush();
230                return;
231            }
232            "cep" => {
233                println!("{}", core::stats::format_cep_report());
234                return;
235            }
236            "dashboard" => {
237                cmd_dashboard(&rest);
238                return;
239            }
240            "team" => {
241                cmd_team(&rest);
242                return;
243            }
244            "provider" => {
245                cmd_provider(&rest);
246                return;
247            }
248            "serve" => {
249                cmd_serve(&rest);
250                return;
251            }
252            "watch" => {
253                cmd_watch(&rest);
254                return;
255            }
256            "proxy" => {
257                cmd_proxy(&rest);
258                return;
259            }
260            "daemon" => {
261                cmd_daemon(&rest);
262                return;
263            }
264            "init" => {
265                super::cmd_init(&rest);
266                return;
267            }
268            "setup" => {
269                let non_interactive = rest.iter().any(|a| a == "--non-interactive");
270                let yes = rest.iter().any(|a| a == "--yes" || a == "-y");
271                let fix = rest.iter().any(|a| a == "--fix");
272                let json = rest.iter().any(|a| a == "--json");
273                let no_auto_approve = rest.iter().any(|a| a == "--no-auto-approve");
274                let skip_rules = rest.iter().any(|a| a == "--skip-rules");
275
276                if non_interactive || fix || json || yes {
277                    let opts = setup::SetupOptions {
278                        non_interactive,
279                        yes,
280                        fix,
281                        json,
282                        no_auto_approve,
283                        skip_rules,
284                        ..Default::default()
285                    };
286                    match setup::run_setup_with_options(opts) {
287                        Ok(report) => {
288                            if json {
289                                println!(
290                                    "{}",
291                                    serde_json::to_string_pretty(&report)
292                                        .unwrap_or_else(|_| "{}".to_string())
293                                );
294                            }
295                            if !report.success {
296                                std::process::exit(1);
297                            }
298                        }
299                        Err(e) => {
300                            eprintln!("{e}");
301                            std::process::exit(1);
302                        }
303                    }
304                } else {
305                    setup::run_setup();
306                }
307                return;
308            }
309            "onboard" => {
310                setup::run_onboard();
311                return;
312            }
313            "install" => {
314                // Plain `lean-ctx install` is a natural thing to type after
315                // installing the binary — treat it as the guided setup rather
316                // than failing with a usage error. `--repair`/`--fix` keeps the
317                // non-interactive, merge-based repair path.
318                let repair = rest.iter().any(|a| a == "--repair" || a == "--fix");
319                let json = rest.iter().any(|a| a == "--json");
320                if !repair {
321                    setup::run_setup();
322                    return;
323                }
324                let opts = setup::SetupOptions {
325                    non_interactive: true,
326                    yes: true,
327                    fix: true,
328                    json,
329                    ..Default::default()
330                };
331                match setup::run_setup_with_options(opts) {
332                    Ok(report) => {
333                        if json {
334                            println!(
335                                "{}",
336                                serde_json::to_string_pretty(&report)
337                                    .unwrap_or_else(|_| "{}".to_string())
338                            );
339                        }
340                        if !report.success {
341                            std::process::exit(1);
342                        }
343                    }
344                    Err(e) => {
345                        eprintln!("{e}");
346                        std::process::exit(1);
347                    }
348                }
349                return;
350            }
351            "bootstrap" => {
352                let json = rest.iter().any(|a| a == "--json");
353                let opts = setup::SetupOptions {
354                    non_interactive: true,
355                    yes: true,
356                    fix: true,
357                    json,
358                    ..Default::default()
359                };
360                match setup::run_setup_with_options(opts) {
361                    Ok(report) => {
362                        if json {
363                            println!(
364                                "{}",
365                                serde_json::to_string_pretty(&report)
366                                    .unwrap_or_else(|_| "{}".to_string())
367                            );
368                        }
369                        if !report.success {
370                            std::process::exit(1);
371                        }
372                    }
373                    Err(e) => {
374                        eprintln!("{e}");
375                        std::process::exit(1);
376                    }
377                }
378                return;
379            }
380            "status" => {
381                let code = status::run_cli(&rest);
382                if code != 0 {
383                    std::process::exit(code);
384                }
385                return;
386            }
387            "read" => {
388                super::cmd_read(&rest);
389                core::tool_lifecycle::flush_all();
390                return;
391            }
392            "call" => {
393                super::cmd_call(&rest);
394                return;
395            }
396            "diff" => {
397                super::cmd_diff(&rest);
398                core::tool_lifecycle::flush_all();
399                return;
400            }
401            "grep" => {
402                super::cmd_grep(&rest);
403                core::tool_lifecycle::flush_all();
404                return;
405            }
406            "glob" => {
407                super::cmd_glob(&rest);
408                core::stats::flush();
409                return;
410            }
411            "find" => {
412                super::cmd_find(&rest);
413                core::tool_lifecycle::flush_all();
414                return;
415            }
416            "ls" => {
417                super::cmd_ls(&rest);
418                core::tool_lifecycle::flush_all();
419                return;
420            }
421            "deps" => {
422                super::cmd_deps(&rest);
423                core::tool_lifecycle::flush_all();
424                return;
425            }
426            "discover" => {
427                super::cmd_discover(&rest);
428                return;
429            }
430            "ghost" => {
431                super::cmd_ghost(&rest);
432                return;
433            }
434            "filter" => {
435                super::cmd_filter(&rest);
436                return;
437            }
438            "heatmap" => {
439                heatmap::cmd_heatmap(&rest);
440                return;
441            }
442            "graph" => {
443                cmd_graph(&rest);
444                return;
445            }
446            "smells" => {
447                cmd_smells(&rest);
448                return;
449            }
450            "session" => {
451                super::cmd_session_action(&rest);
452                return;
453            }
454            "ledger" => {
455                super::cmd_ledger(&rest);
456                return;
457            }
458            "control" | "context-control" => {
459                super::cmd_control(&rest);
460                return;
461            }
462            "plan" | "context-plan" => {
463                super::cmd_plan(&rest);
464                return;
465            }
466            "compile" | "context-compile" => {
467                super::cmd_compile(&rest);
468                return;
469            }
470            "knowledge" => {
471                super::cmd_knowledge(&rest);
472                return;
473            }
474            "skillify" => {
475                super::cmd_skillify(&rest);
476                return;
477            }
478            "summary" => {
479                super::cmd_summary(&rest);
480                return;
481            }
482            "overview" => {
483                super::cmd_overview(&rest);
484                return;
485            }
486            "compress" => {
487                super::cmd_compress(&rest);
488                return;
489            }
490            "wrapped" => {
491                eprintln!("'lean-ctx wrapped' has been removed. Use: lean-ctx gain --wrapped");
492                std::process::exit(1);
493            }
494            "sessions" | "session-store" => {
495                super::cmd_sessions(&rest);
496                return;
497            }
498            "benchmark" => {
499                super::cmd_benchmark(&rest);
500                return;
501            }
502            "compact" => {
503                cmd_compact(&rest);
504                return;
505            }
506            "profile" => {
507                super::cmd_profile(&rest);
508                return;
509            }
510            "tools" => {
511                // `tools health` is the token-budget / rot report (#848); it is
512                // distinct from tool *profiles* and routed before the forward.
513                if rest.first().map(String::as_str) == Some("health") {
514                    super::cmd_tools_health(&rest[1..]);
515                    return;
516                }
517                // Canonical, unambiguous entry point for MCP *tool* profiles
518                // (how many tools the agent sees). Disambiguates from
519                // `lean-ctx profile`, which manages *context* profiles.
520                let mut forwarded = vec!["tools".to_string()];
521                forwarded.extend(rest.iter().cloned());
522                super::cmd_profile(&forwarded);
523                return;
524            }
525            "config" => {
526                super::cmd_config(&rest);
527                return;
528            }
529            "allow" => {
530                super::cmd_allow(&rest);
531                return;
532            }
533            "security" => {
534                super::cmd_security(&rest);
535                return;
536            }
537            "yolo" => {
538                super::cmd_yolo(&rest);
539                return;
540            }
541            "secure" | "lockdown" => {
542                super::cmd_secure(&rest);
543                return;
544            }
545            "trust" => {
546                super::cmd_trust(&rest);
547                return;
548            }
549            "untrust" => {
550                super::cmd_untrust(&rest);
551                return;
552            }
553            "stats" => {
554                super::cmd_stats(&rest);
555                return;
556            }
557            "introspect" => {
558                super::cmd_introspect(&rest);
559                return;
560            }
561            "cache" => {
562                super::cmd_cache(&rest);
563                return;
564            }
565            "theme" => {
566                super::cmd_theme(&rest);
567                return;
568            }
569            "tee" => {
570                super::cmd_tee(&rest);
571                return;
572            }
573            "terse" | "compression" => {
574                super::cmd_compression(&rest);
575                return;
576            }
577            "slow-log" => {
578                super::cmd_slow_log(&rest);
579                return;
580            }
581            "debug-log" => {
582                super::cmd_debug_log(&rest);
583                return;
584            }
585            // Editor focus ingress (#500): called by the VS Code extension on
586            // tab change; <10ms, no daemon required.
587            "editor-signal" => {
588                let file = rest
589                    .iter()
590                    .position(|a| a == "--file")
591                    .and_then(|i| rest.get(i + 1));
592                if let Some(path) = file {
593                    if let Err(e) = core::editor_signal::record_focus(path) {
594                        eprintln!("editor-signal: {e}");
595                        std::process::exit(1);
596                    }
597                } else {
598                    eprintln!("usage: lean-ctx editor-signal --file <path>");
599                    std::process::exit(2);
600                }
601                return;
602            }
603            "update" | "--self-update" => {
604                core::updater::run(&rest);
605                return;
606            }
607            "restart" => {
608                cmd_restart();
609                return;
610            }
611            "stop" => {
612                cmd_stop();
613                return;
614            }
615            "dev-install" => {
616                cmd_dev_install();
617                return;
618            }
619            "codesign-setup" => {
620                cmd_codesign_setup();
621                return;
622            }
623            "doctor" => {
624                let code = doctor::run_cli(&rest);
625                if code != 0 {
626                    std::process::exit(code);
627                }
628                return;
629            }
630            "harden" => {
631                super::harden::run(&rest);
632                return;
633            }
634            "export-rules" => {
635                super::export_rules::run(&rest);
636                return;
637            }
638            "gotchas" | "bugs" => {
639                super::cloud::cmd_gotchas(&rest);
640                return;
641            }
642            "learn" => {
643                super::cmd_learn(&rest);
644                return;
645            }
646            "buddy" | "pet" => {
647                super::cloud::cmd_buddy(&rest);
648                return;
649            }
650            "hook" => {
651                hook_handlers::mark_hook_environment();
652                // Hooks run inside the agent shell environment, so they can see
653                // runtime/session vars (e.g. CODEX_THREAD_ID) that the long-lived
654                // MCP server process never receives. Bridge them for ctx_shell (#370).
655                core::agent_runtime_env::capture();
656                let action = rest.first().map_or("help", std::string::String::as_str);
657                // Gating hooks (rewrite/redirect) self-bound their work and FAIL OPEN
658                // inside the handler (#1035), so they must NOT also carry the
659                // force-exit watchdog (which would `exit(1)` with no decision and
660                // wedge the host). The remaining hooks keep the simple zombie-guard.
661                if !matches!(action, "rewrite" | "redirect") {
662                    hook_handlers::arm_watchdog(std::time::Duration::from_secs(5));
663                }
664                match action {
665                    "rewrite" => hook_handlers::handle_rewrite(),
666                    "redirect" => hook_handlers::handle_redirect(),
667                    "observe" => hook_handlers::handle_observe(),
668                    "copilot" => hook_handlers::handle_copilot(),
669                    "codex-pretooluse" => hook_handlers::handle_codex_pretooluse(),
670                    "codex-session-start" => hook_handlers::handle_codex_session_start(),
671                    "rewrite-inline" => hook_handlers::handle_rewrite_inline(),
672                    _ => {
673                        eprintln!(
674                            "Usage: lean-ctx hook <rewrite|redirect|observe|copilot|codex-pretooluse|codex-session-start|rewrite-inline>"
675                        );
676                        eprintln!(
677                            "  Internal commands used by agent hooks (Claude, Cursor, Copilot, etc.)"
678                        );
679                        std::process::exit(1);
680                    }
681                }
682                return;
683            }
684            "report-issue" | "report" => {
685                report::run(&rest);
686                return;
687            }
688            "uninstall" => {
689                // Safety: `--help`/`-h` must NEVER fall through to a real
690                // uninstall (issue #476). Short-circuit before any removal.
691                if rest.iter().any(|a| a == "--help" || a == "-h") {
692                    uninstall::print_help();
693                    return;
694                }
695                let dry_run = rest.iter().any(|a| a == "--dry-run");
696                let keep_config = rest.iter().any(|a| a == "--keep-config");
697                let keep_binary = rest.iter().any(|a| a == "--keep-binary");
698                uninstall::run(dry_run, keep_config, keep_binary);
699                return;
700            }
701            // `raw` is the primary name; `bypass` is kept as a back-compat alias.
702            // The old "bypass" wording read to a model like a *security* bypass,
703            // but this only skips compression — the shell allowlist and path jail
704            // still apply (GH security audit, finding 5).
705            "raw" | "bypass" => {
706                if rest.is_empty() {
707                    eprintln!("Usage: lean-ctx raw \"command\"");
708                    eprintln!(
709                        "Runs the command with output passed through unchanged (no \
710                         compression). The shell allowlist still applies."
711                    );
712                    std::process::exit(1);
713                }
714                let command = if rest.len() == 1 {
715                    rest[0].clone()
716                } else {
717                    shell::join_command(&args[2..])
718                };
719                // SAFETY: CLI dispatch is single-threaded; this runs before the
720                // process hands off to shell::exec and exits below.
721                unsafe { std::env::set_var("LEAN_CTX_RAW", "1") };
722                let code = shell::exec(&command);
723                std::process::exit(code);
724            }
725            "safety-levels" | "safety" => {
726                println!("{}", core::compression_safety::format_safety_table());
727                return;
728            }
729            "cheat" | "cheatsheet" | "cheat-sheet" => {
730                super::cmd_cheatsheet();
731                return;
732            }
733            "login" => {
734                super::cloud::cmd_login(&rest);
735                return;
736            }
737            "register" => {
738                super::cloud::cmd_register(&rest);
739                return;
740            }
741            "forgot-password" => {
742                super::cloud::cmd_forgot_password(&rest);
743                return;
744            }
745            "sync" => {
746                super::cloud::cmd_sync(&rest);
747                return;
748            }
749            "contribute" => {
750                super::cloud::cmd_contribute();
751                return;
752            }
753            "cloud" => {
754                super::cloud::cmd_cloud(&rest);
755                return;
756            }
757            "upgrade" => {
758                super::cloud::cmd_upgrade();
759                return;
760            }
761            "--version" | "-V" => {
762                println!("{}", core::integrity::origin_line());
763                return;
764            }
765            "help" => {
766                let want_all = rest
767                    .iter()
768                    .any(|a| matches!(a.as_str(), "all" | "full" | "--all" | "-a"));
769                if want_all {
770                    print_help();
771                } else {
772                    print_help_concise();
773                }
774                return;
775            }
776            "--help" | "-h" => {
777                if rest
778                    .iter()
779                    .any(|a| matches!(a.as_str(), "all" | "full" | "--all" | "-a"))
780                {
781                    print_help();
782                } else {
783                    print_help_concise();
784                }
785                return;
786            }
787            "mcp" => {}
788            _ => {
789                let unknown = &args[1];
790                eprintln!("lean-ctx: unknown command '{unknown}'");
791                if let Some(suggestion) = suggest::closest_command(unknown) {
792                    eprintln!("       did you mean '{suggestion}'?");
793                }
794                eprintln!("       run 'lean-ctx help' for the full command list");
795                std::process::exit(1);
796            }
797        }
798    }
799
800    // Bare `lean-ctx` in an interactive terminal: a human almost certainly did
801    // not mean to start a silent stdio MCP server (which just hangs waiting for
802    // JSON-RPC). Show a short quickstart instead. MCP clients pipe stdin (not a
803    // TTY) so they still get the server, and explicit `lean-ctx mcp` always
804    // serves regardless of TTY.
805    if args.len() == 1 && std::io::IsTerminal::is_terminal(&std::io::stdin()) {
806        print_quickstart();
807        return;
808    }
809
810    if let Err(e) = run_mcp_server() {
811        tracing::error!("lean-ctx: {e}");
812        std::process::exit(1);
813    }
814}
815
816/// Long-lived server entry points keep Rust's default ignored SIGPIPE: they
817/// must survive peers closing sockets/pipes early. Bare `lean-ctx` counts as
818/// a server because MCP clients spawn the binary without a subcommand.
819fn is_server_mode(args: &[String]) -> bool {
820    args.len() == 1
821        || args.get(1).is_some_and(|a| {
822            matches!(
823                a.as_str(),
824                "mcp" | "daemon" | "proxy" | "serve" | "watch" | "dashboard"
825            )
826        })
827}
828
829/// Restore the default SIGPIPE disposition for short-lived CLI invocations.
830///
831/// Rust's runtime ignores SIGPIPE process-wide, so `lean-ctx doctor | head`
832/// made `println!` panic with BrokenPipe; the LineWriter flush in stdout's
833/// Drop then panicked again *during unwinding*, which aborts — the SIGABRT
834/// (exit 134) of upstream #378 / GL#436. Real CLIs (cat, grep, rg) terminate
835/// silently with exit 141 instead; SIG_DFL gives us exactly that. Children
836/// spawned via std::process::Command are unaffected either way (std resets
837/// their SIGPIPE disposition since Rust 1.65).
838#[cfg(unix)]
839fn restore_sigpipe_default() {
840    // SAFETY: signal(2) with SIG_DFL has no preconditions and is called once
841    // during single-threaded startup, before any I/O.
842    unsafe {
843        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
844    }
845}
846
847#[cfg(not(unix))]
848fn restore_sigpipe_default() {}
849
850fn passthrough(command: &str) -> ! {
851    let (shell, flag) = shell::shell_and_flag();
852    let mut cmd = std::process::Command::new(&shell);
853    cmd.arg(&flag).arg(command);
854    shell::reentry::mark_child(&mut cmd);
855    shell::platform::apply_utf8_locale(&mut cmd);
856    let status = cmd.status().map_or(127, |s| s.code().unwrap_or(1));
857    std::process::exit(status);
858}
859
860pub(super) fn run_async<F: std::future::Future>(future: F) -> F::Output {
861    // A failed runtime build (e.g. exhausted FDs) must not abort with a panic
862    // backtrace the user can't act on — report it plainly and exit.
863    match tokio::runtime::Runtime::new() {
864        Ok(rt) => rt.block_on(future),
865        Err(e) => {
866            eprintln!("lean-ctx: failed to create async runtime: {e}");
867            std::process::exit(1);
868        }
869    }
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875    use serial_test::serial;
876
877    fn args_of(parts: &[&str]) -> Vec<String> {
878        parts.iter().map(|s| (*s).to_string()).collect()
879    }
880
881    #[test]
882    fn server_modes_keep_ignored_sigpipe() {
883        for mode in ["mcp", "daemon", "proxy", "serve", "watch", "dashboard"] {
884            assert!(
885                is_server_mode(&args_of(&["lean-ctx", mode])),
886                "{mode} must count as server mode"
887            );
888        }
889        // Bare invocation = MCP server spawned by a client.
890        assert!(is_server_mode(&args_of(&["lean-ctx"])));
891    }
892
893    #[test]
894    fn cli_modes_restore_default_sigpipe() {
895        for mode in ["doctor", "-c", "status", "ls", "grep", "gain", "help"] {
896            assert!(
897                !is_server_mode(&args_of(&["lean-ctx", mode])),
898                "{mode} must count as CLI mode (SIGPIPE default)"
899            );
900        }
901    }
902
903    #[test]
904    fn quickstart_is_short_and_points_to_setup() {
905        let q = quickstart_text();
906        assert!(
907            q.contains("lean-ctx onboard"),
908            "quickstart must point to onboard"
909        );
910        assert!(q.contains("lean-ctx help"), "quickstart must point to help");
911        // Must stay a *quickstart*, not the full reference — keep it tight.
912        assert!(
913            q.lines().count() <= 16,
914            "quickstart should be short; got {} lines",
915            q.lines().count()
916        );
917        assert!(
918            !q.contains("COMMANDS:"),
919            "quickstart must not inline the full command reference"
920        );
921    }
922
923    #[test]
924    fn concise_help_is_short_and_points_to_full() {
925        let h = concise_help_text();
926        assert!(h.contains("lean-ctx onboard"), "must lead with onboard");
927        assert!(
928            h.contains("lean-ctx help all"),
929            "must point to full reference"
930        );
931        assert!(
932            h.contains("lean-ctx tools"),
933            "must surface the tools profile command"
934        );
935        // Concise means concise — keep it well under the full reference.
936        assert!(
937            h.lines().count() <= 40,
938            "concise help should stay short; got {} lines",
939            h.lines().count()
940        );
941        assert!(
942            !h.contains("SHELL HOOK PATTERNS"),
943            "concise help must not inline the full pattern catalog"
944        );
945    }
946
947    #[test]
948    fn capability_banner_tool_count_matches_registry() {
949        let n = crate::server::registry::tool_count();
950        let banner = capability_banner();
951        assert!(
952            banner.contains(&format!("{n} MCP tools")),
953            "banner must show the live registry count ({n}); got: {banner}"
954        );
955    }
956
957    #[test]
958    #[serial]
959    fn worker_threads_default_clamps_low() {
960        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
961        assert_eq!(resolve_worker_threads(1), 1);
962    }
963
964    #[test]
965    #[serial]
966    fn worker_threads_default_clamps_high() {
967        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
968        assert_eq!(resolve_worker_threads(32), 4);
969    }
970
971    #[test]
972    #[serial]
973    fn worker_threads_default_passthrough() {
974        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
975        assert_eq!(resolve_worker_threads(3), 3);
976    }
977
978    #[test]
979    #[serial]
980    fn worker_threads_env_override() {
981        crate::test_env::set_var("LEAN_CTX_WORKER_THREADS", "12");
982        assert_eq!(resolve_worker_threads(2), 12);
983        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
984    }
985
986    #[test]
987    #[serial]
988    fn worker_threads_env_invalid_falls_back() {
989        crate::test_env::set_var("LEAN_CTX_WORKER_THREADS", "not_a_number");
990        assert_eq!(resolve_worker_threads(3), 3);
991        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
992    }
993}