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