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