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;
10pub(crate) mod suggest;
11
12#[allow(clippy::wildcard_imports)]
13use analytics::*;
14#[allow(clippy::wildcard_imports)]
15use help::*;
16#[allow(clippy::wildcard_imports)]
17use lifecycle::*;
18#[allow(clippy::wildcard_imports)]
19use network::*;
20#[allow(clippy::wildcard_imports)]
21use server::*;
22
23pub fn run() {
24    let mut args: Vec<String> = std::env::args().collect();
25
26    // On Linux, if the binary was replaced while running, systemd may write
27    // the path with " (deleted)" suffix into ExecStart, causing "(deleted)"
28    // to appear as an argument. Strip it defensively.
29    if args.get(1).is_some_and(|a| a == "(deleted)") {
30        args.remove(1);
31    }
32
33    if !is_server_mode(&args) {
34        restore_sigpipe_default();
35    }
36
37    let enters_mcp = args.len() == 1 || args.get(1).is_some_and(|a| a == "mcp");
38    if !enters_mcp {
39        crate::core::logging::init_logging();
40    }
41
42    if args.len() > 1 {
43        let rest = args[2..].to_vec();
44
45        match args[1].as_str() {
46            "-c" | "exec" => handle_exec(&args, &rest),
47            "-t" | "--track" => handle_track(&args),
48            "shell" | "--shell" => {
49                shell::interactive();
50                return;
51            }
52            "gain" => {
53                cmd_gain(&rest);
54                return;
55            }
56            "spend" => {
57                cmd_spend(&rest);
58                return;
59            }
60            "savings" => {
61                cmd_savings(&rest);
62                return;
63            }
64            "learning" => {
65                cmd_learning(&rest);
66                return;
67            }
68            "conformance" | "selftest" => {
69                cmd_conformance(&rest);
70                return;
71            }
72            "health" => {
73                let code = crate::cli::health_cmd::cmd_health(&rest);
74                if code != 0 {
75                    std::process::exit(code);
76                }
77                return;
78            }
79            "quality-lab" | "quality_lab" => {
80                let code = crate::cli::quality_lab_cmd::cmd_quality_lab(&rest);
81                if code != 0 {
82                    std::process::exit(code);
83                }
84                return;
85            }
86            "billing" => {
87                cmd_billing(&rest);
88                return;
89            }
90            "finops" => {
91                cmd_finops(&rest);
92                return;
93            }
94            "roi" => {
95                // Local ROI is individual + free. The team roll-up lives on its own
96                // surface (`savings team` / the web account), not under `roi`.
97                super::cmd_roi(&rest);
98                return;
99            }
100            "output-savings" | "output_savings" => {
101                // #895 Track B: measured (A/B holdout) or estimated output-token
102                // reduction. Local + free, like `roi`.
103                super::cmd_output_savings(&rest);
104                return;
105            }
106            "token-report" | "report-tokens" => {
107                let code = token_report::run_cli(&rest);
108                if code != 0 {
109                    std::process::exit(code);
110                }
111                return;
112            }
113            "pack" => {
114                crate::cli::cmd_pack(&rest);
115                return;
116            }
117            "policy" => {
118                crate::cli::cmd_policy(&rest);
119                return;
120            }
121            "plugin" | "plugins" => {
122                crate::cli::plugin_cmd::cmd_plugin(&rest);
123                return;
124            }
125            "addon" | "addons" => {
126                let code = crate::cli::addon_cmd::cmd_addon(&rest);
127                if code != 0 {
128                    std::process::exit(code);
129                }
130                return;
131            }
132            "embeddings" => {
133                crate::cli::embeddings_cmd::cmd_embeddings(&rest);
134                return;
135            }
136            "enable-gpu" | "gpu" => {
137                core::updater::enable_gpu(&rest);
138                return;
139            }
140            "rules" => {
141                crate::cli::rules_cmd::cmd_rules(&rest);
142                return;
143            }
144            "proof" => {
145                crate::cli::cmd_proof(&rest);
146                return;
147            }
148            "snapshot" => {
149                crate::cli::cmd_snapshot(&rest);
150                return;
151            }
152            "verify" => {
153                crate::cli::cmd_verify(&rest);
154                return;
155            }
156            "eval" => {
157                crate::cli::eval_cmd::cmd_eval(&rest);
158                return;
159            }
160            "verify-cache" | "cache-selftest" => {
161                let code = crate::cli::verify_cache_cmd::cmd_verify_cache(&rest);
162                if code != 0 {
163                    std::process::exit(code);
164                }
165                return;
166            }
167            "visualize" => {
168                super::cmd_visualize(&rest);
169                return;
170            }
171            "audit" => {
172                if rest.first().map(String::as_str) == Some("evidence") {
173                    crate::cli::audit_report::cmd_evidence(&rest[1..]);
174                } else {
175                    println!("{}", crate::cli::audit_report::generate_report());
176                }
177                return;
178            }
179            "compliance" => {
180                crate::cli::cmd_compliance(&rest);
181                return;
182            }
183            "agent" => {
184                crate::cli::cmd_agent(&rest);
185                return;
186            }
187            "instructions" => {
188                crate::cli::cmd_instructions(&rest);
189                return;
190            }
191            "index" => {
192                crate::cli::cmd_index(&rest);
193                return;
194            }
195            "semantic-search" | "search-code" => {
196                crate::cli::cmd_semantic_search(&rest);
197                core::stats::flush();
198                return;
199            }
200            "explore" => {
201                crate::cli::explore_cmd::cmd_explore(&rest);
202                core::stats::flush();
203                return;
204            }
205            "repomap" | "repo-map" => {
206                crate::cli::cmd_repomap(&rest);
207                core::stats::flush();
208                return;
209            }
210            "cep" => {
211                println!("{}", core::stats::format_cep_report());
212                return;
213            }
214            "dashboard" => {
215                cmd_dashboard(&rest);
216                return;
217            }
218            "team" => {
219                cmd_team(&rest);
220                return;
221            }
222            "provider" => {
223                cmd_provider(&rest);
224                return;
225            }
226            "serve" => {
227                cmd_serve(&rest);
228                return;
229            }
230            "watch" => {
231                cmd_watch(&rest);
232                return;
233            }
234            "proxy" => {
235                cmd_proxy(&rest);
236                return;
237            }
238            "daemon" => {
239                cmd_daemon(&rest);
240                return;
241            }
242            "init" => {
243                super::cmd_init(&rest);
244                return;
245            }
246            "setup" => {
247                handle_setup(&rest);
248                return;
249            }
250            "onboard" => {
251                handle_onboard(&rest);
252                return;
253            }
254            "install" => {
255                handle_install(&rest);
256                return;
257            }
258            "bootstrap" => {
259                handle_bootstrap(&rest);
260                return;
261            }
262            "wrap" => {
263                crate::wrap::run_wrap(&rest);
264                return;
265            }
266            "unwrap" => {
267                crate::wrap::run_unwrap(&rest);
268                return;
269            }
270            "status" => {
271                let code = status::run_cli(&rest);
272                if code != 0 {
273                    std::process::exit(code);
274                }
275                return;
276            }
277            "cognitive" => {
278                crate::cli::cognitive::run();
279                return;
280            }
281            "read" => {
282                super::cmd_read(&rest);
283                core::tool_lifecycle::flush_all();
284                return;
285            }
286            "call" => {
287                super::cmd_call(&rest);
288                return;
289            }
290            "diff" => {
291                super::cmd_diff(&rest);
292                core::tool_lifecycle::flush_all();
293                return;
294            }
295            "grep" => {
296                super::cmd_grep(&rest);
297                core::tool_lifecycle::flush_all();
298                return;
299            }
300            "glob" => {
301                super::cmd_glob(&rest);
302                core::stats::flush();
303                return;
304            }
305            "find" => {
306                super::cmd_find(&rest);
307                core::tool_lifecycle::flush_all();
308                return;
309            }
310            "ls" => {
311                super::cmd_ls(&rest);
312                core::tool_lifecycle::flush_all();
313                return;
314            }
315            "deps" => {
316                super::cmd_deps(&rest);
317                core::tool_lifecycle::flush_all();
318                return;
319            }
320            "discover" => {
321                super::cmd_discover(&rest);
322                return;
323            }
324            "ghost" => {
325                super::cmd_ghost(&rest);
326                return;
327            }
328            "filter" => {
329                super::cmd_filter(&rest);
330                return;
331            }
332            "heatmap" => {
333                heatmap::cmd_heatmap(&rest);
334                return;
335            }
336            "graph" => {
337                cmd_graph(&rest);
338                return;
339            }
340            "smells" => {
341                cmd_smells(&rest);
342                return;
343            }
344            "session" => {
345                super::cmd_session_action(&rest);
346                return;
347            }
348            "ledger" => {
349                super::cmd_ledger(&rest);
350                return;
351            }
352            "ocla" => {
353                super::cmd_ocla(&rest);
354                return;
355            }
356            "control" | "context-control" => {
357                super::cmd_control(&rest);
358                return;
359            }
360            "plan" | "context-plan" => {
361                super::cmd_plan(&rest);
362                return;
363            }
364            "compile" | "context-compile" => {
365                super::cmd_compile(&rest);
366                return;
367            }
368            "knowledge" => {
369                super::cmd_knowledge(&rest);
370                return;
371            }
372            "skillify" => {
373                super::cmd_skillify(&rest);
374                return;
375            }
376            "summary" => {
377                super::cmd_summary(&rest);
378                return;
379            }
380            "overview" => {
381                super::cmd_overview(&rest);
382                return;
383            }
384            "compress" => {
385                super::cmd_compress(&rest);
386                return;
387            }
388            "wrapped" => {
389                eprintln!("'lean-ctx wrapped' has been removed. Use: lean-ctx gain --wrapped");
390                std::process::exit(1);
391            }
392            "sessions" | "session-store" => {
393                super::cmd_sessions(&rest);
394                return;
395            }
396            "benchmark" => {
397                super::cmd_benchmark(&rest);
398                return;
399            }
400            "compact" => {
401                cmd_compact(&rest);
402                return;
403            }
404            "profile" => {
405                super::cmd_profile(&rest);
406                return;
407            }
408            "tools" => {
409                // `tools health` is the token-budget / rot report (#848); it is
410                // distinct from tool *profiles* and routed before the forward.
411                if rest.first().map(String::as_str) == Some("health") {
412                    super::cmd_tools_health(&rest[1..]);
413                    return;
414                }
415                // Canonical, unambiguous entry point for MCP *tool* profiles
416                // (how many tools the agent sees). Disambiguates from
417                // `lean-ctx profile`, which manages *context* profiles.
418                let mut forwarded = vec!["tools".to_string()];
419                forwarded.extend(rest.iter().cloned());
420                super::cmd_profile(&forwarded);
421                return;
422            }
423            "config" => {
424                super::cmd_config(&rest);
425                return;
426            }
427            "allow" => {
428                super::cmd_allow(&rest);
429                return;
430            }
431            "security" => {
432                super::cmd_security(&rest);
433                return;
434            }
435            "yolo" => {
436                super::cmd_yolo(&rest);
437                return;
438            }
439            "secure" | "lockdown" => {
440                super::cmd_secure(&rest);
441                return;
442            }
443            "trust" => {
444                super::cmd_trust(&rest);
445                return;
446            }
447            "untrust" => {
448                super::cmd_untrust(&rest);
449                return;
450            }
451            "stats" => {
452                super::cmd_stats(&rest);
453                return;
454            }
455            "introspect" => {
456                super::cmd_introspect(&rest);
457                return;
458            }
459            "cache" => {
460                super::cmd_cache(&rest);
461                return;
462            }
463            "theme" => {
464                super::cmd_theme(&rest);
465                return;
466            }
467            "enterprise" => {
468                super::enterprise::cmd_enterprise(&rest);
469                return;
470            }
471            "tee" => {
472                super::cmd_tee(&rest);
473                return;
474            }
475            "terse" | "compression" => {
476                super::cmd_compression(&rest);
477                return;
478            }
479            "slow-log" => {
480                super::cmd_slow_log(&rest);
481                return;
482            }
483            "debug-log" => {
484                super::cmd_debug_log(&rest);
485                return;
486            }
487            // Editor focus ingress (#500): called by the VS Code extension on
488            // tab change; <10ms, no daemon required.
489            "editor-signal" => {
490                let file = rest
491                    .iter()
492                    .position(|a| a == "--file")
493                    .and_then(|i| rest.get(i + 1));
494                if let Some(path) = file {
495                    if let Err(e) = core::editor_signal::record_focus(path) {
496                        eprintln!("editor-signal: {e}");
497                        std::process::exit(1);
498                    }
499                } else {
500                    eprintln!("usage: lean-ctx editor-signal --file <path>");
501                    std::process::exit(2);
502                }
503                return;
504            }
505            "editor-session" => {
506                handle_editor_session(&rest);
507                return;
508            }
509            "update" | "--self-update" => {
510                core::updater::run(&rest);
511                return;
512            }
513            "restart" => {
514                cmd_restart();
515                return;
516            }
517            "stop" => {
518                cmd_stop();
519                return;
520            }
521            "dev-install" => {
522                cmd_dev_install();
523                return;
524            }
525            "codesign-setup" => {
526                cmd_codesign_setup();
527                return;
528            }
529            "doctor" => {
530                let code = doctor::run_cli(&rest);
531                if code != 0 {
532                    std::process::exit(code);
533                }
534                return;
535            }
536            "harden" => {
537                super::harden::run(&rest);
538                return;
539            }
540            "export-rules" => {
541                super::export_rules::run(&rest);
542                return;
543            }
544            "completions" => {
545                super::completions::run_completions(&rest);
546                return;
547            }
548            "__complete" => {
549                #[allow(non_snake_case)]
550                super::completions::run___complete(&rest);
551                return;
552            }
553            "gotchas" | "bugs" => {
554                super::cloud::cmd_gotchas(&rest);
555                return;
556            }
557            "learn" => {
558                super::cmd_learn(&rest);
559                return;
560            }
561            "buddy" | "pet" => {
562                super::cloud::cmd_buddy(&rest);
563                return;
564            }
565            "hook" => {
566                hook_handlers::mark_hook_environment();
567                // Hooks run inside the agent shell environment, so they can see
568                // runtime/session vars (e.g. CODEX_THREAD_ID) that the long-lived
569                // MCP server process never receives. Bridge them for ctx_shell (#370).
570                core::agent_runtime_env::capture();
571                let action = rest.first().map_or("help", std::string::String::as_str);
572                // Gating hooks (rewrite/redirect) self-bound their work and FAIL OPEN
573                // inside the handler (#1035), so they must NOT also carry the
574                // force-exit watchdog (which would `exit(1)` with no decision and
575                // wedge the host). The remaining hooks keep the simple zombie-guard.
576                if !matches!(action, "rewrite" | "redirect" | "deny" | "vibe-pre-tool") {
577                    hook_handlers::arm_watchdog(std::time::Duration::from_secs(5));
578                }
579                match action {
580                    "rewrite" => hook_handlers::handle_rewrite(),
581                    "redirect" => hook_handlers::handle_redirect(),
582                    "deny" => hook_handlers::handle_deny(),
583                    "read-dedup" => hook_handlers::handle_read_dedup(),
584                    "observe" => hook_handlers::handle_observe(),
585                    "copilot" => hook_handlers::handle_copilot(),
586                    "codex-pretooluse" => hook_handlers::handle_codex_pretooluse(),
587                    "codex-session-start" => hook_handlers::handle_codex_session_start(),
588                    "rewrite-inline" => hook_handlers::handle_rewrite_inline(),
589                    "vibe-pre-tool" => hook_handlers::handle_vibe_pre_tool(),
590                    _ => {
591                        eprintln!(
592                            "Usage: lean-ctx hook <rewrite|redirect|deny|read-dedup|observe|copilot|codex-pretooluse|codex-session-start|rewrite-inline|vibe-pre-tool>"
593                        );
594                        eprintln!(
595                            "  Internal commands used by agent hooks (Claude, Cursor, Copilot, etc.)"
596                        );
597                        std::process::exit(1);
598                    }
599                }
600                return;
601            }
602            "report-issue" | "report" => {
603                report::run(&rest);
604                return;
605            }
606            "uninstall" => {
607                // Safety: `--help`/`-h` must NEVER fall through to a real
608                // uninstall (issue #476). Short-circuit before any removal.
609                if rest.iter().any(|a| a == "--help" || a == "-h") {
610                    uninstall::print_help();
611                    return;
612                }
613                let dry_run = rest.iter().any(|a| a == "--dry-run");
614                let keep_config = rest.iter().any(|a| a == "--keep-config");
615                let keep_binary = rest.iter().any(|a| a == "--keep-binary");
616                uninstall::run(dry_run, keep_config, keep_binary);
617                return;
618            }
619            // `raw` is the primary name; `bypass` is kept as a back-compat alias.
620            // The old "bypass" wording read to a model like a *security* bypass,
621            // but this only skips compression — the shell allowlist and path jail
622            // still apply (GH security audit, finding 5).
623            "raw" | "bypass" => handle_raw(&args, &rest),
624            "safety-levels" | "safety" => {
625                println!("{}", core::compression_safety::format_safety_table());
626                return;
627            }
628            "cheat" | "cheatsheet" | "cheat-sheet" => {
629                super::cmd_cheatsheet();
630                return;
631            }
632            "login" => {
633                super::cloud::cmd_login(&rest);
634                return;
635            }
636            "register" => {
637                super::cloud::cmd_register(&rest);
638                return;
639            }
640            "forgot-password" => {
641                super::cloud::cmd_forgot_password(&rest);
642                return;
643            }
644            "sync" => {
645                super::cloud::cmd_sync(&rest);
646                return;
647            }
648            "contribute" => {
649                super::cloud::cmd_contribute();
650                return;
651            }
652            "telemetry" => {
653                super::telemetry_cmd::cmd_telemetry(&rest);
654                return;
655            }
656            "cloud" => {
657                super::cloud::cmd_cloud(&rest);
658                return;
659            }
660            "upgrade" => {
661                super::cloud::cmd_upgrade();
662                return;
663            }
664            "--version" | "-V" => {
665                println!("{}", core::integrity::origin_line());
666                return;
667            }
668            "help" => {
669                let want_all = rest
670                    .iter()
671                    .any(|a| matches!(a.as_str(), "all" | "full" | "--all" | "-a"));
672                if want_all {
673                    print_help();
674                } else {
675                    print_help_concise();
676                }
677                return;
678            }
679            "--help" | "-h" => {
680                if rest
681                    .iter()
682                    .any(|a| matches!(a.as_str(), "all" | "full" | "--all" | "-a"))
683                {
684                    print_help();
685                } else {
686                    print_help_concise();
687                }
688                return;
689            }
690            "mcp" => {}
691            _ => {
692                let unknown = &args[1];
693                eprintln!("lean-ctx: unknown command '{unknown}'");
694                if let Some(suggestion) = suggest::closest_command(unknown) {
695                    eprintln!("       did you mean '{suggestion}'?");
696                }
697                eprintln!("       run 'lean-ctx help' for the full command list");
698                std::process::exit(1);
699            }
700        }
701    }
702
703    // Bare `lean-ctx` in an interactive terminal: a human almost certainly did
704    // not mean to start a silent stdio MCP server (which just hangs waiting for
705    // JSON-RPC). Show a short quickstart instead. MCP clients pipe stdin (not a
706    // TTY) so they still get the server, and explicit `lean-ctx mcp` always
707    // serves regardless of TTY.
708    if args.len() == 1 && std::io::IsTerminal::is_terminal(&std::io::stdin()) {
709        print_quickstart();
710        return;
711    }
712
713    if let Err(e) = run_mcp_server() {
714        tracing::error!("lean-ctx: {e}");
715        std::process::exit(1);
716    }
717}
718
719fn handle_editor_session(args: &[String]) {
720    let value_for = |flag: &str| {
721        args.iter()
722            .position(|argument| argument == flag)
723            .and_then(|index| args.get(index + 1))
724    };
725    let fields = (
726        value_for("--event"),
727        value_for("--source"),
728        value_for("--workspace"),
729        value_for("--session-id"),
730    );
731    let (Some(event), Some(source), Some(workspace), Some(session_id)) = fields else {
732        eprintln!(
733            "usage: lean-ctx editor-session --event <open|heartbeat|close> \
734             --source <editor> --workspace <path> --session-id <id>"
735        );
736        std::process::exit(2);
737    };
738
739    if let Err(error) = crate::core::agents::AgentRegistry::record_logical_session_presence(
740        event, source, workspace, session_id,
741    ) {
742        eprintln!("editor-session: {error}");
743        std::process::exit(1);
744    }
745}
746
747/// Long-lived server entry points keep Rust's default ignored SIGPIPE: they
748/// must survive peers closing sockets/pipes early. Bare `lean-ctx` counts as
749/// a server because MCP clients spawn the binary without a subcommand.
750/// Help for `lean-ctx setup`. Printed for `--help`/`-h` and unknown flags so
751/// asking about setup can never accidentally *run* setup (#476 class, #658).
752fn print_setup_help() {
753    println!("Usage: lean-ctx setup [options]");
754    println!();
755    println!("Guided setup: shell hook, agent hooks/rules, MCP registrations.");
756    println!("Interactive by default; runs non-interactively without a TTY.");
757    println!();
758    println!("Options:");
759    println!("  --non-interactive   No prompts; apply defaults");
760    println!("  --yes, -y           Assume yes for all prompts");
761    println!("  --fix               Repair an existing installation");
762    println!("  --json              Machine-readable report (implies non-interactive)");
763    println!("  --no-auto-approve   Skip auto-approve configuration");
764    println!("  --skip-rules        Do not write agent rules files");
765    println!("  --help, -h          Show this help (never runs setup)");
766    println!();
767    println!("See also: lean-ctx onboard (one-command setup), lean-ctx doctor");
768}
769
770fn is_server_mode(args: &[String]) -> bool {
771    args.len() == 1
772        || args.get(1).is_some_and(|a| {
773            matches!(
774                a.as_str(),
775                "mcp" | "daemon" | "proxy" | "serve" | "watch" | "dashboard" | "gateway"
776            )
777        })
778}
779
780/// Extract `KEY=VALUE` prefixes from a command string and promote lean-ctx control variables
781/// (`LEAN_CTX_*`) into the process environment.
782/// Returns the remaining command string without the extracted prefixes.
783///
784/// Only `LEAN_CTX_*` variables are set in the process env — arbitrary user vars like `FOO=bar`
785/// are left for the child shell to handle.
786fn extract_and_apply_env_prefix(cmd: &str) -> String {
787    let mut rest = cmd.trim_start();
788    let mut last_rest = rest;
789
790    loop {
791        if let Some(eq_pos) = rest.find('=') {
792            let key = &rest[..eq_pos];
793            if !key.is_empty()
794                && key
795                    .bytes()
796                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
797            {
798                let after_eq = &rest[eq_pos + 1..];
799                if let Some(space_pos) = after_eq.find(' ') {
800                    let value = &after_eq[..space_pos];
801                    if key.starts_with("LEAN_CTX_") {
802                        // SAFETY: runs before tokio runtime spawns threads.
803                        unsafe { std::env::set_var(key, value) };
804                    }
805                    rest = after_eq[space_pos..].trim_start();
806                    last_rest = rest;
807                    continue;
808                }
809            }
810        }
811        break;
812    }
813
814    last_rest.to_string()
815}
816
817fn handle_exec(args: &[String], rest: &[String]) -> ! {
818    let raw = rest.first().is_some_and(|a| a == "--raw");
819    let cmd_args = if raw { &args[3..] } else { &args[2..] };
820    let command = if cmd_args.len() == 1 {
821        cmd_args[0].clone()
822    } else {
823        shell::join_command(cmd_args)
824    };
825    // Extract LEAN_CTX_* env-var prefixes from the command string and set them in process env
826    // so should_pass_through() / is_disabled() can see them. The child shell still gets the full
827    // original command. (#1321)
828    let _stripped_command = extract_and_apply_env_prefix(&command);
829    // The `lean-ctx -c` wrapper runs inside the agent shell, which carries
830    // runtime/session vars the MCP server never sees. Bridge them so ctx_shell
831    // can forward them too (#370).
832    core::agent_runtime_env::capture();
833    if crate::shell::reentry::should_pass_through() {
834        passthrough(&command);
835    }
836    if raw {
837        core::runtime_flags::enable_raw();
838    } else {
839        core::runtime_flags::enable_compress();
840    }
841    let code = shell::exec(&command);
842    core::tool_lifecycle::flush_all();
843    std::process::exit(code);
844}
845
846fn handle_track(args: &[String]) -> ! {
847    let cmd_args = &args[2..];
848    let code = if cmd_args.len() > 1 {
849        shell::exec_argv(cmd_args)
850    } else {
851        let command = cmd_args[0].clone();
852        if crate::shell::reentry::should_pass_through() {
853            passthrough(&command);
854        }
855        shell::exec(&command)
856    };
857    core::tool_lifecycle::flush_all();
858    std::process::exit(code);
859}
860
861fn handle_setup(rest: &[String]) {
862    // Safety (#476 class): `--help`/`-h` — or any unknown flag — must NEVER
863    // fall through to a real setup run that mutates shell + agent configs.
864    if rest.iter().any(|a| a == "--help" || a == "-h") {
865        print_setup_help();
866        return;
867    }
868    const KNOWN: &[&str] = &[
869        "--non-interactive",
870        "--yes",
871        "-y",
872        "--fix",
873        "--json",
874        "--no-auto-approve",
875        "--skip-rules",
876        "--no-agent-aliases",
877    ];
878    if let Some(unknown) = rest
879        .iter()
880        .find(|a| a.starts_with('-') && !KNOWN.contains(&a.as_str()))
881    {
882        eprintln!("setup: unknown flag '{unknown}'\n");
883        print_setup_help();
884        std::process::exit(2);
885    }
886    let non_interactive = rest.iter().any(|a| a == "--non-interactive");
887    let yes = rest.iter().any(|a| a == "--yes" || a == "-y");
888    let fix = rest.iter().any(|a| a == "--fix");
889    let json = rest.iter().any(|a| a == "--json");
890    let no_auto_approve = rest.iter().any(|a| a == "--no-auto-approve");
891    let skip_rules = rest.iter().any(|a| a == "--skip-rules");
892    let no_agent_aliases = rest.iter().any(|a| a == "--no-agent-aliases");
893
894    if no_agent_aliases {
895        let _ = crate::core::config::setter::set_by_key("skip_agent_aliases", "true");
896    }
897
898    if non_interactive || fix || json || yes {
899        let opts = setup::SetupOptions {
900            non_interactive,
901            yes,
902            fix,
903            json,
904            no_auto_approve,
905            skip_rules,
906            ..Default::default()
907        };
908        run_setup_options(opts, json);
909    } else {
910        setup::run_setup();
911    }
912}
913
914fn handle_onboard(rest: &[String]) {
915    if rest.iter().any(|a| a == "--help" || a == "-h") {
916        println!("Usage: lean-ctx onboard [--no-agent-aliases]");
917        println!("Connect your AI tools with one command: detects installed");
918        println!("agents, installs hooks/rules/MCP registrations, verifies.");
919        println!();
920        println!("  --no-agent-aliases  Do not install claude/codex/gemini shell aliases");
921        println!();
922        println!("Fine-grained control: lean-ctx setup --help");
923        return;
924    }
925    if rest.iter().any(|a| a == "--no-agent-aliases") {
926        let _ = crate::core::config::setter::set_by_key("skip_agent_aliases", "true");
927    }
928    setup::run_onboard();
929}
930
931fn handle_install(rest: &[String]) {
932    // Plain `lean-ctx install` is a natural thing to type after installing the
933    // binary; keep it as guided setup unless repair mode was explicitly asked.
934    let repair = rest.iter().any(|a| a == "--repair" || a == "--fix");
935    let json = rest.iter().any(|a| a == "--json");
936    if !repair {
937        setup::run_setup();
938        return;
939    }
940    run_repair_setup(json);
941}
942
943fn handle_bootstrap(rest: &[String]) {
944    let json = rest.iter().any(|a| a == "--json");
945    run_repair_setup(json);
946}
947
948fn run_repair_setup(json: bool) {
949    let opts = setup::SetupOptions {
950        non_interactive: true,
951        yes: true,
952        fix: true,
953        json,
954        ..Default::default()
955    };
956    run_setup_options(opts, json);
957}
958
959fn handle_raw(args: &[String], rest: &[String]) -> ! {
960    if rest.is_empty() {
961        eprintln!("Usage: lean-ctx raw \"command\"");
962        eprintln!(
963            "Runs the command with output passed through unchanged (no compression). \
964             The shell allowlist still applies."
965        );
966        std::process::exit(1);
967    }
968    let command = if rest.len() == 1 {
969        rest[0].clone()
970    } else {
971        shell::join_command(&args[2..])
972    };
973    core::runtime_flags::enable_raw();
974    let code = shell::exec(&command);
975    std::process::exit(code);
976}
977
978fn run_setup_options(opts: setup::SetupOptions, json: bool) {
979    match setup::run_setup_with_options(opts) {
980        Ok(report) => {
981            if json {
982                println!(
983                    "{}",
984                    serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
985                );
986            }
987            if !report.success {
988                std::process::exit(1);
989            }
990        }
991        Err(e) => {
992            eprintln!("{e}");
993            std::process::exit(1);
994        }
995    }
996}
997
998/// Restore the default SIGPIPE disposition for short-lived CLI invocations.
999///
1000/// Rust's runtime ignores SIGPIPE process-wide, so `lean-ctx doctor | head`
1001/// made `println!` panic with BrokenPipe; the LineWriter flush in stdout's
1002/// Drop then panicked again *during unwinding*, which aborts — the SIGABRT
1003/// (exit 134) of upstream #378 / GL#436. Real CLIs (cat, grep, rg) terminate
1004/// silently with exit 141 instead; SIG_DFL gives us exactly that. Children
1005/// spawned via std::process::Command are unaffected either way (std resets
1006/// their SIGPIPE disposition since Rust 1.65).
1007#[cfg(unix)]
1008fn restore_sigpipe_default() {
1009    // SAFETY: signal(2) with SIG_DFL has no preconditions and is called once
1010    // during single-threaded startup, before any I/O.
1011    unsafe {
1012        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1013    }
1014}
1015
1016#[cfg(not(unix))]
1017fn restore_sigpipe_default() {}
1018
1019fn passthrough(command: &str) -> ! {
1020    let (shell, flag) = shell::shell_and_flag();
1021    let mut cmd = std::process::Command::new(&shell);
1022    cmd.arg(&flag).arg(command);
1023    shell::reentry::mark_child(&mut cmd);
1024    shell::platform::apply_utf8_locale(&mut cmd);
1025    let status = cmd.status().map_or(127, |s| s.code().unwrap_or(1));
1026    std::process::exit(status);
1027}
1028
1029pub(super) fn run_async<F: std::future::Future>(future: F) -> F::Output {
1030    // A failed runtime build (e.g. exhausted FDs) must not abort with a panic
1031    // backtrace the user can't act on — report it plainly and exit.
1032    match tokio::runtime::Runtime::new() {
1033        Ok(rt) => rt.block_on(future),
1034        Err(e) => {
1035            eprintln!("lean-ctx: failed to create async runtime: {e}");
1036            std::process::exit(1);
1037        }
1038    }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::{
1044        capability_banner, concise_help_text, is_server_mode, quickstart_text,
1045        resolve_worker_threads,
1046    };
1047    use serial_test::serial;
1048
1049    fn args_of(parts: &[&str]) -> Vec<String> {
1050        parts.iter().map(|s| (*s).to_string()).collect()
1051    }
1052
1053    #[test]
1054    fn server_modes_keep_ignored_sigpipe() {
1055        for mode in ["mcp", "daemon", "proxy", "serve", "watch", "dashboard"] {
1056            assert!(
1057                is_server_mode(&args_of(&["lean-ctx", mode])),
1058                "{mode} must count as server mode"
1059            );
1060        }
1061        // Bare invocation = MCP server spawned by a client.
1062        assert!(is_server_mode(&args_of(&["lean-ctx"])));
1063    }
1064
1065    #[test]
1066    fn cli_modes_restore_default_sigpipe() {
1067        for mode in ["doctor", "-c", "status", "ls", "grep", "gain", "help"] {
1068            assert!(
1069                !is_server_mode(&args_of(&["lean-ctx", mode])),
1070                "{mode} must count as CLI mode (SIGPIPE default)"
1071            );
1072        }
1073    }
1074
1075    #[test]
1076    fn quickstart_is_short_and_points_to_setup() {
1077        let q = quickstart_text();
1078        assert!(q.contains("lean-ctx wrap"), "quickstart must point to wrap");
1079        assert!(q.contains("lean-ctx help"), "quickstart must point to help");
1080        // Must stay a *quickstart*, not the full reference — keep it tight.
1081        assert!(
1082            q.lines().count() <= 16,
1083            "quickstart should be short; got {} lines",
1084            q.lines().count()
1085        );
1086        assert!(
1087            !q.contains("COMMANDS:"),
1088            "quickstart must not inline the full command reference"
1089        );
1090    }
1091
1092    #[test]
1093    fn concise_help_is_short_and_points_to_full() {
1094        let h = concise_help_text();
1095        assert!(h.contains("lean-ctx wrap"), "must lead with wrap");
1096        assert!(
1097            h.contains("lean-ctx help all"),
1098            "must point to full reference"
1099        );
1100        assert!(
1101            h.contains("lean-ctx tools"),
1102            "must surface the tools profile command"
1103        );
1104        // Concise means concise — keep it well under the full reference.
1105        assert!(
1106            h.lines().count() <= 40,
1107            "concise help should stay short; got {} lines",
1108            h.lines().count()
1109        );
1110        assert!(
1111            !h.contains("SHELL HOOK PATTERNS"),
1112            "concise help must not inline the full pattern catalog"
1113        );
1114    }
1115
1116    #[test]
1117    fn capability_banner_tool_count_matches_registry() {
1118        let n = crate::server::registry::tool_count();
1119        let banner = capability_banner();
1120        assert!(
1121            banner.contains(&format!("{n} MCP tools")),
1122            "banner must show the live registry count ({n}); got: {banner}"
1123        );
1124    }
1125
1126    #[test]
1127    #[serial]
1128    fn worker_threads_default_clamps_low() {
1129        let _env_lock = crate::core::data_dir::test_env_lock();
1130        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
1131        assert_eq!(resolve_worker_threads(1), 1);
1132    }
1133
1134    #[test]
1135    #[serial]
1136    fn worker_threads_default_clamps_high() {
1137        let _env_lock = crate::core::data_dir::test_env_lock();
1138        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
1139        assert_eq!(resolve_worker_threads(32), 4);
1140    }
1141
1142    #[test]
1143    #[serial]
1144    fn worker_threads_default_passthrough() {
1145        let _env_lock = crate::core::data_dir::test_env_lock();
1146        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
1147        assert_eq!(resolve_worker_threads(3), 3);
1148    }
1149
1150    #[test]
1151    #[serial]
1152    fn worker_threads_env_override() {
1153        let _env_lock = crate::core::data_dir::test_env_lock();
1154        crate::test_env::set_var("LEAN_CTX_WORKER_THREADS", "12");
1155        assert_eq!(resolve_worker_threads(2), 12);
1156        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
1157    }
1158
1159    #[test]
1160    #[serial]
1161    fn worker_threads_env_invalid_falls_back() {
1162        let _env_lock = crate::core::data_dir::test_env_lock();
1163        crate::test_env::set_var("LEAN_CTX_WORKER_THREADS", "not_a_number");
1164        assert_eq!(resolve_worker_threads(3), 3);
1165        crate::test_env::remove_var("LEAN_CTX_WORKER_THREADS");
1166    }
1167}
1168
1169#[cfg(test)]
1170mod env_prefix_tests {
1171    use super::extract_and_apply_env_prefix;
1172    use serial_test::serial;
1173
1174    #[test]
1175    #[serial]
1176    fn extracts_lean_ctx_disabled() {
1177        let _env_lock = crate::core::data_dir::test_env_lock();
1178        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1179        let result = extract_and_apply_env_prefix("LEAN_CTX_DISABLED=1 cargo test --lib");
1180        assert_eq!(result, "cargo test --lib");
1181        assert_eq!(std::env::var("LEAN_CTX_DISABLED").unwrap(), "1");
1182        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1183    }
1184
1185    #[test]
1186    #[serial]
1187    fn ignores_non_lean_ctx_vars() {
1188        let _env_lock = crate::core::data_dir::test_env_lock();
1189        crate::test_env::remove_var("FOO");
1190        let result = extract_and_apply_env_prefix("FOO=bar cargo test --lib");
1191        assert_eq!(result, "cargo test --lib");
1192        assert!(
1193            std::env::var("FOO").is_err(),
1194            "FOO must not be set in process env"
1195        );
1196    }
1197
1198    #[test]
1199    #[serial]
1200    fn extracts_multiple_lean_ctx_vars() {
1201        let _env_lock = crate::core::data_dir::test_env_lock();
1202        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1203        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1204        let result =
1205            extract_and_apply_env_prefix("LEAN_CTX_DISABLED=1 LEAN_CTX_ACTIVE=1 cargo test --lib");
1206        assert_eq!(result, "cargo test --lib");
1207        assert_eq!(std::env::var("LEAN_CTX_DISABLED").unwrap(), "1");
1208        assert_eq!(std::env::var("LEAN_CTX_ACTIVE").unwrap(), "1");
1209        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1210        crate::test_env::remove_var("LEAN_CTX_ACTIVE");
1211    }
1212
1213    #[test]
1214    #[serial]
1215    fn no_prefix_returns_unchanged() {
1216        let _env_lock = crate::core::data_dir::test_env_lock();
1217        let result = extract_and_apply_env_prefix("cargo test --lib");
1218        assert_eq!(result, "cargo test --lib");
1219    }
1220
1221    #[test]
1222    #[serial]
1223    fn mixed_vars_extracts_only_lean_ctx() {
1224        let _env_lock = crate::core::data_dir::test_env_lock();
1225        crate::test_env::remove_var("FOO");
1226        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1227        let result = extract_and_apply_env_prefix("FOO=bar LEAN_CTX_DISABLED=1 cargo test --lib");
1228        assert_eq!(result, "cargo test --lib");
1229        assert_eq!(std::env::var("LEAN_CTX_DISABLED").unwrap(), "1");
1230        assert!(std::env::var("FOO").is_err());
1231        crate::test_env::remove_var("LEAN_CTX_DISABLED");
1232    }
1233}