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