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