mod actions;
mod apt_mirror_actions;
mod launcher;
mod state_actions;
mod typed;
pub use crate::cli::actions::main_entry;
pub use crate::model::{InstallOptions, Profile};
pub(crate) struct CommandHelp {
pub usage: String,
pub about: &'static str,
pub children: Vec<(String, String)>,
pub sections: &'static [HelpSection],
}
pub(crate) struct HelpSection {
pub title: &'static str,
pub rows: &'static [(&'static str, &'static str)],
}
const NO_CHILDREN: &[(&str, &str)] = &[];
const NO_SECTIONS: &[HelpSection] = &[];
const PLAN_ARGUMENTS: &[(&str, &str)] = &[(
"[PROFILE]",
"Installation profile; defaults to standard when omitted",
)];
const RESUME_ARGUMENTS: &[(&str, &str)] = &[(
"[PROFILE]",
"Original profile; uses the journal value when omitted (required for legacy journals)",
)];
const REMOVE_ARGUMENTS: &[(&str, &str)] = &[("<NAME>", "Managed tool or skill name to remove")];
const STATUS_ARGUMENTS: &[(&str, &str)] = &[(
"[PROFILE]",
"Installation profile; defaults to standard when omitted",
)];
const CONFIG_OPTIONS: &[(&str, &str)] = &[
(
"--config <FILE>",
"Configuration file; defaults to bot-forge.toml",
),
(
"--overlay <FILE>",
"Apply a configuration overlay file; repeat as needed",
),
];
const INIT_EXECUTION: &[(&str, &str)] = &[("-f, --force", "Overwrite an existing configuration")];
const INIT_OUTPUT: &[(&str, &str)] = &[(
"--output <FILE>",
"Write the generated configuration to FILE",
)];
const CACHE_STATUS_OPTIONS: &[(&str, &str)] =
&[("--format <FORMAT>", "Output format: human or json")];
const APT_MIRROR_OPTIONS: &[(&str, &str)] = &[
(
"--config <FILE>",
"Configuration file; defaults to bot-forge.toml",
),
(
"--overlay <FILE>",
"Apply a configuration overlay file; repeat as needed",
),
];
const PLAN_CONFIG: &[(&str, &str)] = &[
(
"--config <FILE>",
"Configuration file; defaults to bot-forge.toml",
),
(
"--overlay <FILE>",
"Apply a configuration overlay file; repeat as needed",
),
];
const PLAN_SELECTION: &[(&str, &str)] = &[
(
"--only <NAME>",
"Include only a component; repeat as needed",
),
("--exclude <NAME>", "Exclude a component; repeat as needed"),
];
const PLAN_OUTPUT: &[(&str, &str)] = &[
("--format <FORMAT>", "Output format: human or json"),
("--why", "Explain plan decisions"),
];
const INSTALL_CONFIG: &[(&str, &str)] = PLAN_CONFIG;
const INSTALL_SELECTION: &[(&str, &str)] = PLAN_SELECTION;
const INSTALL_EXECUTION: &[(&str, &str)] = &[("-y, --yes", "Skip confirmation prompts")];
const INSTALL_OUTPUT: &[(&str, &str)] = &[
("--format <FORMAT>", "Output format: human, json, or jsonl"),
("-q, --quiet", "Suppress human output"),
];
const RESUME_TRANSACTION: &[(&str, &str)] = &[
("--run <ID>", "Resume the selected run"),
("--abandon <ID>", "Abandon the selected run"),
];
const RESUME_CONFIGURATION: &[(&str, &str)] = &[
(
"--config <FILE>",
"Configuration file; defaults to bot-forge.toml",
),
(
"--overlay <FILE>",
"Apply a configuration overlay file; repeat as needed",
),
];
const RESUME_SELECTION: &[(&str, &str)] = &[
(
"--only <NAME>",
"Include only a component; repeat as needed",
),
("--exclude <NAME>", "Exclude a component; repeat as needed"),
];
const REMOVE_SELECTION: &[(&str, &str)] = &[("--kind <KIND>", "Limit removal to tool or skill")];
const REMOVE_EXECUTION: &[(&str, &str)] = &[
("--dry-run", "Preview changes without applying them"),
("-y, --yes", "Skip confirmation prompts"),
];
const STATUS_CONFIGURATION: &[(&str, &str)] = &[
(
"--config <FILE>",
"Configuration file; defaults to bot-forge.toml",
),
(
"--overlay <FILE>",
"Apply a configuration overlay file; repeat as needed",
),
];
const STATUS_OUTPUT: &[(&str, &str)] = &[("--format <FORMAT>", "Output format: human or json")];
const DOCTOR_CONFIGURATION: &[(&str, &str)] = &[(
"--config <FILE>",
"Configuration file; defaults to bot-forge.toml",
)];
const DOCTOR_OUTPUT: &[(&str, &str)] = &[("--format <FORMAT>", "Output format: human or json")];
const EFFECTIVE_CONFIGURATION: &[(&str, &str)] = &[
(
"--config <FILE>",
"Configuration file; defaults to bot-forge.toml",
),
(
"--overlay <FILE>",
"Apply a configuration overlay file; repeat as needed",
),
];
const EFFECTIVE_OUTPUT: &[(&str, &str)] = &[
(
"-v, --verbose",
"Allow --show-sensitive to reveal sensitive values",
),
(
"--show-sensitive",
"Include sensitive values; requires --verbose",
),
];
const CACHE_GC_SELECTION: &[(&str, &str)] =
&[("--max-age-days <DAYS>", "Remove entries older than DAYS")];
const CACHE_GC_EXECUTION: &[(&str, &str)] =
&[("--dry-run", "Preview changes without applying them")];
const CACHE_GC_OUTPUT: &[(&str, &str)] = &[("--format <FORMAT>", "Output format: human or json")];
const PLAN_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Arguments",
rows: PLAN_ARGUMENTS,
},
HelpSection {
title: "Configuration",
rows: PLAN_CONFIG,
},
HelpSection {
title: "Selection",
rows: PLAN_SELECTION,
},
HelpSection {
title: "Output",
rows: PLAN_OUTPUT,
},
];
const INSTALL_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Arguments",
rows: PLAN_ARGUMENTS,
},
HelpSection {
title: "Configuration",
rows: INSTALL_CONFIG,
},
HelpSection {
title: "Selection",
rows: INSTALL_SELECTION,
},
HelpSection {
title: "Execution",
rows: INSTALL_EXECUTION,
},
HelpSection {
title: "Output",
rows: INSTALL_OUTPUT,
},
];
const RESUME_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Arguments",
rows: RESUME_ARGUMENTS,
},
HelpSection {
title: "Transaction",
rows: RESUME_TRANSACTION,
},
HelpSection {
title: "Configuration",
rows: RESUME_CONFIGURATION,
},
HelpSection {
title: "Selection",
rows: RESUME_SELECTION,
},
];
const REMOVE_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Arguments",
rows: REMOVE_ARGUMENTS,
},
HelpSection {
title: "Selection",
rows: REMOVE_SELECTION,
},
HelpSection {
title: "Execution",
rows: REMOVE_EXECUTION,
},
];
const STATUS_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Arguments",
rows: STATUS_ARGUMENTS,
},
HelpSection {
title: "Configuration",
rows: STATUS_CONFIGURATION,
},
HelpSection {
title: "Output",
rows: STATUS_OUTPUT,
},
];
const DOCTOR_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Configuration",
rows: DOCTOR_CONFIGURATION,
},
HelpSection {
title: "Output",
rows: DOCTOR_OUTPUT,
},
];
const CONFIG_SECTIONS: &[HelpSection] = &[HelpSection {
title: "Configuration",
rows: CONFIG_OPTIONS,
}];
const EFFECTIVE_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Configuration",
rows: EFFECTIVE_CONFIGURATION,
},
HelpSection {
title: "Output",
rows: EFFECTIVE_OUTPUT,
},
];
const INIT_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Output",
rows: INIT_OUTPUT,
},
HelpSection {
title: "Execution",
rows: INIT_EXECUTION,
},
];
const CACHE_STATUS_SECTIONS: &[HelpSection] = &[HelpSection {
title: "Output",
rows: CACHE_STATUS_OPTIONS,
}];
const CACHE_GC_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Selection",
rows: CACHE_GC_SELECTION,
},
HelpSection {
title: "Execution",
rows: CACHE_GC_EXECUTION,
},
HelpSection {
title: "Output",
rows: CACHE_GC_OUTPUT,
},
];
const APT_MIRROR_SECTIONS: &[HelpSection] = &[HelpSection {
title: "Configuration",
rows: APT_MIRROR_OPTIONS,
}];
const APT_MIRROR_WRITE_SECTIONS: &[HelpSection] = &[
HelpSection {
title: "Configuration",
rows: APT_MIRROR_OPTIONS,
},
HelpSection {
title: "Confirmation",
rows: &[("-y, --yes", "Skip the confirmation prompt")],
},
];
pub(crate) fn command_help(path: &[&str]) -> Option<CommandHelp> {
let (about, children, sections): (&str, &[(&str, &str)], &[HelpSection]) = match path {
["config"] => (
"Create, validate, and inspect configuration",
&[],
NO_SECTIONS,
),
["config", "init"] => ("Create bot-forge.toml", &[], INIT_SECTIONS),
["config", "validate"] => ("Validate configuration", &[], CONFIG_SECTIONS),
["config", "effective"] => (
"Print effective configuration",
NO_CHILDREN,
EFFECTIVE_SECTIONS,
),
["config", "explain"] => (
"Explain configuration sources",
NO_CHILDREN,
CONFIG_SECTIONS,
),
["config", "help"] => (
"Print this message or the help of the given subcommand(s)",
NO_CHILDREN,
NO_SECTIONS,
),
["plan"] => (
"Create or explain an installation plan",
NO_CHILDREN,
PLAN_SECTIONS,
),
["install"] => (
"Execute an installation plan",
NO_CHILDREN,
INSTALL_SECTIONS,
),
["resume"] => (
"Resume or abandon an unfinished transaction",
NO_CHILDREN,
RESUME_SECTIONS,
),
["remove"] => (
"Remove a managed installation",
NO_CHILDREN,
REMOVE_SECTIONS,
),
["status"] => (
"Show managed installation status",
NO_CHILDREN,
STATUS_SECTIONS,
),
["cache"] => ("Inspect or reclaim the cache", NO_CHILDREN, NO_SECTIONS),
["cache", "status"] => ("Show cache status", NO_CHILDREN, CACHE_STATUS_SECTIONS),
["cache", "gc"] => (
"Reclaim expired cache entries",
NO_CHILDREN,
CACHE_GC_SECTIONS,
),
["cache", "help"] => (
"Print this message or the help of the given subcommand(s)",
NO_CHILDREN,
NO_SECTIONS,
),
["doctor"] => ("Run system diagnostics", NO_CHILDREN, DOCTOR_SECTIONS),
["apt-mirror"] => ("Manage APT mirrors", NO_CHILDREN, NO_SECTIONS),
["apt-mirror", "show"] => (
"Preview the configured mirror",
NO_CHILDREN,
APT_MIRROR_SECTIONS,
),
["apt-mirror", "check"] => (
"Validate the configured mirror",
NO_CHILDREN,
APT_MIRROR_SECTIONS,
),
["apt-mirror", "apply"] => (
"Apply the configured mirror",
NO_CHILDREN,
APT_MIRROR_WRITE_SECTIONS,
),
["apt-mirror", "restore"] => (
"Restore the previous mirror",
NO_CHILDREN,
APT_MIRROR_WRITE_SECTIONS,
),
["apt-mirror", "help"] => (
"Print this message or the help of the given subcommand(s)",
NO_CHILDREN,
NO_SECTIONS,
),
["generate"] => (
"Generate protocol artifacts or documentation",
NO_CHILDREN,
NO_SECTIONS,
),
[
"generate",
"completion" | "man" | "schema" | "json" | "jsonl",
] => ("Generate a protocol artifact", NO_CHILDREN, NO_SECTIONS),
_ => return None,
};
let usage = match path {
["config"] => "bot-forge config <COMMAND>".to_string(),
["config", "init"] => "bot-forge config init [OPTIONS]".to_string(),
["config", "validate"] => "bot-forge config validate [OPTIONS]".to_string(),
["config", "effective"] => "bot-forge config effective [OPTIONS]".to_string(),
["config", "explain"] => "bot-forge config explain [OPTIONS]".to_string(),
["config", "help"] => "bot-forge config help [COMMAND]".to_string(),
["plan"] => "bot-forge plan [OPTIONS] [PROFILE]".to_string(),
["install"] => "bot-forge install [OPTIONS] [PROFILE]".to_string(),
["resume"] => "bot-forge resume [OPTIONS] [PROFILE]".to_string(),
["remove"] => "bot-forge remove [OPTIONS] <NAME>".to_string(),
["status"] => "bot-forge status [OPTIONS] [PROFILE]".to_string(),
["cache"] => "bot-forge cache <COMMAND>".to_string(),
["cache", "status"] => "bot-forge cache status [OPTIONS]".to_string(),
["cache", "gc"] => "bot-forge cache gc [OPTIONS]".to_string(),
["cache", "help"] => "bot-forge cache help [COMMAND]".to_string(),
["doctor"] => "bot-forge doctor [OPTIONS]".to_string(),
["apt-mirror"] => "bot-forge apt-mirror <COMMAND>".to_string(),
["apt-mirror", "help"] => "bot-forge apt-mirror help [COMMAND]".to_string(),
["apt-mirror", action] => format!("bot-forge apt-mirror {action} [OPTIONS]"),
["generate"] => "bot-forge generate <FORMAT>".to_string(),
["generate", format] => format!("bot-forge generate {format}"),
_ => format!("bot-forge {}", path.join(" ")),
};
let children = if matches!(path, ["config"] | ["cache"] | ["apt-mirror"]) {
typed_children(path)
} else if path == ["generate"] {
typed_generate_formats()
} else {
children
.iter()
.map(|(name, description)| ((*name).to_owned(), (*description).to_owned()))
.collect()
};
Some(CommandHelp {
usage,
about,
children,
sections,
})
}
fn typed_generate_formats() -> Vec<(String, String)> {
let command = typed::command();
let Some(generate) = command
.get_subcommands()
.find(|command| command.get_name() == "generate")
else {
return Vec::new();
};
generate
.get_arguments()
.find(|argument| argument.get_id() == "format")
.map(|argument| {
argument
.get_possible_values()
.into_iter()
.map(|value| {
(
value.get_name().to_owned(),
value
.get_help()
.map_or_else(String::new, |help| help.to_string()),
)
})
.collect()
})
.unwrap_or_default()
}
fn typed_children(path: &[&str]) -> Vec<(String, String)> {
let mut command = typed::command();
for name in path {
let Some(next) = command
.get_subcommands()
.find(|candidate| candidate.get_name() == *name)
.cloned()
else {
return Vec::new();
};
command = next;
}
let mut children = command
.get_subcommands()
.filter_map(|child| {
child
.get_about()
.map(|about| (child.get_name().to_owned(), about.to_string()))
})
.collect::<Vec<_>>();
children.push((
"help".to_owned(),
"Print this message or the help of the given subcommand(s)".to_owned(),
));
children
}
pub(crate) fn top_level_commands() -> Vec<(String, String)> {
typed::command()
.get_subcommands()
.filter_map(|command| {
command
.get_about()
.map(|about| (command.get_name().to_owned(), about.to_string()))
})
.collect()
}
pub(crate) fn help_text(version: &str) -> String {
let commands = top_level_commands();
let mut text = format!(
"BotForge CLI {version} | Configurable Rust tool installer\n\nUsage: bot-forge [OPTIONS] <COMMAND>\n\nCommands:\n"
);
let width = commands
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or(0)
+ 2;
for (name, about) in commands {
text.push_str(&format!(" {name:<width$}{about}\n"));
}
text.push_str(
"\nEnvironment:\n BOT_FORGE_HOME Override the data directory for state, cache, logs, and artifacts.\n\nData directory defaults:\n Windows %LOCALAPPDATA%\\bot-forge\n Linux $XDG_DATA_HOME/bot-forge or ~/.local/share/bot-forge\n macOS ~/Library/Application Support/bot-forge\n",
);
text.push_str(
"\nNotes:\n Run `bot-forge generate completion|man|schema` to create raw artifacts.\n Run `bot-forge config validate` before installing from a custom configuration.\n",
);
text
}
pub(crate) fn completion() -> String {
format!(
"complete -W \"{}\" bot-forge\n",
top_level_commands()
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
.join(" ")
)
}
pub(crate) fn man_page(version: &str) -> String {
let mut text = format!(
".TH BOT-FORGE 1\n.SH NAME\nbot-forge - configurable installer\n.SH VERSION\n{version}\n.SH COMMANDS\n"
);
for (name, about) in top_level_commands() {
text.push_str(&format!(".TP\n.B {name}\n{about}\n"));
}
text
}
pub(crate) fn schema() -> &'static str {
include_str!("../../schema/config.json")
}
#[cfg(test)]
mod tests {
use crate::cli::command_help;
#[test]
fn nested_command_groups_expose_the_standard_help_command() {
for path in [
["config"].as_slice(),
["cache"].as_slice(),
["apt-mirror"].as_slice(),
] {
let commands = command_help(path).expect("group help").children;
assert!(commands.iter().any(|(name, _)| name == "help"));
let mut help_path = path.to_vec();
help_path.push("help");
assert!(command_help(&help_path).is_some());
}
}
}