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