use std::collections::BTreeSet;
use std::env;
use std::io::{self, IsTerminal};
use std::path::PathBuf;
use std::process;
use crate::cancellation::install_handler;
use crate::cli::apt_mirror_actions::cmd_apt_mirror;
use crate::cli::launcher::{LauncherChoice, choose};
use crate::cli::state_actions::{cmd_cache, cmd_resume, cmd_status};
use crate::cli::typed::{command, validate};
use crate::cli::{command_help, completion, help_text, man_page, schema, top_level_commands};
use crate::config::{init_config, load_config_with_overlays};
use crate::diagnostics::doctor_checks;
use crate::events::{LifecycleEvent, LifecycleSession};
use crate::execution::{PreparedExecution, execute, preview_plan, select};
use crate::items::{RUSTUP_REMOVAL_UNSUPPORTED, remove_installed};
use crate::model::{InstallKind, InstallOptions, InstallOutcome, Profile};
use crate::planning::{PlanRequest, TargetPlatform, build_plan, plan_profile};
use crate::redaction::mask_secrets;
use crate::reporting::write_install_report_log;
use crate::state::read_registry_document;
use crate::ui::input::{PromptOutcome, confirm as confirm_install, wait_for_enter_or_interrupt};
use crate::ui::{self, CliOutput, Document, OutputSilencer, RawKind, StatusKind};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const RETURN_TO_LAUNCHER: &str = "return to launcher";
const EXIT_LAUNCHER: &str = "exit bot-forge";
pub fn main_entry() {
ui::install_terminal_panic_hook();
if let Err(error) = install_handler() {
ui::stderr_status(
StatusKind::Warning,
&format!("Could not install the Ctrl-C handler: {error}"),
);
}
let arguments = env::args().skip(1).collect::<Vec<_>>();
let machine_output = requested_machine_output(&arguments);
if let Err(error) = run(arguments) {
let code = exit_code_for_error(&error);
if let Some(kind) = machine_output {
let text = match kind {
RawKind::Json => serde_json::to_string_pretty(&serde_json::json!({
"ok": false,
"error": {
"code": stable_error_code(code),
"message": error,
"hint": error_hint(code, &error)
}
})),
RawKind::JsonLines => serde_json::to_string(&serde_json::json!({
"ok": false,
"error": {
"code": stable_error_code(code),
"message": error,
"hint": error_hint(code, &error)
}
})),
_ => unreachable!("only JSON protocols are prescanned"),
}
.unwrap_or_else(|_| "{\"ok\":false}".to_string());
let _ = ui::try_print(&CliOutput::Raw {
kind,
text: format!("{text}\n"),
});
process::exit(code);
}
let document = error_document(code, &error);
if let Err(output_error) = ui::try_print_error(&document)
&& output_error.kind() != io::ErrorKind::BrokenPipe
{
process::exit(1);
}
process::exit(code);
}
}
fn requested_machine_output(arguments: &[String]) -> Option<RawKind> {
let mut iter = arguments.iter();
while let Some(argument) = iter.next() {
let value = if argument == "--format" {
iter.next().map(String::as_str)
} else {
argument.strip_prefix("--format=")
};
match value {
Some("json") => return Some(RawKind::Json),
Some("jsonl") => return Some(RawKind::JsonLines),
_ => {}
}
}
None
}
fn error_document(code: i32, error: &str) -> Document {
Document::with_subtitle("bot-forge", "error")
.field("Error code", stable_error_code(code))
.status(StatusKind::Error, error)
.hint(error_hint(code, error))
}
fn unsupported_rustup_removal(error: &str) -> bool {
error
.strip_prefix("configuration error: ")
.is_some_and(|message| message.starts_with(RUSTUP_REMOVAL_UNSUPPORTED))
}
fn error_hint(code: i32, error: &str) -> &'static str {
if unsupported_rustup_removal(error) {
return "Use rustup to manage this tool; see rustup toolchain uninstall --help or rustup component remove --help.";
}
match code {
2 => "Run bot-forge help to inspect valid arguments, or run bot-forge config validate.",
3 => "Review the plan and retry; add --yes in automation.",
5 => "Review the install log and recent command output, then run bot-forge install.",
6 => "Check the target path and permissions; do not bypass managed-path safeguards.",
7 => "Run bot-forge doctor --format json to inspect DNS, TLS, and proxy settings.",
8 => {
"Run bot-forge resume to inspect the unfinished transaction or restore from its backup."
}
_ => "Run bot-forge doctor for environment diagnostics.",
}
}
pub(crate) fn print_human(document: Document) -> Result<(), String> {
ui::try_print(&CliOutput::Human(document))
.map_err(|error| format!("failed to write output: {error}"))
}
fn stable_error_code(exit_code: i32) -> String {
format!("BF{:04}", 1000 + exit_code)
}
fn exit_code_for_error(error: &str) -> i32 {
if unsupported_rustup_removal(error) {
2
} else if error == "installation cancelled" || error.contains("non-interactive terminal") {
3
} else if error.contains("verification failed") || error.contains("installation failed") {
5
} else if error.contains("permission") || error.contains("unsafe") || error.contains("refuse") {
6
} else if error.contains("verification")
|| error.contains("network")
|| error.contains("download")
{
7
} else if error.contains("registry") || error.contains("corrupt state") {
8
} else if error.contains("unknown")
|| error.contains("unexpected argument")
|| error.contains("invalid help command path")
|| error.contains("generate accepts")
|| error.contains("missing")
|| error.contains("does not support")
|| error.contains("only accepts")
|| error.contains("only one")
|| error.contains("cannot")
|| error.contains("must be valid")
|| error.contains("does not accept")
|| error.starts_with("configuration error")
|| error.starts_with("parse error")
{
2
} else {
1
}
}
fn run(args: Vec<String>) -> Result<(), String> {
if let Some(path) = conventional_help_path(&args) {
let names = path.iter().map(String::as_str).collect::<Vec<_>>();
if command_help(&names).is_none() {
return Err(format!("invalid help command path: {}", names.join(" ")));
}
return print_command_help(&path, false);
}
validate(&args)?;
let args = expand_long_options(args);
reject_duplicate_options(&args)?;
let Some(command) = args.first().map(String::as_str) else {
if io::stdin().is_terminal() && io::stdout().is_terminal() {
return run_launcher_menu();
}
print_help();
return Ok(());
};
if args
.iter()
.skip(1)
.any(|argument| argument == "--help" || argument == "-h")
{
validate_help_arguments(&args)?;
return print_command_help(&args, true);
}
match command {
"config" => cmd_config(&args[1..]),
"plan" => cmd_plan(&args[1..]),
"install" => cmd_install(&args[1..]),
"resume" => cmd_resume(&args[1..]),
"remove" => cmd_remove(&args[1..]),
"status" => cmd_status(&args[1..]),
"cache" => cmd_cache(&args[1..]),
"doctor" => cmd_doctor(&args[1..]),
"apt-mirror" => cmd_apt_mirror(&args[1..]),
"generate" => cmd_generate(&args[1..]),
"-h" | "--help" => {
reject_command_arguments(command, &args[1..])?;
print_help();
Ok(())
}
"help" => {
if args.len() == 1 {
print_help();
} else {
let path = args[1..].iter().map(String::as_str).collect::<Vec<_>>();
if command_help(&path).is_none() {
return Err(format!("unknown help command: {}", path.join(" ")));
}
print_command_help(&args[1..], false)?;
}
Ok(())
}
"-V" | "--version" => {
reject_command_arguments(command, &args[1..])?;
ui::stdout_line(&format!("bot-forge {VERSION}"));
Ok(())
}
unknown => Err(format!("unknown command: {unknown}")),
}
}
fn conventional_help_path(args: &[String]) -> Option<Vec<String>> {
let index = args.iter().position(|argument| argument == "help")?;
if index == 0
|| index + 1 != args.len()
|| args[..index]
.iter()
.any(|argument| argument.starts_with('-'))
{
return None;
}
let mut command = command();
let mut path = Vec::new();
for value in &args[..index] {
let next = command
.get_subcommands()
.find(|candidate| candidate.get_name() == value)
.cloned();
let Some(next) = next else {
if command.get_subcommands().next().is_none() {
continue;
}
return None;
};
path.push(value.clone());
command = next;
}
(!path.is_empty()).then_some(path)
}
fn reject_duplicate_options(args: &[String]) -> Result<(), String> {
let repeatable = ["--overlay", "--only", "--exclude"];
let mut seen = BTreeSet::new();
for argument in args.iter().filter(|argument| argument.starts_with('-')) {
let canonical = match argument.as_str() {
"-f" => "--force",
"-h" => "--help",
"-q" => "--quiet",
"-v" => "--verbose",
"-y" => "--yes",
_ => argument.as_str(),
};
if repeatable.contains(&canonical) {
continue;
}
if !seen.insert(canonical) {
return Err(format!(
"argument {canonical} cannot be used multiple times"
));
}
}
Ok(())
}
fn validate_help_arguments(args: &[String]) -> Result<(), String> {
let mut filtered = args
.iter()
.filter(|argument| *argument != "--help" && *argument != "-h")
.cloned()
.collect::<Vec<_>>();
let command = filtered.first().cloned().unwrap_or_default();
if command.is_empty() {
return Err("unexpected argument found".to_string());
}
filtered.remove(0);
let subcommand = match command.as_str() {
"config" | "cache" | "apt-mirror" => filtered.first().map(String::as_str),
"generate" => filtered.first().map(String::as_str),
_ => None,
}
.map(str::to_owned);
if subcommand.is_none()
&& matches!(
command.as_str(),
"config" | "cache" | "apt-mirror" | "generate"
)
&& filtered.is_empty()
{
return Ok(());
}
if subcommand.is_some() {
filtered.remove(0);
}
match (command.as_str(), subcommand.as_deref()) {
("plan", _) => {
let filtered = filtered
.into_iter()
.filter(|argument| argument != "--why")
.collect::<Vec<_>>();
parse_install_options(&filtered).map(|_| ())
}
("install", _) => parse_install_options(&filtered).map(|_| ()),
("resume", _) => validate_resume_help_arguments(&filtered),
("remove", _) => validate_remove_help_arguments(&filtered),
("status", _) => validate_status_help_arguments(&filtered),
("doctor", _) => validate_options_with_values(&filtered, &["--format", "--config"], &[], 0),
("config", Some("init")) => {
validate_options_with_values(&filtered, &["--output"], &["--force", "-f"], 0)
}
("config", Some("validate" | "effective" | "explain")) => {
validate_config_help_arguments(subcommand.as_deref().unwrap_or_default(), &filtered)
}
("cache", Some("status")) => validate_options_with_values(&filtered, &["--format"], &[], 0),
("cache", Some("gc")) => validate_options_with_values(
&filtered,
&["--format", "--max-age-days"],
&["--dry-run"],
0,
),
("apt-mirror", Some("show" | "check" | "apply" | "restore")) => {
validate_options_with_values(&filtered, &["--config", "--overlay"], &[], 0)
}
("generate", Some("completion" | "man" | "schema" | "json" | "jsonl")) => {
if filtered.is_empty() {
Ok(())
} else {
Err("generate accepts one format and no additional arguments".to_string())
}
}
("config" | "cache" | "apt-mirror" | "generate", _) => {
Err("invalid help command path".to_string())
}
_ => Err("invalid help command path".to_string()),
}
}
fn validate_resume_help_arguments(args: &[String]) -> Result<(), String> {
let mut install = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--run" | "--abandon" => {
index += 1;
if args.get(index).is_none_or(|value| value.starts_with('-')) {
return Err(format!("{} requires a value", args[index - 1]));
}
}
value => install.push(value.to_string()),
}
index += 1;
}
parse_install_options(&install).map(|_| ())
}
fn validate_remove_help_arguments(args: &[String]) -> Result<(), String> {
validate_options_with_values(args, &["--kind"], &["--dry-run", "--yes", "-y"], 1)
}
fn validate_status_help_arguments(args: &[String]) -> Result<(), String> {
validate_options_with_values(args, &["--format", "--config", "--overlay"], &[], 1)
}
fn validate_config_help_arguments(command: &str, args: &[String]) -> Result<(), String> {
let mut allowed_flags = Vec::new();
if command == "effective" {
allowed_flags.extend(["--verbose", "-v", "--show-sensitive"]);
}
validate_options_with_values(args, &["--config", "--overlay"], &allowed_flags, 0)
}
fn validate_options_with_values(
args: &[String],
value_options: &[&str],
flag_options: &[&str],
max_positionals: usize,
) -> Result<(), String> {
let mut positionals = 0;
let mut index = 0;
while index < args.len() {
let argument = &args[index];
let (name, inline) = argument
.split_once('=')
.map_or((argument.as_str(), None), |(name, value)| {
(name, Some(value))
});
if value_options.contains(&name) {
if inline.is_some_and(str::is_empty)
|| inline.is_none()
&& args
.get(index + 1)
.is_none_or(|value| value.starts_with('-'))
{
return Err(format!("{name} requires a value"));
}
if inline.is_none() {
index += 1;
}
} else if !flag_options.contains(&name) {
if argument.starts_with('-') {
return Err(format!("unexpected argument '{argument}' found"));
}
positionals += 1;
if positionals > max_positionals {
return Err("unexpected argument found".to_string());
}
}
index += 1;
}
Ok(())
}
fn expand_long_options(args: Vec<String>) -> Vec<String> {
args.into_iter()
.flat_map(|argument| {
if let Some((name, value)) = argument.split_once('=')
&& name.starts_with("--")
&& !value.is_empty()
{
return vec![name.to_string(), value.to_string()];
}
vec![argument]
})
.collect()
}
fn cmd_config(args: &[String]) -> Result<(), String> {
let action = args
.first()
.map(String::as_str)
.ok_or("config requires one of: init, validate, effective, explain")?;
if action == "init" {
return cmd_init(&args[1..]);
}
if !matches!(action, "validate" | "effective" | "explain") {
return Err(format!(
"unknown config action: {action}; expected init, validate, effective, or explain"
));
}
let mut path = None;
let mut verbose = false;
let mut show_sensitive = false;
let mut overlays = Vec::new();
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--config" => {
index += 1;
path = Some(PathBuf::from(value_after(args, index, "--config")?));
}
"--overlay" => {
index += 1;
overlays.push(PathBuf::from(value_after(args, index, "--overlay")?));
}
"--verbose" | "-v" => verbose = true,
"--show-sensitive" => show_sensitive = true,
value => return Err(format!("unknown config option: {value}")),
}
index += 1;
}
if action != "effective" && (verbose || show_sensitive) {
return Err(format!(
"config {action} does not support --verbose or --show-sensitive"
));
}
if show_sensitive && !verbose {
return Err("--show-sensitive requires --verbose".to_string());
}
let loaded =
load_config_with_overlays(path.as_deref(), &overlays).map_err(|error| error.to_string())?;
match action {
"validate" => print_human(
Document::with_subtitle("bot-forge", "config validate")
.status(StatusKind::Success, "Configuration is valid")
.field("Catalog", loaded.document.catalog.clone()),
)?,
"effective" => {
let mut effective = toml::to_string_pretty(&loaded.document)
.map_err(|error| format!("failed to write effective configuration: {error}"))?;
if !(verbose && show_sensitive) {
effective = mask_secrets(&effective);
}
ui::try_print(&CliOutput::Raw {
kind: RawKind::Toml,
text: effective,
})
.map_err(|error| format!("failed to write effective configuration: {error}"))?;
}
"explain" => {
let mut document = Document::with_subtitle("bot-forge", "config explain")
.field(
"Source",
loaded
.path
.as_deref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "built-in configuration".to_string()),
)
.field("Catalog", loaded.document.catalog.clone())
.field("Digest", loaded.document.catalog_digest.clone())
.hint("Explicit scalar and array values override; components and environment mutations replace by id; tables merge recursively by field.")
.blank()
.section("Expanded values");
for (group, components) in &loaded.document.groups {
document = document.item(format!("group:{group}"), components.join(", "));
}
for (name, value) in &loaded.document.versions {
document = document.item(format!("version:{name}"), value);
}
document = document.blank().section("Field sources");
for (field, origin) in &loaded.origins.fields {
document = document.item(field, origin);
}
print_human(document)?;
}
_ => unreachable!("config action validated above"),
}
Ok(())
}
fn run_launcher_menu() -> Result<(), String> {
loop {
let choice = choose(VERSION)?;
match choice {
LauncherChoice::Install(profile) => {
let error = run_launcher_action(launcher_label(&profile), || {
cmd_install_from_launcher(&[profile.as_str().to_string()])
});
if error.as_deref().is_some_and(is_return_to_launcher) {
continue;
}
if error.as_deref().is_some_and(is_exit_launcher) {
ui::stdout_status(StatusKind::Info, "Exited bot-forge.");
return Ok(());
}
wait_for_launcher_close()?;
return Ok(());
}
LauncherChoice::Status => {
run_launcher_action("Show installation status", || cmd_status(&[]));
wait_for_launcher_close()?;
return Ok(());
}
LauncherChoice::Doctor => {
run_launcher_action("Run system diagnostics", || cmd_doctor(&[]));
wait_for_launcher_close()?;
return Ok(());
}
LauncherChoice::Help => print_help(),
LauncherChoice::Exit => {
ui::stdout_status(StatusKind::Info, "Exited bot-forge.");
return Ok(());
}
}
}
}
fn launcher_label(profile: &Profile) -> &'static str {
match profile {
Profile::Minimal => "Install minimal environment",
Profile::Standard => "Install standard environment",
Profile::Advanced => "Install advanced environment",
Profile::Custom(_) => "Install custom environment",
}
}
fn run_launcher_action<F>(label: &str, action: F) -> Option<String>
where
F: FnOnce() -> Result<(), String>,
{
ui::stdout_status(StatusKind::Info, &format!("Starting {label}"));
let activity = ui::progress::ActivityStatus::new(label, launcher_activity_detail(label));
let error = action().err();
if let Some(error) = &error
&& !is_return_to_launcher(error)
&& !is_exit_launcher(error)
{
ui::stdout_status(StatusKind::Error, &format!("{label}: {error}"));
}
drop(activity);
error
}
fn is_return_to_launcher(error: &str) -> bool {
error == RETURN_TO_LAUNCHER || error == format!("command error: {RETURN_TO_LAUNCHER}")
}
fn is_exit_launcher(error: &str) -> bool {
error == EXIT_LAUNCHER || error == format!("command error: {EXIT_LAUNCHER}")
}
fn launcher_activity_detail(label: &str) -> &'static str {
match label {
label if label.starts_with("Install ") => "Detecting installed tools and resolving plan",
"Show installation status" => "Reading managed registry and checking tool versions",
"Run system diagnostics" => "Checking toolchain, network, and platform",
_ => "Preparing the selected operation",
}
}
fn wait_for_launcher_close() -> Result<(), String> {
ui::stdout_status(
StatusKind::Hint,
"Press Enter to close this window, or Ctrl-C to exit.",
);
wait_for_enter_or_interrupt()
.map_err(|error| format!("failed to read close confirmation: {error}"))
}
fn cmd_init(args: &[String]) -> Result<(), String> {
let mut force = false;
let mut output = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--force" | "-f" => force = true,
"--output" => {
index += 1;
output = Some(PathBuf::from(value_after(args, index, "--output")?));
}
value => return Err(format!("unknown init option: {value}")),
}
index += 1;
}
let path = init_config(output.as_deref(), force).map_err(|error| error.to_string())?;
print_human(
Document::with_subtitle("bot-forge", "config init")
.status(StatusKind::Success, "Configuration created")
.labeled_path("Path", path.display().to_string()),
)
}
fn cmd_install(args: &[String]) -> Result<(), String> {
cmd_install_inner(args, false)
}
fn cmd_install_from_launcher(args: &[String]) -> Result<(), String> {
cmd_install_inner(args, true)
}
fn cmd_install_inner(args: &[String], allow_launcher_back: bool) -> Result<(), String> {
let (mut options, output_mode) = parse_install_options(args)?;
let json = output_mode == InstallOutputMode::Json;
let silencer = if matches!(
output_mode,
InstallOutputMode::Json | InstallOutputMode::JsonLines | InstallOutputMode::Quiet
) {
options.status_bar = false;
Some(
OutputSilencer::stdout()
.map_err(|error| format!("failed to isolate machine output: {error}"))?,
)
} else {
None
};
let loaded = load_config_with_overlays(options.config_path.as_deref(), &options.overlay_paths)
.map_err(|error| error.to_string())?;
let plan = build_plan(PlanRequest {
document: &loaded.document,
origins: &loaded.origins,
profile: options.profile.as_str(),
target: TargetPlatform::host(),
only: &options.only,
exclude: &options.exclude,
source_root: loaded.path.as_deref().and_then(std::path::Path::parent),
})
.map_err(|error| error.to_string())?;
let lifecycle = (output_mode == InstallOutputMode::JsonLines).then(|| {
let session =
LifecycleSession::begin(format!("{}-{}", &plan.plan_hash[..16], std::process::id()));
session.emit(None, None, None, LifecycleEvent::Planned);
session
});
let selection = select(&plan, options.yes).map_err(|error| {
let message = error.to_string();
if is_return_to_launcher(&message) && !allow_launcher_back {
"installation cancelled".to_string()
} else {
message
}
})?;
let already_satisfied = if selection.components.is_empty() {
let preview = preview_plan(&plan).map_err(|error| error.to_string())?;
preview.missing_tools().is_empty()
&& preview.missing_skills().is_empty()
&& !preview
.tools
.iter()
.any(|status| status.outdated && status.installable)
} else {
false
};
if selection.components.is_empty() && !already_satisfied {
return Err("no components selected; installation cancelled".to_string());
}
if !options.yes && !already_satisfied && !confirm_action("Run planned install and save state?")?
{
return Err("installation cancelled".to_string());
}
let selection = if already_satisfied {
crate::execution::ExecutionSelection::all(&plan)
} else {
selection
};
let report = execute(PreparedExecution {
selection,
plan,
options: options.clone(),
})
.map_err(|error| error.to_string())?;
let log_path = write_install_report_log(&report).map_err(|error| error.to_string())?;
if let Some(session) = &lifecycle {
session.emit(
None,
None,
None,
LifecycleEvent::RunCompleted {
outcome: install_outcome_name(report.outcome).into(),
duration_ms: report.duration_ms,
log_path: log_path.display().to_string(),
},
);
}
drop(silencer);
if json {
let value = serde_json::json!({
"profile": options.profile.as_str(),
"log_path": log_path,
"report": report,
});
let text = serde_json::to_string_pretty(&value)
.map_err(|error| format!("failed to serialize install result: {error}"))?;
ui::try_print(&CliOutput::Raw {
kind: RawKind::Json,
text: format!("{text}\n"),
})
.map_err(|error| format!("failed to write install result: {error}"))?;
}
match report.outcome {
InstallOutcome::Success => {}
InstallOutcome::Cancelled => return Err("installation cancelled".to_string()),
InstallOutcome::Failed => {
return Err("installation or post-install verification failed".to_string());
}
}
if matches!(
output_mode,
InstallOutputMode::Json | InstallOutputMode::JsonLines | InstallOutputMode::Quiet
) {
return Ok(());
}
let mut document = Document::with_subtitle("bot-forge", "install")
.status(StatusKind::Success, "Installation complete")
.field("Profile", options.profile.as_str())
.labeled_path("Log", log_path.display().to_string());
if !report.entries.is_empty() {
document = document.blank().section("Managed items");
}
for entry in report.entries {
document = document.item(
format!("{} {}", entry.kind.as_str(), entry.name),
format!("{} targets", entry.targets.len()),
);
}
print_human(document)
}
fn cmd_plan(args: &[String]) -> Result<(), String> {
let why = args.iter().any(|arg| arg == "--why");
let filtered = args
.iter()
.filter(|arg| *arg != "--why")
.cloned()
.collect::<Vec<_>>();
let (options, output_mode) = parse_install_options(&filtered)?;
if options.yes
|| matches!(
output_mode,
InstallOutputMode::JsonLines | InstallOutputMode::Quiet
)
{
return Err("plan does not support --yes, --format jsonl, or --quiet".to_string());
}
if why && output_mode == InstallOutputMode::Json {
return Err("plan --why cannot be combined with --format json".to_string());
}
if output_mode == InstallOutputMode::Json {
print_plan_json(&options)
} else if why {
print_plan_why(&options)
} else {
print_plan(&options)
}
}
fn print_plan_why(options: &InstallOptions) -> Result<(), String> {
let plan = plan_profile(options).map_err(|error| error.to_string())?;
let mut document = Document::with_subtitle("bot-forge", "plan explain")
.field("Profile", plan.profile.clone())
.field("Plan", plan.plan_hash.clone())
.field(
"Certificate preflight",
if plan.certificate_preflight.is_some() {
"enabled"
} else {
"none"
},
);
for component in &plan.components {
let name = component.display_name.as_deref().map_or_else(
|| component.id.clone(),
|name| format!("{name} ({})", component.id),
);
let resources = plan
.nodes
.iter()
.filter(|node| node.component == component.id)
.flat_map(|node| node.resources.iter().map(|claim| claim.key.clone()))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let origin = plan
.origins
.get(&format!("components.{}", component.id))
.or_else(|| plan.origins.get("document"))
.map(String::as_str)
.unwrap_or("builtin");
document = document
.blank()
.section(name)
.field("Requested by", component.requested_by.join(", "))
.field(
"Provider",
component.variant.as_deref().unwrap_or("component"),
)
.field("Dependencies", display_or_none(&component.dependencies))
.field("Resources", display_or_none(&resources))
.field("Origin", origin);
}
print_human(document)
}
fn display_or_none(values: &[String]) -> String {
if values.is_empty() {
"none".to_string()
} else {
values.join(", ")
}
}
fn print_plan_json(options: &InstallOptions) -> Result<(), String> {
let plan = plan_profile(options).map_err(|error| error.to_string())?;
let text = serde_json::to_string_pretty(&plan)
.map_err(|error| format!("failed to serialize install plan: {error}"))?;
ui::try_print(&CliOutput::Raw {
kind: RawKind::Json,
text: format!("{text}\n"),
})
.map_err(|error| format!("failed to write install plan: {error}"))
}
fn print_plan(options: &InstallOptions) -> Result<(), String> {
let plan = plan_profile(options).map_err(|error| error.to_string())?;
let mut document = Document::with_subtitle("bot-forge", "plan")
.field("Profile", plan.profile)
.field("Config", plan.config_hash)
.field("Plan", plan.plan_hash)
.field(
"Certificate preflight",
if plan.certificate_preflight.is_some() {
"enabled"
} else {
"none"
},
)
.blank()
.section("Components");
for component in &plan.components {
document = document.item(&component.id, display_or_none(&component.dependencies));
}
document = document.blank().section("Execution nodes");
for node in &plan.nodes {
document = document.item(format!("{:?}", node.kind), &node.id);
}
print_human(document)
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum InstallOutputMode {
#[default]
Human,
Json,
JsonLines,
Quiet,
}
pub(crate) fn parse_install_options(
args: &[String],
) -> Result<(InstallOptions, InstallOutputMode), String> {
parse_install_options_with_profile(args).map(|(options, output, _)| (options, output))
}
pub(crate) fn parse_install_options_with_profile(
args: &[String],
) -> Result<(InstallOptions, InstallOutputMode, bool), String> {
let mut options = InstallOptions::default();
let mut output_mode = InstallOutputMode::Human;
let mut profile_set = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--config" => {
index += 1;
options.config_path = Some(PathBuf::from(value_after(args, index, "--config")?));
}
"--overlay" => {
index += 1;
options
.overlay_paths
.push(PathBuf::from(value_after(args, index, "--overlay")?));
}
"--yes" | "-y" => options.yes = true,
"--format" => {
index += 1;
output_mode = parse_output_mode(value_after(args, index, "--format")?)?;
}
"--quiet" | "-q" => set_output_mode(&mut output_mode, InstallOutputMode::Quiet)?,
"--only" => {
index += 1;
options
.only
.push(value_after(args, index, "--only")?.to_string());
}
"--exclude" => {
index += 1;
options
.exclude
.push(value_after(args, index, "--exclude")?.to_string());
}
value if value.starts_with('-') => {
return Err(format!("unknown install option: {value}"));
}
value if Profile::parse(value).is_some() => {
if profile_set {
return Err("only one installation profile may be specified".to_string());
}
options.profile = Profile::parse(value)
.ok_or_else(|| format!("unknown installation profile: {value}"))?;
profile_set = true;
}
value => return Err(format!("unknown installation profile: {value}")),
}
index += 1;
}
Ok((options, output_mode, profile_set))
}
fn set_output_mode(
current: &mut InstallOutputMode,
requested: InstallOutputMode,
) -> Result<(), String> {
if *current != InstallOutputMode::Human && *current != requested {
return Err("--format and --quiet cannot be combined".to_string());
}
*current = requested;
Ok(())
}
pub(crate) fn parse_output_mode(value: &str) -> Result<InstallOutputMode, String> {
match value {
"human" => Ok(InstallOutputMode::Human),
"json" => Ok(InstallOutputMode::Json),
"jsonl" => Ok(InstallOutputMode::JsonLines),
_ => Err(format!("unsupported output format: {value}")),
}
}
fn install_outcome_name(outcome: InstallOutcome) -> &'static str {
match outcome {
InstallOutcome::Success => "success",
InstallOutcome::Cancelled => "cancelled",
InstallOutcome::Failed => "failed",
}
}
fn cmd_remove(args: &[String]) -> Result<(), String> {
let mut name = None;
let mut kind = None;
let mut plan_only = false;
let mut yes = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--kind" => {
index += 1;
kind = Some(parse_install_kind(value_after(args, index, "--kind")?)?);
}
"--dry-run" => plan_only = true,
"--yes" | "-y" => yes = true,
value if value.starts_with('-') => {
return Err(format!("unknown remove option: {value}"));
}
value => {
if name.replace(value.to_string()).is_some() {
return Err("remove accepts only one name".to_string());
}
}
}
index += 1;
}
let name = name.ok_or_else(|| "missing managed item name to remove".to_string())?;
let document = read_registry_document().map_err(|error| error.to_string())?;
let matches = document
.entries
.iter()
.filter(|entry| entry.name == name && kind.is_none_or(|kind| entry.kind == kind))
.collect::<Vec<_>>();
if matches.is_empty() {
return Err(format!("managed item not found: {name}"));
}
let mut plan_document = Document::with_subtitle("bot-forge", "remove plan")
.field("Records", matches.len().to_string())
.section("Targets");
for entry in &matches {
plan_document = plan_document.item(
format!("{} {}", entry.kind.as_str(), entry.name),
entry
.targets
.iter()
.map(|target| target.path.display().to_string())
.collect::<Vec<_>>()
.join(", "),
);
}
print_human(plan_document)?;
if plan_only {
return Ok(());
}
if !yes && !confirm_action("Remove the managed item shown above?")? {
return Err("removal cancelled".to_string());
}
let removed =
remove_installed(&name, kind, document.revision).map_err(|error| error.to_string())?;
print_human(
Document::with_subtitle("bot-forge", "remove")
.status(StatusKind::Success, "Removal complete")
.field("Records", removed.len().to_string()),
)
}
fn cmd_doctor(args: &[String]) -> Result<(), String> {
let mut json = false;
let mut config = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--format" => {
index += 1;
json = parse_human_json_format(value_after(args, index, "--format")?)?;
}
"--config" => {
index += 1;
config = Some(PathBuf::from(value_after(args, index, "--config")?));
}
value => return Err(format!("unknown doctor option: {value}")),
}
index += 1;
}
let checks = doctor_checks(config.as_deref());
if json {
let text = serde_json::to_string_pretty(&checks)
.map_err(|error| format!("failed to serialize diagnostics: {error}"))?;
return ui::try_print(&CliOutput::Raw {
kind: RawKind::Json,
text: format!("{text}\n"),
})
.map_err(|error| format!("failed to write diagnostics: {error}"));
}
let mut document = Document::with_subtitle("bot-forge", "doctor").section("Checks");
for check in checks {
let kind = match check.severity.as_str() {
"ok" | "success" => StatusKind::Success,
"warning" | "warn" => StatusKind::Warning,
"error" => StatusKind::Error,
_ => StatusKind::Info,
};
document = document.status_item(check.id, check.summary, kind);
if let Some(suggestion) = check.suggestion {
document = document.hint(suggestion);
}
}
print_human(document)
}
pub(crate) fn parse_human_json_format(value: &str) -> Result<bool, String> {
match value {
"human" => Ok(false),
"json" => Ok(true),
_ => Err(format!("unsupported output format: {value}")),
}
}
pub(crate) fn confirm_action(message: &str) -> Result<bool, String> {
match confirm_install(message, false).map_err(|error| error.to_string())? {
PromptOutcome::Confirmed(()) => Ok(true),
PromptOutcome::Cancelled => Ok(false),
PromptOutcome::Interrupted => Err("operation interrupted by Ctrl-C".to_string()),
PromptOutcome::Unavailable => {
Err("a non-interactive terminal cannot confirm; pass explicit authorization or run in a TTY".to_string())
}
}
}
fn parse_install_kind(value: &str) -> Result<InstallKind, String> {
match value {
"tool" => Ok(InstallKind::Tool),
"skill" => Ok(InstallKind::Skill),
_ => Err(format!("unknown managed item kind: {value}")),
}
}
pub(crate) fn value_after<'a>(
args: &'a [String],
index: usize,
option: &str,
) -> Result<&'a str, String> {
args.get(index)
.map(String::as_str)
.ok_or_else(|| format!("missing value after {option}"))
}
fn reject_command_arguments(command: &str, args: &[String]) -> Result<(), String> {
if args.is_empty() {
Ok(())
} else {
Err(format!("{command} does not accept arguments"))
}
}
fn print_help() {
let _ = ui::try_print(&CliOutput::HumanHelp(help_text(VERSION)));
}
fn print_command_help(args: &[String], allow_business_arguments: bool) -> Result<(), String> {
let candidates = args
.iter()
.filter(|argument| *argument != "--help" && *argument != "-h")
.take(2)
.cloned()
.collect::<Vec<_>>();
let mut command_path = candidates.first().cloned().into_iter().collect::<Vec<_>>();
if let Some(candidate) = candidates.get(1) {
let parent_names = command_path.iter().map(String::as_str).collect::<Vec<_>>();
let parent = command_help(&parent_names);
let is_child = parent
.as_ref()
.is_some_and(|help| help.children.iter().any(|(name, _)| name == candidate));
if is_child
|| !allow_business_arguments
|| parent.is_some_and(|help| !help.children.is_empty())
{
command_path.push(candidate.clone());
}
}
let path = command_path.iter().map(String::as_str).collect::<Vec<_>>();
let Some(help) = command_help(&path) else {
return Err(format!("unknown help command: {}", path.join(" ")));
};
let display_path = command_path.join(" ");
let mut text = format!(
"BotForge {display_path} | {}\n\nUsage: {}\n",
help.about, help.usage
);
if !help.children.is_empty() {
let section = if path == ["generate"] {
"Formats"
} else {
"Commands"
};
text.push_str(&format!("\n{section}:\n"));
let width = help
.children
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or(0)
+ 2;
for (name, description) in help.children {
text.push_str(&format!(" {name:<width$}{description}\n"));
}
}
for section in help.sections {
let width = section
.rows
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or(0)
+ 2;
text.push_str(&format!("\n{}:\n", section.title));
for (name, description) in section.rows {
text.push_str(&format!(" {name:<width$}{description}\n"));
}
}
let _ = ui::try_print(&CliOutput::HumanHelp(text));
Ok(())
}
fn cmd_generate(args: &[String]) -> Result<(), String> {
let format = args
.first()
.map(String::as_str)
.ok_or("generate requires a format")?;
if args.len() != 1 {
return Err("generate accepts exactly one format".to_string());
}
let (kind, text) = match format {
"completion" => (RawKind::Completion, completion()),
"man" => (RawKind::ManPage, man_page(VERSION)),
"schema" => (RawKind::Schema, schema().to_string()),
"json" => (
RawKind::Json,
serde_json::to_string_pretty(
&top_level_commands()
.iter()
.map(|(name, about)| {
serde_json::json!({"name": name, "usage": name, "about": about})
})
.collect::<Vec<_>>(),
)
.unwrap()
+ "\n",
),
"jsonl" => (
RawKind::JsonLines,
top_level_commands()
.iter()
.map(|(name, about)| {
serde_json::json!({"name": name, "usage": name, "about": about}).to_string()
})
.collect::<Vec<_>>()
.join("\n")
+ "\n",
),
value => return Err(format!("unknown generate format: {value}")),
};
ui::try_print(&CliOutput::Raw { kind, text })
.map_err(|error| format!("failed to write generated content: {error}"))
}