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