Skip to main content

bot_forge/cli/
actions.rs

1use std::collections::BTreeSet;
2use std::env;
3use std::io::{self, IsTerminal};
4use std::path::PathBuf;
5use std::process;
6
7use crate::cancellation::install_handler;
8use crate::cli::apt_mirror_actions::cmd_apt_mirror;
9use crate::cli::launcher::{LauncherChoice, choose};
10use crate::cli::state_actions::{cmd_cache, cmd_resume, cmd_status};
11use crate::cli::typed::{command, validate};
12use crate::cli::{command_help, completion, help_text, man_page, schema, top_level_commands};
13
14use crate::config::{init_config, load_config_with_overlays};
15use crate::diagnostics::doctor_checks;
16use crate::events::{LifecycleEvent, LifecycleSession};
17use crate::execution::{PreparedExecution, execute, preview_plan, select};
18use crate::items::{RUSTUP_REMOVAL_UNSUPPORTED, remove_installed};
19use crate::model::{InstallKind, InstallOptions, InstallOutcome, Profile};
20use crate::planning::{PlanRequest, TargetPlatform, build_plan, plan_profile};
21use crate::redaction::mask_secrets;
22use crate::reporting::write_install_report_log;
23use crate::state::read_registry_document;
24use crate::ui::input::{PromptOutcome, confirm as confirm_install, wait_for_enter_or_interrupt};
25use crate::ui::{self, CliOutput, Document, OutputSilencer, RawKind, StatusKind};
26
27const VERSION: &str = env!("CARGO_PKG_VERSION");
28const RETURN_TO_LAUNCHER: &str = "return to launcher";
29const EXIT_LAUNCHER: &str = "exit bot-forge";
30/// Run the command-line application using the current process arguments and standard streams.
31///
32/// This is the only binary integration entry point. It installs process-wide terminal and
33/// cancellation hooks, renders user-facing failures, and terminates the process with the stable
34/// CLI exit code when command execution fails.
35pub fn main_entry() {
36    ui::install_terminal_panic_hook();
37    if let Err(error) = install_handler() {
38        ui::stderr_status(
39            StatusKind::Warning,
40            &format!("Could not install the Ctrl-C handler: {error}"),
41        );
42    }
43    let arguments = env::args().skip(1).collect::<Vec<_>>();
44    let machine_output = requested_machine_output(&arguments);
45    if let Err(error) = run(arguments) {
46        let code = exit_code_for_error(&error);
47        if let Some(kind) = machine_output {
48            let text = match kind {
49                RawKind::Json => serde_json::to_string_pretty(&serde_json::json!({
50                    "ok": false,
51                    "error": {
52                        "code": stable_error_code(code),
53                        "message": error,
54                        "hint": error_hint(code, &error)
55                    }
56                })),
57                RawKind::JsonLines => serde_json::to_string(&serde_json::json!({
58                    "ok": false,
59                    "error": {
60                        "code": stable_error_code(code),
61                        "message": error,
62                        "hint": error_hint(code, &error)
63                    }
64                })),
65                _ => unreachable!("only JSON protocols are prescanned"),
66            }
67            .unwrap_or_else(|_| "{\"ok\":false}".to_string());
68            let _ = ui::try_print(&CliOutput::Raw {
69                kind,
70                text: format!("{text}\n"),
71            });
72            process::exit(code);
73        }
74        let document = error_document(code, &error);
75        if let Err(output_error) = ui::try_print_error(&document)
76            && output_error.kind() != io::ErrorKind::BrokenPipe
77        {
78            process::exit(1);
79        }
80        process::exit(code);
81    }
82}
83
84fn requested_machine_output(arguments: &[String]) -> Option<RawKind> {
85    let mut iter = arguments.iter();
86    while let Some(argument) = iter.next() {
87        let value = if argument == "--format" {
88            iter.next().map(String::as_str)
89        } else {
90            argument.strip_prefix("--format=")
91        };
92        match value {
93            Some("json") => return Some(RawKind::Json),
94            Some("jsonl") => return Some(RawKind::JsonLines),
95            _ => {}
96        }
97    }
98    None
99}
100
101fn error_document(code: i32, error: &str) -> Document {
102    Document::with_subtitle("bot-forge", "error")
103        .field("Error code", stable_error_code(code))
104        .status(StatusKind::Error, error)
105        .hint(error_hint(code, error))
106}
107
108fn unsupported_rustup_removal(error: &str) -> bool {
109    error
110        .strip_prefix("configuration error: ")
111        .is_some_and(|message| message.starts_with(RUSTUP_REMOVAL_UNSUPPORTED))
112}
113
114fn error_hint(code: i32, error: &str) -> &'static str {
115    if unsupported_rustup_removal(error) {
116        return "Use rustup to manage this tool; see rustup toolchain uninstall --help or rustup component remove --help.";
117    }
118    match code {
119        2 => "Run bot-forge help to inspect valid arguments, or run bot-forge config validate.",
120        3 => "Review the plan and retry; add --yes in automation.",
121        5 => "Review the install log and recent command output, then run bot-forge install.",
122        6 => "Check the target path and permissions; do not bypass managed-path safeguards.",
123        7 => "Run bot-forge doctor --format json to inspect DNS, TLS, and proxy settings.",
124        8 => {
125            "Run bot-forge resume to inspect the unfinished transaction or restore from its backup."
126        }
127        _ => "Run bot-forge doctor for environment diagnostics.",
128    }
129}
130
131pub(crate) fn print_human(document: Document) -> Result<(), String> {
132    ui::try_print(&CliOutput::Human(document))
133        .map_err(|error| format!("failed to write output: {error}"))
134}
135
136fn stable_error_code(exit_code: i32) -> String {
137    format!("BF{:04}", 1000 + exit_code)
138}
139fn exit_code_for_error(error: &str) -> i32 {
140    if unsupported_rustup_removal(error) {
141        2
142    } else if error == "installation cancelled" || error.contains("non-interactive terminal") {
143        3
144    } else if error.contains("verification failed") || error.contains("installation failed") {
145        5
146    } else if error.contains("permission") || error.contains("unsafe") || error.contains("refuse") {
147        6
148    } else if error.contains("verification")
149        || error.contains("network")
150        || error.contains("download")
151    {
152        7
153    } else if error.contains("registry") || error.contains("corrupt state") {
154        8
155    } else if error.contains("unknown")
156        || error.contains("unexpected argument")
157        || error.contains("invalid help command path")
158        || error.contains("generate accepts")
159        || error.contains("missing")
160        || error.contains("does not support")
161        || error.contains("only accepts")
162        || error.contains("only one")
163        || error.contains("cannot")
164        || error.contains("must be valid")
165        || error.contains("does not accept")
166        || error.starts_with("configuration error")
167        || error.starts_with("parse error")
168    {
169        2
170    } else {
171        1
172    }
173}
174
175fn run(args: Vec<String>) -> Result<(), String> {
176    if let Some(path) = conventional_help_path(&args) {
177        let names = path.iter().map(String::as_str).collect::<Vec<_>>();
178        if command_help(&names).is_none() {
179            return Err(format!("invalid help command path: {}", names.join(" ")));
180        }
181        return print_command_help(&path, false);
182    }
183    validate(&args)?;
184    let args = expand_long_options(args);
185    reject_duplicate_options(&args)?;
186    let Some(command) = args.first().map(String::as_str) else {
187        if io::stdin().is_terminal() && io::stdout().is_terminal() {
188            return run_launcher_menu();
189        }
190        print_help();
191        return Ok(());
192    };
193
194    if args
195        .iter()
196        .skip(1)
197        .any(|argument| argument == "--help" || argument == "-h")
198    {
199        validate_help_arguments(&args)?;
200        return print_command_help(&args, true);
201    }
202
203    match command {
204        "config" => cmd_config(&args[1..]),
205        "plan" => cmd_plan(&args[1..]),
206        "install" => cmd_install(&args[1..]),
207        "resume" => cmd_resume(&args[1..]),
208        "remove" => cmd_remove(&args[1..]),
209        "status" => cmd_status(&args[1..]),
210        "cache" => cmd_cache(&args[1..]),
211        "doctor" => cmd_doctor(&args[1..]),
212        "apt-mirror" => cmd_apt_mirror(&args[1..]),
213        "generate" => cmd_generate(&args[1..]),
214        "-h" | "--help" => {
215            reject_command_arguments(command, &args[1..])?;
216            print_help();
217            Ok(())
218        }
219        "help" => {
220            if args.len() == 1 {
221                print_help();
222            } else {
223                let path = args[1..].iter().map(String::as_str).collect::<Vec<_>>();
224                if command_help(&path).is_none() {
225                    return Err(format!("unknown help command: {}", path.join(" ")));
226                }
227                print_command_help(&args[1..], false)?;
228            }
229            Ok(())
230        }
231        "-V" | "--version" => {
232            reject_command_arguments(command, &args[1..])?;
233            ui::stdout_line(&format!("bot-forge {VERSION}"));
234            Ok(())
235        }
236        unknown => Err(format!("unknown command: {unknown}")),
237    }
238}
239
240fn conventional_help_path(args: &[String]) -> Option<Vec<String>> {
241    let index = args.iter().position(|argument| argument == "help")?;
242    if index == 0
243        || index + 1 != args.len()
244        || args[..index]
245            .iter()
246            .any(|argument| argument.starts_with('-'))
247    {
248        return None;
249    }
250    let mut command = command();
251    let mut path = Vec::new();
252    for value in &args[..index] {
253        let next = command
254            .get_subcommands()
255            .find(|candidate| candidate.get_name() == value)
256            .cloned();
257        let Some(next) = next else {
258            if command.get_subcommands().next().is_none() {
259                continue;
260            }
261            return None;
262        };
263        path.push(value.clone());
264        command = next;
265    }
266    (!path.is_empty()).then_some(path)
267}
268
269fn reject_duplicate_options(args: &[String]) -> Result<(), String> {
270    let repeatable = ["--overlay", "--only", "--exclude"];
271    let mut seen = BTreeSet::new();
272    for argument in args.iter().filter(|argument| argument.starts_with('-')) {
273        let canonical = match argument.as_str() {
274            "-f" => "--force",
275            "-h" => "--help",
276            "-q" => "--quiet",
277            "-v" => "--verbose",
278            "-y" => "--yes",
279            _ => argument.as_str(),
280        };
281        if repeatable.contains(&canonical) {
282            continue;
283        }
284        if !seen.insert(canonical) {
285            return Err(format!(
286                "argument {canonical} cannot be used multiple times"
287            ));
288        }
289    }
290    Ok(())
291}
292
293fn validate_help_arguments(args: &[String]) -> Result<(), String> {
294    let mut filtered = args
295        .iter()
296        .filter(|argument| *argument != "--help" && *argument != "-h")
297        .cloned()
298        .collect::<Vec<_>>();
299    let command = filtered.first().cloned().unwrap_or_default();
300    if command.is_empty() {
301        return Err("unexpected argument found".to_string());
302    }
303    filtered.remove(0);
304    let subcommand = match command.as_str() {
305        "config" | "cache" | "apt-mirror" => filtered.first().map(String::as_str),
306        "generate" => filtered.first().map(String::as_str),
307        _ => None,
308    }
309    .map(str::to_owned);
310    if subcommand.is_none()
311        && matches!(
312            command.as_str(),
313            "config" | "cache" | "apt-mirror" | "generate"
314        )
315        && filtered.is_empty()
316    {
317        return Ok(());
318    }
319    if subcommand.is_some() {
320        filtered.remove(0);
321    }
322    match (command.as_str(), subcommand.as_deref()) {
323        ("plan", _) => {
324            let filtered = filtered
325                .into_iter()
326                .filter(|argument| argument != "--why")
327                .collect::<Vec<_>>();
328            parse_install_options(&filtered).map(|_| ())
329        }
330        ("install", _) => parse_install_options(&filtered).map(|_| ()),
331        ("resume", _) => validate_resume_help_arguments(&filtered),
332        ("remove", _) => validate_remove_help_arguments(&filtered),
333        ("status", _) => validate_status_help_arguments(&filtered),
334        ("doctor", _) => validate_options_with_values(&filtered, &["--format", "--config"], &[], 0),
335        ("config", Some("init")) => {
336            validate_options_with_values(&filtered, &["--output"], &["--force", "-f"], 0)
337        }
338        ("config", Some("validate" | "effective" | "explain")) => {
339            validate_config_help_arguments(subcommand.as_deref().unwrap_or_default(), &filtered)
340        }
341        ("cache", Some("status")) => validate_options_with_values(&filtered, &["--format"], &[], 0),
342        ("cache", Some("gc")) => validate_options_with_values(
343            &filtered,
344            &["--format", "--max-age-days"],
345            &["--dry-run"],
346            0,
347        ),
348        ("apt-mirror", Some("show" | "check" | "apply" | "restore")) => {
349            validate_options_with_values(&filtered, &["--config", "--overlay"], &[], 0)
350        }
351        ("generate", Some("completion" | "man" | "schema" | "json" | "jsonl")) => {
352            if filtered.is_empty() {
353                Ok(())
354            } else {
355                Err("generate accepts one format and no additional arguments".to_string())
356            }
357        }
358        ("config" | "cache" | "apt-mirror" | "generate", _) => {
359            Err("invalid help command path".to_string())
360        }
361        _ => Err("invalid help command path".to_string()),
362    }
363}
364
365fn validate_resume_help_arguments(args: &[String]) -> Result<(), String> {
366    let mut install = Vec::new();
367    let mut index = 0;
368    while index < args.len() {
369        match args[index].as_str() {
370            "--run" | "--abandon" => {
371                index += 1;
372                if args.get(index).is_none_or(|value| value.starts_with('-')) {
373                    return Err(format!("{} requires a value", args[index - 1]));
374                }
375            }
376            value => install.push(value.to_string()),
377        }
378        index += 1;
379    }
380    parse_install_options(&install).map(|_| ())
381}
382
383fn validate_remove_help_arguments(args: &[String]) -> Result<(), String> {
384    validate_options_with_values(args, &["--kind"], &["--dry-run", "--yes", "-y"], 1)
385}
386
387fn validate_status_help_arguments(args: &[String]) -> Result<(), String> {
388    validate_options_with_values(args, &["--format", "--config", "--overlay"], &[], 1)
389}
390
391fn validate_config_help_arguments(command: &str, args: &[String]) -> Result<(), String> {
392    let mut allowed_flags = Vec::new();
393    if command == "effective" {
394        allowed_flags.extend(["--verbose", "-v", "--show-sensitive"]);
395    }
396    validate_options_with_values(args, &["--config", "--overlay"], &allowed_flags, 0)
397}
398
399fn validate_options_with_values(
400    args: &[String],
401    value_options: &[&str],
402    flag_options: &[&str],
403    max_positionals: usize,
404) -> Result<(), String> {
405    let mut positionals = 0;
406    let mut index = 0;
407    while index < args.len() {
408        let argument = &args[index];
409        let (name, inline) = argument
410            .split_once('=')
411            .map_or((argument.as_str(), None), |(name, value)| {
412                (name, Some(value))
413            });
414        if value_options.contains(&name) {
415            if inline.is_some_and(str::is_empty)
416                || inline.is_none()
417                    && args
418                        .get(index + 1)
419                        .is_none_or(|value| value.starts_with('-'))
420            {
421                return Err(format!("{name} requires a value"));
422            }
423            if inline.is_none() {
424                index += 1;
425            }
426        } else if !flag_options.contains(&name) {
427            if argument.starts_with('-') {
428                return Err(format!("unexpected argument '{argument}' found"));
429            }
430            positionals += 1;
431            if positionals > max_positionals {
432                return Err("unexpected argument found".to_string());
433            }
434        }
435        index += 1;
436    }
437    Ok(())
438}
439
440fn expand_long_options(args: Vec<String>) -> Vec<String> {
441    args.into_iter()
442        .flat_map(|argument| {
443            if let Some((name, value)) = argument.split_once('=')
444                && name.starts_with("--")
445                && !value.is_empty()
446            {
447                return vec![name.to_string(), value.to_string()];
448            }
449            vec![argument]
450        })
451        .collect()
452}
453
454fn cmd_config(args: &[String]) -> Result<(), String> {
455    let action = args
456        .first()
457        .map(String::as_str)
458        .ok_or("config requires one of: init, validate, effective, explain")?;
459    if action == "init" {
460        return cmd_init(&args[1..]);
461    }
462    if !matches!(action, "validate" | "effective" | "explain") {
463        return Err(format!(
464            "unknown config action: {action}; expected init, validate, effective, or explain"
465        ));
466    }
467    let mut path = None;
468    let mut verbose = false;
469    let mut show_sensitive = false;
470    let mut overlays = Vec::new();
471    let mut index = 1;
472    while index < args.len() {
473        match args[index].as_str() {
474            "--config" => {
475                index += 1;
476                path = Some(PathBuf::from(value_after(args, index, "--config")?));
477            }
478            "--overlay" => {
479                index += 1;
480                overlays.push(PathBuf::from(value_after(args, index, "--overlay")?));
481            }
482            "--verbose" | "-v" => verbose = true,
483            "--show-sensitive" => show_sensitive = true,
484            value => return Err(format!("unknown config option: {value}")),
485        }
486        index += 1;
487    }
488    if action != "effective" && (verbose || show_sensitive) {
489        return Err(format!(
490            "config {action} does not support --verbose or --show-sensitive"
491        ));
492    }
493    if show_sensitive && !verbose {
494        return Err("--show-sensitive requires --verbose".to_string());
495    }
496    let loaded =
497        load_config_with_overlays(path.as_deref(), &overlays).map_err(|error| error.to_string())?;
498    match action {
499        "validate" => print_human(
500            Document::with_subtitle("bot-forge", "config validate")
501                .status(StatusKind::Success, "Configuration is valid")
502                .field("Catalog", loaded.document.catalog.clone()),
503        )?,
504        "effective" => {
505            let mut effective = toml::to_string_pretty(&loaded.document)
506                .map_err(|error| format!("failed to write effective configuration: {error}"))?;
507            if !(verbose && show_sensitive) {
508                effective = mask_secrets(&effective);
509            }
510            ui::try_print(&CliOutput::Raw {
511                kind: RawKind::Toml,
512                text: effective,
513            })
514            .map_err(|error| format!("failed to write effective configuration: {error}"))?;
515        }
516        "explain" => {
517            let mut document = Document::with_subtitle("bot-forge", "config explain")
518                .field(
519                    "Source",
520                    loaded
521                        .path
522                        .as_deref()
523                        .map(|path| path.display().to_string())
524                        .unwrap_or_else(|| "built-in configuration".to_string()),
525                )
526                .field("Catalog", loaded.document.catalog.clone())
527                .field("Digest", loaded.document.catalog_digest.clone())
528                .hint("Explicit scalar and array values override; components and environment mutations replace by id; tables merge recursively by field.")
529                .blank()
530                .section("Expanded values");
531            for (group, components) in &loaded.document.groups {
532                document = document.item(format!("group:{group}"), components.join(", "));
533            }
534            for (name, value) in &loaded.document.versions {
535                document = document.item(format!("version:{name}"), value);
536            }
537            document = document.blank().section("Field sources");
538            for (field, origin) in &loaded.origins.fields {
539                document = document.item(field, origin);
540            }
541            print_human(document)?;
542        }
543        _ => unreachable!("config action validated above"),
544    }
545    Ok(())
546}
547
548fn run_launcher_menu() -> Result<(), String> {
549    loop {
550        let choice = choose(VERSION)?;
551        match choice {
552            LauncherChoice::Install(profile) => {
553                let error = run_launcher_action(launcher_label(&profile), || {
554                    cmd_install_from_launcher(&[profile.as_str().to_string()])
555                });
556                if error.as_deref().is_some_and(is_return_to_launcher) {
557                    continue;
558                }
559                if error.as_deref().is_some_and(is_exit_launcher) {
560                    ui::stdout_status(StatusKind::Info, "Exited bot-forge.");
561                    return Ok(());
562                }
563                wait_for_launcher_close()?;
564                return Ok(());
565            }
566            LauncherChoice::Status => {
567                run_launcher_action("Show installation status", || cmd_status(&[]));
568                wait_for_launcher_close()?;
569                return Ok(());
570            }
571            LauncherChoice::Doctor => {
572                run_launcher_action("Run system diagnostics", || cmd_doctor(&[]));
573                wait_for_launcher_close()?;
574                return Ok(());
575            }
576            LauncherChoice::Help => print_help(),
577            LauncherChoice::Exit => {
578                ui::stdout_status(StatusKind::Info, "Exited bot-forge.");
579                return Ok(());
580            }
581        }
582    }
583}
584
585fn launcher_label(profile: &Profile) -> &'static str {
586    match profile {
587        Profile::Minimal => "Install minimal environment",
588        Profile::Standard => "Install standard environment",
589        Profile::Advanced => "Install advanced environment",
590        Profile::Custom(_) => "Install custom environment",
591    }
592}
593
594fn run_launcher_action<F>(label: &str, action: F) -> Option<String>
595where
596    F: FnOnce() -> Result<(), String>,
597{
598    ui::stdout_status(StatusKind::Info, &format!("Starting {label}"));
599    let activity = ui::progress::ActivityStatus::new(label, launcher_activity_detail(label));
600    let error = action().err();
601    if let Some(error) = &error
602        && !is_return_to_launcher(error)
603        && !is_exit_launcher(error)
604    {
605        ui::stdout_status(StatusKind::Error, &format!("{label}: {error}"));
606    }
607    drop(activity);
608    error
609}
610
611fn is_return_to_launcher(error: &str) -> bool {
612    error == RETURN_TO_LAUNCHER || error == format!("command error: {RETURN_TO_LAUNCHER}")
613}
614
615fn is_exit_launcher(error: &str) -> bool {
616    error == EXIT_LAUNCHER || error == format!("command error: {EXIT_LAUNCHER}")
617}
618
619fn launcher_activity_detail(label: &str) -> &'static str {
620    match label {
621        label if label.starts_with("Install ") => "Detecting installed tools and resolving plan",
622        "Show installation status" => "Reading managed registry and checking tool versions",
623        "Run system diagnostics" => "Checking toolchain, network, and platform",
624        _ => "Preparing the selected operation",
625    }
626}
627
628fn wait_for_launcher_close() -> Result<(), String> {
629    ui::stdout_status(
630        StatusKind::Hint,
631        "Press Enter to close this window, or Ctrl-C to exit.",
632    );
633    wait_for_enter_or_interrupt()
634        .map_err(|error| format!("failed to read close confirmation: {error}"))
635}
636
637fn cmd_init(args: &[String]) -> Result<(), String> {
638    let mut force = false;
639    let mut output = None;
640    let mut index = 0;
641    while index < args.len() {
642        match args[index].as_str() {
643            "--force" | "-f" => force = true,
644            "--output" => {
645                index += 1;
646                output = Some(PathBuf::from(value_after(args, index, "--output")?));
647            }
648            value => return Err(format!("unknown init option: {value}")),
649        }
650        index += 1;
651    }
652
653    let path = init_config(output.as_deref(), force).map_err(|error| error.to_string())?;
654    print_human(
655        Document::with_subtitle("bot-forge", "config init")
656            .status(StatusKind::Success, "Configuration created")
657            .labeled_path("Path", path.display().to_string()),
658    )
659}
660
661fn cmd_install(args: &[String]) -> Result<(), String> {
662    cmd_install_inner(args, false)
663}
664
665fn cmd_install_from_launcher(args: &[String]) -> Result<(), String> {
666    cmd_install_inner(args, true)
667}
668
669fn cmd_install_inner(args: &[String], allow_launcher_back: bool) -> Result<(), String> {
670    let (mut options, output_mode) = parse_install_options(args)?;
671    let json = output_mode == InstallOutputMode::Json;
672
673    let silencer = if matches!(
674        output_mode,
675        InstallOutputMode::Json | InstallOutputMode::JsonLines | InstallOutputMode::Quiet
676    ) {
677        options.status_bar = false;
678        Some(
679            OutputSilencer::stdout()
680                .map_err(|error| format!("failed to isolate machine output: {error}"))?,
681        )
682    } else {
683        None
684    };
685    let loaded = load_config_with_overlays(options.config_path.as_deref(), &options.overlay_paths)
686        .map_err(|error| error.to_string())?;
687    let plan = build_plan(PlanRequest {
688        document: &loaded.document,
689        origins: &loaded.origins,
690        profile: options.profile.as_str(),
691        target: TargetPlatform::host(),
692        only: &options.only,
693        exclude: &options.exclude,
694        source_root: loaded.path.as_deref().and_then(std::path::Path::parent),
695    })
696    .map_err(|error| error.to_string())?;
697    let lifecycle = (output_mode == InstallOutputMode::JsonLines).then(|| {
698        let session =
699            LifecycleSession::begin(format!("{}-{}", &plan.plan_hash[..16], std::process::id()));
700        session.emit(None, None, None, LifecycleEvent::Planned);
701        session
702    });
703    let selection = select(&plan, options.yes).map_err(|error| {
704        let message = error.to_string();
705        if is_return_to_launcher(&message) && !allow_launcher_back {
706            "installation cancelled".to_string()
707        } else {
708            message
709        }
710    })?;
711    let already_satisfied = if selection.components.is_empty() {
712        let preview = preview_plan(&plan).map_err(|error| error.to_string())?;
713        preview.missing_tools().is_empty()
714            && preview.missing_skills().is_empty()
715            && !preview
716                .tools
717                .iter()
718                .any(|status| status.outdated && status.installable)
719    } else {
720        false
721    };
722    if selection.components.is_empty() && !already_satisfied {
723        return Err("no components selected; installation cancelled".to_string());
724    }
725    if !options.yes && !already_satisfied && !confirm_action("Run planned install and save state?")?
726    {
727        return Err("installation cancelled".to_string());
728    }
729    let selection = if already_satisfied {
730        crate::execution::ExecutionSelection::all(&plan)
731    } else {
732        selection
733    };
734    let report = execute(PreparedExecution {
735        selection,
736        plan,
737        options: options.clone(),
738    })
739    .map_err(|error| error.to_string())?;
740    let log_path = write_install_report_log(&report).map_err(|error| error.to_string())?;
741    if let Some(session) = &lifecycle {
742        session.emit(
743            None,
744            None,
745            None,
746            LifecycleEvent::RunCompleted {
747                outcome: install_outcome_name(report.outcome).into(),
748                duration_ms: report.duration_ms,
749                log_path: log_path.display().to_string(),
750            },
751        );
752    }
753    drop(silencer);
754    if json {
755        let value = serde_json::json!({
756            "profile": options.profile.as_str(),
757            "log_path": log_path,
758            "report": report,
759        });
760        let text = serde_json::to_string_pretty(&value)
761            .map_err(|error| format!("failed to serialize install result: {error}"))?;
762        ui::try_print(&CliOutput::Raw {
763            kind: RawKind::Json,
764            text: format!("{text}\n"),
765        })
766        .map_err(|error| format!("failed to write install result: {error}"))?;
767    }
768    match report.outcome {
769        InstallOutcome::Success => {}
770        InstallOutcome::Cancelled => return Err("installation cancelled".to_string()),
771        InstallOutcome::Failed => {
772            return Err("installation or post-install verification failed".to_string());
773        }
774    }
775    if matches!(
776        output_mode,
777        InstallOutputMode::Json | InstallOutputMode::JsonLines | InstallOutputMode::Quiet
778    ) {
779        return Ok(());
780    }
781    let mut document = Document::with_subtitle("bot-forge", "install")
782        .status(StatusKind::Success, "Installation complete")
783        .field("Profile", options.profile.as_str())
784        .labeled_path("Log", log_path.display().to_string());
785    if !report.entries.is_empty() {
786        document = document.blank().section("Managed items");
787    }
788    for entry in report.entries {
789        document = document.item(
790            format!("{} {}", entry.kind.as_str(), entry.name),
791            format!("{} targets", entry.targets.len()),
792        );
793    }
794    print_human(document)
795}
796
797fn cmd_plan(args: &[String]) -> Result<(), String> {
798    let why = args.iter().any(|arg| arg == "--why");
799    let filtered = args
800        .iter()
801        .filter(|arg| *arg != "--why")
802        .cloned()
803        .collect::<Vec<_>>();
804    let (options, output_mode) = parse_install_options(&filtered)?;
805    if options.yes
806        || matches!(
807            output_mode,
808            InstallOutputMode::JsonLines | InstallOutputMode::Quiet
809        )
810    {
811        return Err("plan does not support --yes, --format jsonl, or --quiet".to_string());
812    }
813    if why && output_mode == InstallOutputMode::Json {
814        return Err("plan --why cannot be combined with --format json".to_string());
815    }
816    if output_mode == InstallOutputMode::Json {
817        print_plan_json(&options)
818    } else if why {
819        print_plan_why(&options)
820    } else {
821        print_plan(&options)
822    }
823}
824
825fn print_plan_why(options: &InstallOptions) -> Result<(), String> {
826    let plan = plan_profile(options).map_err(|error| error.to_string())?;
827    let mut document = Document::with_subtitle("bot-forge", "plan explain")
828        .field("Profile", plan.profile.clone())
829        .field("Plan", plan.plan_hash.clone())
830        .field(
831            "Certificate preflight",
832            if plan.certificate_preflight.is_some() {
833                "enabled"
834            } else {
835                "none"
836            },
837        );
838    for component in &plan.components {
839        let name = component.display_name.as_deref().map_or_else(
840            || component.id.clone(),
841            |name| format!("{name} ({})", component.id),
842        );
843        let resources = plan
844            .nodes
845            .iter()
846            .filter(|node| node.component == component.id)
847            .flat_map(|node| node.resources.iter().map(|claim| claim.key.clone()))
848            .collect::<std::collections::BTreeSet<_>>()
849            .into_iter()
850            .collect::<Vec<_>>();
851        let origin = plan
852            .origins
853            .get(&format!("components.{}", component.id))
854            .or_else(|| plan.origins.get("document"))
855            .map(String::as_str)
856            .unwrap_or("builtin");
857        document = document
858            .blank()
859            .section(name)
860            .field("Requested by", component.requested_by.join(", "))
861            .field(
862                "Provider",
863                component.variant.as_deref().unwrap_or("component"),
864            )
865            .field("Dependencies", display_or_none(&component.dependencies))
866            .field("Resources", display_or_none(&resources))
867            .field("Origin", origin);
868    }
869    print_human(document)
870}
871
872fn display_or_none(values: &[String]) -> String {
873    if values.is_empty() {
874        "none".to_string()
875    } else {
876        values.join(", ")
877    }
878}
879
880fn print_plan_json(options: &InstallOptions) -> Result<(), String> {
881    let plan = plan_profile(options).map_err(|error| error.to_string())?;
882    let text = serde_json::to_string_pretty(&plan)
883        .map_err(|error| format!("failed to serialize install plan: {error}"))?;
884    ui::try_print(&CliOutput::Raw {
885        kind: RawKind::Json,
886        text: format!("{text}\n"),
887    })
888    .map_err(|error| format!("failed to write install plan: {error}"))
889}
890
891fn print_plan(options: &InstallOptions) -> Result<(), String> {
892    let plan = plan_profile(options).map_err(|error| error.to_string())?;
893    let mut document = Document::with_subtitle("bot-forge", "plan")
894        .field("Profile", plan.profile)
895        .field("Config", plan.config_hash)
896        .field("Plan", plan.plan_hash)
897        .field(
898            "Certificate preflight",
899            if plan.certificate_preflight.is_some() {
900                "enabled"
901            } else {
902                "none"
903            },
904        )
905        .blank()
906        .section("Components");
907    for component in &plan.components {
908        document = document.item(&component.id, display_or_none(&component.dependencies));
909    }
910    document = document.blank().section("Execution nodes");
911    for node in &plan.nodes {
912        document = document.item(format!("{:?}", node.kind), &node.id);
913    }
914    print_human(document)
915}
916
917#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
918pub(crate) enum InstallOutputMode {
919    #[default]
920    Human,
921    Json,
922    JsonLines,
923    Quiet,
924}
925
926pub(crate) fn parse_install_options(
927    args: &[String],
928) -> Result<(InstallOptions, InstallOutputMode), String> {
929    parse_install_options_with_profile(args).map(|(options, output, _)| (options, output))
930}
931
932pub(crate) fn parse_install_options_with_profile(
933    args: &[String],
934) -> Result<(InstallOptions, InstallOutputMode, bool), String> {
935    let mut options = InstallOptions::default();
936    let mut output_mode = InstallOutputMode::Human;
937    let mut profile_set = false;
938    let mut index = 0;
939
940    while index < args.len() {
941        match args[index].as_str() {
942            "--config" => {
943                index += 1;
944                options.config_path = Some(PathBuf::from(value_after(args, index, "--config")?));
945            }
946            "--overlay" => {
947                index += 1;
948                options
949                    .overlay_paths
950                    .push(PathBuf::from(value_after(args, index, "--overlay")?));
951            }
952            "--yes" | "-y" => options.yes = true,
953            "--format" => {
954                index += 1;
955                output_mode = parse_output_mode(value_after(args, index, "--format")?)?;
956            }
957            "--quiet" | "-q" => set_output_mode(&mut output_mode, InstallOutputMode::Quiet)?,
958            "--only" => {
959                index += 1;
960                options
961                    .only
962                    .push(value_after(args, index, "--only")?.to_string());
963            }
964            "--exclude" => {
965                index += 1;
966                options
967                    .exclude
968                    .push(value_after(args, index, "--exclude")?.to_string());
969            }
970            value if value.starts_with('-') => {
971                return Err(format!("unknown install option: {value}"));
972            }
973            value if Profile::parse(value).is_some() => {
974                if profile_set {
975                    return Err("only one installation profile may be specified".to_string());
976                }
977                options.profile = Profile::parse(value)
978                    .ok_or_else(|| format!("unknown installation profile: {value}"))?;
979                profile_set = true;
980            }
981            value => return Err(format!("unknown installation profile: {value}")),
982        }
983        index += 1;
984    }
985    Ok((options, output_mode, profile_set))
986}
987
988fn set_output_mode(
989    current: &mut InstallOutputMode,
990    requested: InstallOutputMode,
991) -> Result<(), String> {
992    if *current != InstallOutputMode::Human && *current != requested {
993        return Err("--format and --quiet cannot be combined".to_string());
994    }
995    *current = requested;
996    Ok(())
997}
998
999pub(crate) fn parse_output_mode(value: &str) -> Result<InstallOutputMode, String> {
1000    match value {
1001        "human" => Ok(InstallOutputMode::Human),
1002        "json" => Ok(InstallOutputMode::Json),
1003        "jsonl" => Ok(InstallOutputMode::JsonLines),
1004        _ => Err(format!("unsupported output format: {value}")),
1005    }
1006}
1007
1008fn install_outcome_name(outcome: InstallOutcome) -> &'static str {
1009    match outcome {
1010        InstallOutcome::Success => "success",
1011        InstallOutcome::Cancelled => "cancelled",
1012        InstallOutcome::Failed => "failed",
1013    }
1014}
1015
1016fn cmd_remove(args: &[String]) -> Result<(), String> {
1017    let mut name = None;
1018    let mut kind = None;
1019    let mut plan_only = false;
1020    let mut yes = false;
1021    let mut index = 0;
1022
1023    while index < args.len() {
1024        match args[index].as_str() {
1025            "--kind" => {
1026                index += 1;
1027                kind = Some(parse_install_kind(value_after(args, index, "--kind")?)?);
1028            }
1029            "--dry-run" => plan_only = true,
1030            "--yes" | "-y" => yes = true,
1031            value if value.starts_with('-') => {
1032                return Err(format!("unknown remove option: {value}"));
1033            }
1034            value => {
1035                if name.replace(value.to_string()).is_some() {
1036                    return Err("remove accepts only one name".to_string());
1037                }
1038            }
1039        }
1040        index += 1;
1041    }
1042
1043    let name = name.ok_or_else(|| "missing managed item name to remove".to_string())?;
1044    let document = read_registry_document().map_err(|error| error.to_string())?;
1045    let matches = document
1046        .entries
1047        .iter()
1048        .filter(|entry| entry.name == name && kind.is_none_or(|kind| entry.kind == kind))
1049        .collect::<Vec<_>>();
1050    if matches.is_empty() {
1051        return Err(format!("managed item not found: {name}"));
1052    }
1053    let mut plan_document = Document::with_subtitle("bot-forge", "remove plan")
1054        .field("Records", matches.len().to_string())
1055        .section("Targets");
1056    for entry in &matches {
1057        plan_document = plan_document.item(
1058            format!("{} {}", entry.kind.as_str(), entry.name),
1059            entry
1060                .targets
1061                .iter()
1062                .map(|target| target.path.display().to_string())
1063                .collect::<Vec<_>>()
1064                .join(", "),
1065        );
1066    }
1067    print_human(plan_document)?;
1068    if plan_only {
1069        return Ok(());
1070    }
1071    if !yes && !confirm_action("Remove the managed item shown above?")? {
1072        return Err("removal cancelled".to_string());
1073    }
1074    let removed =
1075        remove_installed(&name, kind, document.revision).map_err(|error| error.to_string())?;
1076    print_human(
1077        Document::with_subtitle("bot-forge", "remove")
1078            .status(StatusKind::Success, "Removal complete")
1079            .field("Records", removed.len().to_string()),
1080    )
1081}
1082
1083fn cmd_doctor(args: &[String]) -> Result<(), String> {
1084    let mut json = false;
1085    let mut config = None;
1086    let mut index = 0;
1087    while index < args.len() {
1088        match args[index].as_str() {
1089            "--format" => {
1090                index += 1;
1091                json = parse_human_json_format(value_after(args, index, "--format")?)?;
1092            }
1093            "--config" => {
1094                index += 1;
1095                config = Some(PathBuf::from(value_after(args, index, "--config")?));
1096            }
1097            value => return Err(format!("unknown doctor option: {value}")),
1098        }
1099        index += 1;
1100    }
1101    let checks = doctor_checks(config.as_deref());
1102    if json {
1103        let text = serde_json::to_string_pretty(&checks)
1104            .map_err(|error| format!("failed to serialize diagnostics: {error}"))?;
1105        return ui::try_print(&CliOutput::Raw {
1106            kind: RawKind::Json,
1107            text: format!("{text}\n"),
1108        })
1109        .map_err(|error| format!("failed to write diagnostics: {error}"));
1110    }
1111    let mut document = Document::with_subtitle("bot-forge", "doctor").section("Checks");
1112    for check in checks {
1113        let kind = match check.severity.as_str() {
1114            "ok" | "success" => StatusKind::Success,
1115            "warning" | "warn" => StatusKind::Warning,
1116            "error" => StatusKind::Error,
1117            _ => StatusKind::Info,
1118        };
1119        document = document.status_item(check.id, check.summary, kind);
1120        if let Some(suggestion) = check.suggestion {
1121            document = document.hint(suggestion);
1122        }
1123    }
1124    print_human(document)
1125}
1126
1127pub(crate) fn parse_human_json_format(value: &str) -> Result<bool, String> {
1128    match value {
1129        "human" => Ok(false),
1130        "json" => Ok(true),
1131        _ => Err(format!("unsupported output format: {value}")),
1132    }
1133}
1134
1135pub(crate) fn confirm_action(message: &str) -> Result<bool, String> {
1136    match confirm_install(message, false).map_err(|error| error.to_string())? {
1137        PromptOutcome::Confirmed(()) => Ok(true),
1138        PromptOutcome::Cancelled => Ok(false),
1139        PromptOutcome::Interrupted => Err("operation interrupted by Ctrl-C".to_string()),
1140        PromptOutcome::Unavailable => {
1141            Err("a non-interactive terminal cannot confirm; pass explicit authorization or run in a TTY".to_string())
1142        }
1143    }
1144}
1145
1146fn parse_install_kind(value: &str) -> Result<InstallKind, String> {
1147    match value {
1148        "tool" => Ok(InstallKind::Tool),
1149        "skill" => Ok(InstallKind::Skill),
1150        _ => Err(format!("unknown managed item kind: {value}")),
1151    }
1152}
1153
1154pub(crate) fn value_after<'a>(
1155    args: &'a [String],
1156    index: usize,
1157    option: &str,
1158) -> Result<&'a str, String> {
1159    args.get(index)
1160        .map(String::as_str)
1161        .ok_or_else(|| format!("missing value after {option}"))
1162}
1163
1164fn reject_command_arguments(command: &str, args: &[String]) -> Result<(), String> {
1165    if args.is_empty() {
1166        Ok(())
1167    } else {
1168        Err(format!("{command} does not accept arguments"))
1169    }
1170}
1171
1172fn print_help() {
1173    let _ = ui::try_print(&CliOutput::HumanHelp(help_text(VERSION)));
1174}
1175
1176fn print_command_help(args: &[String], allow_business_arguments: bool) -> Result<(), String> {
1177    let candidates = args
1178        .iter()
1179        .filter(|argument| *argument != "--help" && *argument != "-h")
1180        .take(2)
1181        .cloned()
1182        .collect::<Vec<_>>();
1183    let mut command_path = candidates.first().cloned().into_iter().collect::<Vec<_>>();
1184    if let Some(candidate) = candidates.get(1) {
1185        let parent_names = command_path.iter().map(String::as_str).collect::<Vec<_>>();
1186        let parent = command_help(&parent_names);
1187        let is_child = parent
1188            .as_ref()
1189            .is_some_and(|help| help.children.iter().any(|(name, _)| name == candidate));
1190        if is_child
1191            || !allow_business_arguments
1192            || parent.is_some_and(|help| !help.children.is_empty())
1193        {
1194            command_path.push(candidate.clone());
1195        }
1196    }
1197    let path = command_path.iter().map(String::as_str).collect::<Vec<_>>();
1198    let Some(help) = command_help(&path) else {
1199        return Err(format!("unknown help command: {}", path.join(" ")));
1200    };
1201    let display_path = command_path.join(" ");
1202    let mut text = format!(
1203        "BotForge {display_path} | {}\n\nUsage: {}\n",
1204        help.about, help.usage
1205    );
1206    if !help.children.is_empty() {
1207        let section = if path == ["generate"] {
1208            "Formats"
1209        } else {
1210            "Commands"
1211        };
1212        text.push_str(&format!("\n{section}:\n"));
1213        let width = help
1214            .children
1215            .iter()
1216            .map(|(name, _)| name.len())
1217            .max()
1218            .unwrap_or(0)
1219            + 2;
1220        for (name, description) in help.children {
1221            text.push_str(&format!("  {name:<width$}{description}\n"));
1222        }
1223    }
1224    for section in help.sections {
1225        let width = section
1226            .rows
1227            .iter()
1228            .map(|(name, _)| name.len())
1229            .max()
1230            .unwrap_or(0)
1231            + 2;
1232        text.push_str(&format!("\n{}:\n", section.title));
1233        for (name, description) in section.rows {
1234            text.push_str(&format!("  {name:<width$}{description}\n"));
1235        }
1236    }
1237    let _ = ui::try_print(&CliOutput::HumanHelp(text));
1238    Ok(())
1239}
1240
1241fn cmd_generate(args: &[String]) -> Result<(), String> {
1242    let format = args
1243        .first()
1244        .map(String::as_str)
1245        .ok_or("generate requires a format")?;
1246    if args.len() != 1 {
1247        return Err("generate accepts exactly one format".to_string());
1248    }
1249    let (kind, text) = match format {
1250        "completion" => (RawKind::Completion, completion()),
1251        "man" => (RawKind::ManPage, man_page(VERSION)),
1252        "schema" => (RawKind::Schema, schema().to_string()),
1253        "json" => (
1254            RawKind::Json,
1255            serde_json::to_string_pretty(
1256                &top_level_commands()
1257                    .iter()
1258                    .map(|(name, about)| {
1259                        serde_json::json!({"name": name, "usage": name, "about": about})
1260                    })
1261                    .collect::<Vec<_>>(),
1262            )
1263            .unwrap()
1264                + "\n",
1265        ),
1266        "jsonl" => (
1267            RawKind::JsonLines,
1268            top_level_commands()
1269                .iter()
1270                .map(|(name, about)| {
1271                    serde_json::json!({"name": name, "usage": name, "about": about}).to_string()
1272                })
1273                .collect::<Vec<_>>()
1274                .join("\n")
1275                + "\n",
1276        ),
1277        value => return Err(format!("unknown generate format: {value}")),
1278    };
1279    ui::try_print(&CliOutput::Raw { kind, text })
1280        .map_err(|error| format!("failed to write generated content: {error}"))
1281}