use clap::{Command as ClapCommand, CommandFactory, Parser, Subcommand, error::ErrorKind};
#[derive(Debug, Parser)]
#[command(
name = "bot-forge",
version,
about = "Configurable Rust tool installer",
override_usage = "bot-forge [OPTIONS] <COMMAND>",
no_binary_name = true,
disable_help_flag = true,
disable_help_subcommand = true,
disable_version_flag = true
)]
pub(crate) struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Install(InstallArgs),
Plan(PlanArgs),
Status(StatusArgs),
Resume(ResumeArgs),
Remove(RemoveArgs),
Config(ConfigArgs),
Cache(CacheArgs),
Doctor(DoctorArgs),
AptMirror(AptMirrorArgs),
Generate(GenerateArgs),
Help(HelpArgs),
}
#[derive(Debug, clap::Args)]
struct CommonPlanArgs {
#[arg(value_name = "PROFILE")]
profile: Option<String>,
#[arg(long, value_name = "FILE", help_heading = "Configuration")]
config: Option<String>,
#[arg(long, value_name = "FILE", help_heading = "Configuration")]
overlay: Vec<String>,
#[arg(long, value_name = "NAME", help_heading = "Selection")]
only: Vec<String>,
#[arg(long, value_name = "NAME", help_heading = "Selection")]
exclude: Vec<String>,
}
#[derive(Debug, clap::Args)]
struct InstallArgs {
#[command(flatten)]
common: CommonPlanArgs,
#[arg(short = 'y', long, help_heading = "Execution")]
yes: bool,
#[arg(long, value_enum, conflicts_with = "quiet", help_heading = "Output")]
format: Option<InstallFormat>,
#[arg(short = 'q', long, conflicts_with = "format", help_heading = "Output")]
quiet: bool,
}
#[derive(Debug, clap::Args)]
struct PlanArgs {
#[command(flatten)]
common: CommonPlanArgs,
#[arg(long, value_enum, help_heading = "Output")]
format: Option<OutputFormat>,
#[arg(long, help_heading = "Output")]
why: bool,
}
#[derive(Debug, clap::Args)]
struct StatusArgs {
profile: Option<String>,
#[arg(long)]
config: Option<String>,
#[arg(long)]
overlay: Vec<String>,
#[arg(long, value_enum, help_heading = "Output")]
format: Option<OutputFormat>,
}
#[derive(Debug, clap::Args)]
struct ResumeArgs {
profile: Option<String>,
#[arg(long, conflicts_with = "abandon")]
run: Option<String>,
#[arg(
long,
conflicts_with_all = ["run", "profile", "config", "overlay", "only", "exclude"]
)]
abandon: Option<String>,
#[arg(long)]
config: Option<String>,
#[arg(long)]
overlay: Vec<String>,
#[arg(long)]
only: Vec<String>,
#[arg(long)]
exclude: Vec<String>,
}
#[derive(Debug, clap::Args)]
struct RemoveArgs {
name: String,
#[arg(long, value_enum)]
kind: Option<RemoveKind>,
#[arg(long, conflicts_with = "yes")]
dry_run: bool,
#[arg(short = 'y', long, conflicts_with = "dry_run")]
yes: bool,
}
#[derive(Debug, clap::Args)]
struct ConfigArgs {
#[command(subcommand)]
command: ConfigCommand,
}
#[derive(Debug, Subcommand)]
enum ConfigCommand {
Init {
#[arg(long)]
output: Option<String>,
#[arg(short = 'f', long)]
force: bool,
},
Validate(ConfigFiles),
Effective {
#[command(flatten)]
files: ConfigFiles,
#[arg(short = 'v', long)]
verbose: bool,
#[arg(long, requires = "verbose")]
show_sensitive: bool,
},
Explain(ConfigFiles),
}
#[derive(Debug, clap::Args)]
struct ConfigFiles {
#[arg(long)]
config: Option<String>,
#[arg(long)]
overlay: Vec<String>,
}
#[derive(Debug, clap::Args)]
struct CacheArgs {
#[command(subcommand)]
command: CacheCommand,
}
#[derive(Debug, Subcommand)]
enum CacheCommand {
Status {
#[arg(long, value_enum)]
format: Option<OutputFormat>,
},
Gc {
#[arg(long, value_enum)]
format: Option<OutputFormat>,
#[arg(long)]
max_age_days: Option<u64>,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Debug, clap::Args)]
struct DoctorArgs {
#[arg(long)]
config: Option<String>,
#[arg(long, value_enum)]
format: Option<OutputFormat>,
}
#[derive(Debug, clap::Args)]
struct AptMirrorArgs {
#[command(subcommand)]
command: AptMirrorCommand,
}
#[derive(Debug, Subcommand)]
enum AptMirrorCommand {
Show(ConfigFiles),
Check(ConfigFiles),
Apply(AptMirrorWriteArgs),
Restore(AptMirrorWriteArgs),
}
#[derive(Debug, clap::Args)]
struct AptMirrorWriteArgs {
#[command(flatten)]
files: ConfigFiles,
#[arg(short = 'y', long)]
yes: bool,
}
#[derive(Debug, clap::Args)]
struct GenerateArgs {
#[arg(value_enum)]
format: GenerateFormat,
}
#[derive(Debug, clap::Args)]
struct HelpArgs {
command: Option<String>,
subcommand: Option<String>,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum OutputFormat {
Human,
Json,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum InstallFormat {
Human,
Json,
Jsonl,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum RemoveKind {
Tool,
Skill,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum GenerateFormat {
Completion,
Man,
Schema,
Json,
Jsonl,
}
pub(crate) fn validate(arguments: &[String]) -> Result<(), String> {
if arguments.len() == 1 && matches!(arguments[0].as_str(), "-V" | "--version") {
return Ok(());
}
let help_requested = arguments
.iter()
.any(|argument| matches!(argument.as_str(), "-h" | "--help"));
if arguments.first().is_some_and(|argument| argument == "help")
|| arguments.get(1).is_some_and(|argument| argument == "help")
{
return Ok(());
}
let filtered = arguments
.iter()
.filter(|argument| !matches!(argument.as_str(), "-h" | "--help"))
.cloned()
.collect::<Vec<_>>();
if filtered.is_empty() {
return Ok(());
}
match Cli::try_parse_from(filtered) {
Ok(_) => Ok(()),
Err(error)
if help_requested
&& matches!(
error.kind(),
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
| ErrorKind::MissingRequiredArgument
| ErrorKind::MissingSubcommand
) =>
{
Ok(())
}
Err(error) if help_requested && error.kind() == ErrorKind::InvalidSubcommand => {
Err("invalid help command path".to_string())
}
Err(error) => {
let text = error.to_string();
if text.contains("cannot be used multiple times") {
let option = text
.split_once("the argument '")
.and_then(|(_, rest)| rest.split_once('\''))
.map_or("argument", |(option, _)| option);
Err(format!("argument {option} cannot be used multiple times"))
} else if text.contains("unexpected argument '--force'") {
Err("unknown remove option: --force".to_string())
} else {
Err(format!("parse error: {text}"))
}
}
}
}
pub(crate) fn command() -> ClapCommand {
Cli::command()
.name("bot-forge")
.subcommand_required(false)
.arg_required_else_help(false)
}