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";
30pub 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}