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";
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)
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}