mod catalog;
mod config_tui;
mod config_ui;
mod doctor;
mod installer;
mod manual;
mod model;
mod output;
mod paths;
mod project_store;
mod update;
use std::io::{self, IsTerminal};
use std::path::PathBuf;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use installer::InstallScope;
#[derive(Debug, Parser)]
#[command(
name = "skiller",
version,
about = "Declaratively manage project and global skills from registered catalogs"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Catalog {
#[command(subcommand)]
command: CatalogCommand,
},
Config {
#[arg(short = 'g', long)]
global: bool,
#[arg(long, value_name = "SKILL=STATE")]
set: Vec<String>,
#[arg(long, value_name = "AGENT,...")]
agents: Option<String>,
},
Doctor {
#[arg(short = 'g', long)]
global: bool,
#[arg(long)]
repair: bool,
#[arg(long, requires = "repair")]
yes: bool,
},
Update {
#[arg(short = 'g', long)]
global: bool,
#[arg(long)]
yes: bool,
},
Install {
#[arg(short = 'g', long)]
global: bool,
},
}
#[derive(Debug, Subcommand)]
enum CatalogCommand {
AddSkill(AddSkillArgs),
Configure(CatalogConfigureArgs),
}
#[derive(Debug, Args)]
struct CatalogConfigureArgs {
alias: String,
source: String,
#[arg(long, value_name = "REF")]
r#ref: Option<String>,
#[arg(long)]
authoring_root: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct AddSkillArgs {
alias: String,
source: PathBuf,
scope: String,
#[arg(long)]
global: bool,
}
fn scope(global: bool) -> Result<installer::InstallScope> {
Ok(if global {
installer::InstallScope::Global
} else {
installer::InstallScope::Project(paths::project_root()?)
})
}
fn parse_agents(value: Option<String>) -> Result<Vec<String>> {
let agents: Vec<String> = value
.map(|value| value.split(',').map(str::to_owned).collect())
.unwrap_or_default();
if !agents.is_empty() {
installer::validate_agents(&agents)?;
}
Ok(agents)
}
fn main() -> Result<()> {
let machine = !io::stdout().is_terminal();
match Cli::parse().command {
None if machine => anyhow::bail!(
"interactive configuration requires a terminal; run `skiller config` or another command"
),
None => match paths::project_root() {
Err(_) => config_ui::configure(InstallScope::Global, false, &[], &[]),
Ok(project_root) => match config_tui::choose_configuration_scope()? {
Some(config_tui::ConfigurationScope::Project) => {
config_ui::configure(InstallScope::Project(project_root), false, &[], &[])
}
Some(config_tui::ConfigurationScope::Global) => {
config_ui::configure(InstallScope::Global, false, &[], &[])
}
None => Ok(()),
},
},
Some(Command::Catalog { command }) => match command {
CatalogCommand::AddSkill(args) => catalog::add_skill(
&args.alias,
&args.source,
&args.scope,
if args.global {
catalog::CatalogEligibility::Global
} else {
catalog::CatalogEligibility::Project
},
),
CatalogCommand::Configure(args) => catalog::configure_catalog(
&args.alias,
&args.source,
args.r#ref.as_deref(),
args.authoring_root.as_deref(),
),
},
Some(Command::Config {
global,
set,
agents,
}) => config_ui::configure(scope(global)?, machine, &set, &parse_agents(agents)?),
Some(Command::Doctor {
global,
repair,
yes,
}) => doctor::run(scope(global)?, machine, repair, yes),
Some(Command::Update { global, yes }) => update::run(scope(global)?, machine, yes),
Some(Command::Install { global }) => installer::install(scope(global)?),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_cli_selects_interactive_configuration() {
assert!(Cli::try_parse_from(["skiller"]).unwrap().command.is_none());
}
#[test]
fn lean_cli_rejects_removed_compatibility_surface() {
for args in [
vec!["skiller", "add-catalog", "pyg", "source"],
vec!["skiller", "migrate"],
vec!["skiller", "config", "--print"],
vec!["skiller", "doctor", "--print"],
vec!["skiller", "update", "--check"],
vec!["skiller", "update", "--json"],
] {
assert!(Cli::try_parse_from(args).is_err());
}
}
#[test]
fn doctor_requires_repair_for_yes() {
assert!(Cli::try_parse_from(["skiller", "doctor", "--yes"]).is_err());
assert!(Cli::try_parse_from(["skiller", "doctor", "-g", "--repair", "--yes"]).is_ok());
}
#[test]
fn catalog_commands_use_alias_owned_authoring() {
assert!(
Cli::try_parse_from([
"skiller",
"catalog",
"configure",
"private",
"git@example/catalog.git",
"--ref",
"main",
"--authoring-root",
"/catalog",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"skiller",
"catalog",
"add-skill",
"private",
"/candidate",
"learning",
])
.is_ok()
);
}
#[test]
fn config_has_one_complete_selection_state() {
assert!(
Cli::try_parse_from([
"skiller",
"config",
"--set",
"pyg/release=enable-ignored",
"--agents",
"universal,pi",
])
.is_ok()
);
assert!(Cli::try_parse_from(["skiller", "config", "--set-gitignore", "x=true"]).is_err());
}
}