pub mod color;
pub mod header;
pub mod output;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use crate::cli::color::ColorChoice;
use crate::cli::output::{OutputConfig, OutputFormat};
#[derive(Debug, Parser)]
#[command(
name = "saferskills",
version,
about = "Every AI capability, independently scanned.",
after_help = "An OpenLatch project · https://saferskills.ai",
disable_help_subcommand = true
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Human)]
pub format: OutputFormat,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub no_color: bool,
#[arg(long, global = true, value_enum)]
pub color: Option<ColorChoice>,
#[arg(long, short, global = true)]
pub verbose: bool,
#[arg(long, short, global = true)]
pub quiet: bool,
#[arg(long, global = true)]
pub yes: bool,
#[arg(long, global = true)]
pub force: bool,
#[arg(long = "non-interactive", visible_alias = "no-input", global = true)]
pub non_interactive: bool,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
#[command(visible_alias = "check")]
Info(InfoArgs),
Install(InstallArgs),
Uninstall(UninstallArgs),
Update(UpdateArgs),
List(ListArgs),
#[command(visible_alias = "find")]
Search(SearchArgs),
Capability(CapabilityArgs),
Agent(AgentArgs),
Doctor(DoctorArgs),
Completion {
shell: clap_complete::Shell,
},
#[command(hide = true)]
Man,
}
#[derive(Debug, clap::Args)]
pub struct InfoArgs {
pub name: String,
#[arg(long)]
pub kind: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct InstallArgs {
pub name: String,
#[arg(long = "to")]
pub to: Vec<String>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub project: bool,
#[arg(long)]
pub update: bool,
#[arg(long)]
pub reinstall: bool,
#[arg(long = "seen-score")]
pub seen_score: Option<u8>,
#[arg(long = "dry-run")]
pub dry_run: bool,
}
#[derive(Debug, clap::Args)]
pub struct UninstallArgs {
pub name: String,
#[arg(long = "from")]
pub from: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct UpdateArgs {
pub name: Option<String>,
#[arg(long)]
pub all: bool,
#[arg(long = "prune-red")]
pub prune_red: bool,
}
#[derive(Debug, clap::Args)]
pub struct ListArgs {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum SortArg {
#[value(name = "most_installed")]
MostInstalled,
#[value(name = "least_installed")]
LeastInstalled,
#[value(name = "recent")]
Recent,
#[value(name = "oldest")]
Oldest,
#[value(name = "highest_score")]
HighestScore,
#[value(name = "lowest_score")]
LowestScore,
#[value(name = "most_starred")]
MostStarred,
#[value(name = "name_asc")]
NameAsc,
#[value(name = "name_desc")]
NameDesc,
#[value(name = "most_active")]
MostActive,
#[value(name = "least_active")]
LeastActive,
}
impl SortArg {
pub fn as_server_key(self) -> &'static str {
match self {
SortArg::MostInstalled => "most_installed",
SortArg::LeastInstalled => "least_installed",
SortArg::Recent => "recent",
SortArg::Oldest => "oldest",
SortArg::HighestScore => "highest_score",
SortArg::LowestScore => "lowest_score",
SortArg::MostStarred => "most_starred",
SortArg::NameAsc => "name_asc",
SortArg::NameDesc => "name_desc",
SortArg::MostActive => "most_active",
SortArg::LeastActive => "least_active",
}
}
}
#[derive(Debug, clap::Args)]
pub struct SearchArgs {
pub query: Option<String>,
#[arg(long = "kind")]
pub kind: Vec<String>,
#[arg(long = "agent")]
pub agent: Vec<String>,
#[arg(long = "scan-tier")]
pub scan_tier: Vec<String>,
#[arg(long = "score-min", value_parser = clap::value_parser!(u8).range(0..=100))]
pub score_min: Option<u8>,
#[arg(long, value_enum)]
pub sort: Option<SortArg>,
#[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=100))]
pub limit: u32,
#[arg(long = "show-low-quality")]
pub show_low_quality: bool,
}
#[derive(Debug, clap::Args)]
pub struct CapabilityArgs {
#[arg(conflicts_with = "to")]
pub target: Option<String>,
#[arg(long = "to", value_name = "AGENT", conflicts_with = "target")]
pub to: Vec<String>,
#[arg(long)]
pub private: bool,
#[arg(long)]
pub detailed: bool,
}
#[derive(Debug, clap::Args)]
pub struct AgentArgs {
#[arg(long = "to", value_name = "AGENT")]
pub to: Vec<String>,
#[arg(long, value_name = "NAME")]
pub name: Option<String>,
#[arg(long)]
pub private: bool,
#[arg(long = "fail-on", value_name = "THRESHOLD")]
pub fail_on: Option<String>,
#[arg(long, value_name = "PATH")]
pub baseline: Option<PathBuf>,
#[arg(long = "no-telemetry")]
pub no_telemetry: bool,
#[arg(long = "no-components")]
pub no_components: bool,
#[arg(long = "print-skill")]
pub print_skill: bool,
#[arg(long = "submit-blob", value_name = "FILE")]
pub submit_blob: Option<PathBuf>,
#[arg(long, value_name = "MINUTES", default_value_t = 45, value_parser = clap::value_parser!(u64).range(1..=1440))]
pub timeout: u64,
}
#[derive(Debug, clap::Args)]
pub struct DoctorArgs {
#[arg(long)]
pub fix: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct Interaction {
pub yes: bool,
pub force: bool,
pub non_interactive: bool,
}
pub fn interaction(cli: &Cli) -> Interaction {
Interaction {
yes: cli.yes,
force: cli.force,
non_interactive: cli.non_interactive || cli.json,
}
}
pub fn build_output_config(cli: &Cli) -> OutputConfig {
let format = if cli.json {
OutputFormat::Json
} else {
cli.format
};
let color = if format == OutputFormat::Json || format == OutputFormat::Md {
false
} else {
color::is_color_enabled(cli.color, cli.no_color)
};
OutputConfig {
format,
verbose: cli.verbose,
quiet: cli.quiet,
color,
}
}
pub fn command_label(cmd: &Commands) -> (&'static str, Option<&'static str>) {
match cmd {
Commands::Info(_) => ("info", None),
Commands::Install(_) => ("install", None),
Commands::Uninstall(_) => ("uninstall", None),
Commands::Update(_) => ("update", None),
Commands::List(_) => ("list", None),
Commands::Search(_) => ("search", None),
Commands::Capability(_) => ("capability", None),
Commands::Agent(_) => ("agent", None),
Commands::Doctor(_) => ("doctor", None),
Commands::Completion { .. } => ("completion", None),
Commands::Man => ("man", None),
}
}
pub const KNOWN_SUBCOMMANDS: &[&str] = &[
"info",
"check",
"install",
"uninstall",
"update",
"list",
"search",
"find",
"capability",
"agent",
"doctor",
"completion",
];
pub fn suggest_subcommand(input: &str) -> Option<String> {
let lower = input.to_ascii_lowercase();
KNOWN_SUBCOMMANDS
.iter()
.map(|c| (*c, strsim::jaro_winkler(&lower, c)))
.filter(|(_, score)| *score > 0.7)
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(c, _)| c.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn json_flag_forces_json_and_no_color() {
let cli = Cli::parse_from(["saferskills", "--json", "info", "x"]);
let cfg = build_output_config(&cli);
assert!(cfg.is_json());
assert!(!cfg.color);
}
#[test]
fn info_has_check_alias() {
let cli = Cli::parse_from(["saferskills", "check", "github-mcp"]);
assert!(matches!(cli.command, Some(Commands::Info(_))));
}
#[test]
fn suggest_subcommand_finds_close_typo() {
assert_eq!(suggest_subcommand("instal").as_deref(), Some("install"));
assert_eq!(suggest_subcommand("info").as_deref(), Some("info"));
assert!(suggest_subcommand("zzzzzz").is_none());
}
#[test]
fn command_label_from_grammar() {
let cli = Cli::parse_from(["saferskills", "update", "--all"]);
assert_eq!(
command_label(cli.command.as_ref().unwrap()),
("update", None)
);
}
}