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