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