use anyhow::Context;
use clap::{Parser, Subcommand, error::ErrorKind};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use upskill::fmt::{FmtReport, fmt};
use upskill::lint::{LintReport, lint};
use upskill::pipeline::{
DoctorReport, InstallReport, ItemKind, ListReport, ListedBundle, ListedItem, OrphanReason,
RemoveFilter, RemoveReport, UpdateMode, UpdateReport, UpdateStatus, doctor,
install_with_lockfile, list, remove, update,
};
use upskill::scaffold::{NewKind, ScaffoldReport, scaffold};
use upskill::search;
use upskill::source::parse_install_source;
const EXIT_SUCCESS: i32 = 0;
const EXIT_ERROR: i32 = 1;
const EXIT_USAGE: i32 = 2;
const EXIT_INTERRUPTED: i32 = 130;
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
#[derive(Parser, Debug)]
#[command(name = "upskill")]
#[command(about = "Author and distribute AI-assistance content across coding agents")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Add {
source: String,
#[arg(short = 'g', long = "global")]
global: bool,
},
Remove {
names: Vec<String>,
#[arg(long = "source")]
source: Option<String>,
#[arg(short = 'g', long = "global")]
global: bool,
},
Update {
names: Vec<String>,
#[arg(long = "dry-run")]
dry_run: bool,
#[arg(short = 'g', long = "global")]
global: bool,
},
List {
#[arg(short = 'g', long = "global")]
global: bool,
},
Doctor {
#[arg(short = 'g', long = "global")]
global: bool,
},
Search {
query: String,
#[arg(long, default_value = "10")]
limit: usize,
},
Lint {
paths: Vec<PathBuf>,
#[arg(long)]
strict: bool,
},
Fmt {
paths: Vec<PathBuf>,
},
New {
kind: String,
name: String,
},
}
fn main() {
if let Err(err) = install_signal_handlers() {
eprintln!("error: {}", err);
std::process::exit(EXIT_ERROR);
}
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => {
let code = map_clap_error(&err);
let _ = err.print();
std::process::exit(code);
}
};
let mut exit_code = match cli.command {
Commands::Add { source, global } => run_add(&source, global),
Commands::Remove {
names,
source,
global,
} => run_remove(&names, source.as_deref(), global),
Commands::Update {
names,
dry_run,
global,
} => run_update(&names, dry_run, global),
Commands::List { global } => run_list(global),
Commands::Doctor { global } => run_doctor(global),
Commands::Search { query, limit } => run_search(&query, limit),
Commands::Lint { paths, strict } => run_lint(&paths, strict),
Commands::Fmt { paths } => run_fmt(&paths),
Commands::New { kind, name } => run_new(&kind, &name),
};
if was_interrupted() {
exit_code = EXIT_INTERRUPTED;
}
std::process::exit(exit_code);
}
fn install_signal_handlers() -> anyhow::Result<()> {
ctrlc::set_handler(|| {
INTERRUPTED.store(true, Ordering::SeqCst);
})
.context("failed to install signal handler")
}
fn was_interrupted() -> bool {
INTERRUPTED.load(Ordering::SeqCst)
}
fn map_clap_error(err: &clap::Error) -> i32 {
match err.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => EXIT_SUCCESS,
_ => EXIT_USAGE,
}
}
fn run_add(source: &str, global: bool) -> i32 {
let parsed = match parse_install_source(source) {
Ok(s) => s,
Err(err) => {
eprintln!("error: {}", err);
return EXIT_USAGE;
}
};
let target = match install_target(global) {
Ok(t) => t,
Err(err) => {
eprintln!("error: {}", err);
return EXIT_ERROR;
}
};
match install_with_lockfile(&parsed, &target) {
Ok(report) => {
print_install_report(&report, source);
EXIT_SUCCESS
}
Err(err) => {
eprintln!("error: {:#}", err);
EXIT_ERROR
}
}
}
fn install_target(global: bool) -> anyhow::Result<PathBuf> {
if global {
std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow::anyhow!("HOME is not set"))
} else {
std::env::current_dir().context("failed to get current directory")
}
}
fn print_install_report(report: &InstallReport, source: &str) {
use std::collections::BTreeMap;
println!("Installed {} files from {}", report.items.len(), source);
let mut grouped: BTreeMap<(ItemKind, String), Vec<&'static str>> = BTreeMap::new();
for item in &report.items {
grouped
.entry((item.kind, item.name.clone()))
.or_default()
.push(item.client.name());
}
for ((kind, name), clients) in grouped {
let kind_label = match kind {
ItemKind::Rule => "rule ",
ItemKind::Skill => "skill",
ItemKind::Agent => "agent",
};
println!(" {} {:<32} โ {}", kind_label, name, clients.join(", "));
}
}
fn run_remove(names: &[String], source: Option<&str>, global: bool) -> i32 {
let filter = match (names.is_empty(), source) {
(true, Some(s)) => RemoveFilter::BySource(s.to_string()),
(false, None) => RemoveFilter::ByNames(names.to_vec()),
(true, None) => {
eprintln!(
"error: nothing to remove โ pass one or more item names, or `--source <label>`"
);
return EXIT_USAGE;
}
(false, Some(_)) => {
eprintln!("error: `--source` and item names are mutually exclusive");
return EXIT_USAGE;
}
};
let target = match install_target(global) {
Ok(t) => t,
Err(err) => {
eprintln!("error: {}", err);
return EXIT_ERROR;
}
};
match remove(&target, filter) {
Ok(report) => {
print_remove_report(&report);
EXIT_SUCCESS
}
Err(err) => {
eprintln!("error: {:#}", err);
EXIT_ERROR
}
}
}
fn print_remove_report(report: &RemoveReport) {
if report.items.is_empty() {
println!("no matching items in lockfile");
return;
}
println!("Removed {} item(s)", report.items.len());
for item in &report.items {
let kind_label = match item.kind {
ItemKind::Rule => "rule ",
ItemKind::Skill => "skill",
ItemKind::Agent => "agent",
};
println!(
" {} {:<32} ({} file(s))",
kind_label,
item.name,
item.deleted_files.len()
);
}
}
fn run_update(names: &[String], dry_run: bool, global: bool) -> i32 {
let target = match install_target(global) {
Ok(t) => t,
Err(err) => {
eprintln!("error: {}", err);
return EXIT_ERROR;
}
};
let mode = if dry_run {
UpdateMode::DryRun
} else {
UpdateMode::Apply
};
match update(&target, names, mode) {
Ok(report) => {
print_update_report(&report, dry_run);
EXIT_SUCCESS
}
Err(err) => {
eprintln!("error: {:#}", err);
EXIT_ERROR
}
}
}
fn print_update_report(report: &UpdateReport, dry_run: bool) {
if report.items.is_empty() {
println!("nothing to update โ lockfile is empty");
return;
}
let header = if dry_run {
"Dry-run: would update"
} else {
"Updated"
};
let changes = report
.items
.iter()
.filter(|i| !matches!(i.status, UpdateStatus::UpToDate))
.count();
println!("{header} {} of {} item(s)", changes, report.items.len());
for item in &report.items {
let kind_label = match item.kind {
ItemKind::Rule => "rule ",
ItemKind::Skill => "skill",
ItemKind::Agent => "agent",
};
let status = match &item.status {
UpdateStatus::UpToDate => "up to date".to_string(),
UpdateStatus::Updated { new_hash, .. } => format!("updated โ {}", short(new_hash)),
UpdateStatus::WouldChange { new_hash, .. } => {
format!("would change โ {}", short(new_hash))
}
};
println!(" {} {:<32} {}", kind_label, item.name, status);
}
}
fn short(h: &Option<String>) -> String {
match h {
Some(s) if s.len() >= 8 => s[..8].to_string(),
Some(s) => s.clone(),
None => "<no hash>".to_string(),
}
}
fn run_doctor(global: bool) -> i32 {
let target = match install_target(global) {
Ok(t) => t,
Err(err) => {
eprintln!("error: {}", err);
return EXIT_ERROR;
}
};
match doctor(&target) {
Ok(report) => {
print_doctor_report(&report);
if report.is_clean() {
EXIT_SUCCESS
} else {
EXIT_ERROR
}
}
Err(err) => {
eprintln!("error: {:#}", err);
EXIT_ERROR
}
}
}
fn print_doctor_report(report: &DoctorReport) {
if report.is_clean() {
println!("doctor: clean");
return;
}
if !report.missing_outputs.is_empty() {
println!(
"doctor: missing per-client outputs ({} item(s)) โ reinstall to fix",
report.missing_outputs.len()
);
for m in &report.missing_outputs {
println!(" {} {}", kind_label(m.kind), m.name);
for path in &m.missing_files {
println!(" - {}", path.display());
}
}
}
if !report.stale_hashes.is_empty() {
println!(
"doctor: SSOT hash drift on local sources ({} item(s)) โ `upskill update` to fix",
report.stale_hashes.len()
);
for s in &report.stale_hashes {
println!(" {} {} ({})", kind_label(s.kind), s.name, s.source);
}
}
if !report.orphan_entries.is_empty() {
println!(
"doctor: lockfile entries with no recoverable source ({} item(s)) โ `upskill remove` to clear",
report.orphan_entries.len()
);
for o in &report.orphan_entries {
let reason = match o.reason {
OrphanReason::LocalPathGone => "local path gone",
OrphanReason::ItemMissingInSource => "item not in source",
};
println!(
" {} {} ({}) โ {}",
kind_label(o.kind),
o.name,
o.source,
reason
);
}
}
}
fn kind_label(kind: ItemKind) -> &'static str {
match kind {
ItemKind::Rule => "rule ",
ItemKind::Skill => "skill",
ItemKind::Agent => "agent",
}
}
fn run_list(global: bool) -> i32 {
let target = match install_target(global) {
Ok(t) => t,
Err(err) => {
eprintln!("error: {}", err);
return EXIT_ERROR;
}
};
match list(&target) {
Ok(report) => {
print_list_report(&report);
EXIT_SUCCESS
}
Err(err) => {
eprintln!("error: {:#}", err);
EXIT_ERROR
}
}
}
fn print_list_report(report: &ListReport) {
if report.is_empty() {
println!("no items installed");
return;
}
print_list_section("rules", &report.rules);
print_list_section("skills", &report.skills);
print_list_section("agents", &report.agents);
print_list_bundles(&report.bundles);
}
fn print_list_section(label: &str, items: &[ListedItem]) {
if items.is_empty() {
return;
}
println!("{label} ({})", items.len());
for item in items {
let pinned = match &item.git_ref {
Some(r) => format!("@{r}"),
None => String::new(),
};
println!(" {:<32} {}{}", item.name, item.source, pinned);
}
}
fn print_list_bundles(bundles: &[ListedBundle]) {
if bundles.is_empty() {
return;
}
println!("bundles ({})", bundles.len());
for bundle in bundles {
let pinned = match &bundle.git_ref {
Some(r) => format!("@{r}"),
None => String::new(),
};
println!(" {:<32} {}{}", bundle.name, bundle.source, pinned);
}
}
fn run_lint(paths: &[PathBuf], strict: bool) -> i32 {
match lint(paths, strict) {
Ok(report) => {
print_lint_report(&report);
if report.has_errors() {
EXIT_ERROR
} else {
EXIT_SUCCESS
}
}
Err(err) => {
eprintln!("error: {:#}", err);
EXIT_USAGE
}
}
}
fn print_lint_report(report: &LintReport) {
for f in &report.findings {
let line = f.line.map(|n| format!(":{n}")).unwrap_or_default();
println!(
"{}: {}{} [{}] {}",
f.severity.label(),
f.path.display(),
line,
f.rule_id,
f.message
);
}
println!(
"{} file(s) checked, {} findings",
report.files_checked,
report.findings.len()
);
}
fn run_fmt(paths: &[PathBuf]) -> i32 {
match fmt(paths) {
Ok(report) => {
print_fmt_report(&report);
EXIT_SUCCESS
}
Err(err) => {
eprintln!("error: {:#}", err);
EXIT_USAGE
}
}
}
fn print_fmt_report(report: &FmtReport) {
if report.files_changed.is_empty() {
println!("{} file(s) checked, all formatted", report.files_checked);
return;
}
for path in &report.files_changed {
println!("formatted: {}", path.display());
}
println!(
"{} file(s) checked, {} file(s) changed",
report.files_checked,
report.files_changed.len()
);
}
fn run_new(kind: &str, name: &str) -> i32 {
let parsed_kind = match NewKind::parse(kind) {
Ok(k) => k,
Err(err) => {
eprintln!("error: {}", err);
return EXIT_USAGE;
}
};
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(err) => {
eprintln!("error: get current directory: {}", err);
return EXIT_ERROR;
}
};
match scaffold(&cwd, parsed_kind, name) {
Ok(report) => {
print_scaffold_report(&report);
EXIT_SUCCESS
}
Err(err) => {
let msg = format!("{:#}", err);
eprintln!("error: {}", msg);
if msg.contains("consumer project") {
EXIT_USAGE
} else {
EXIT_ERROR
}
}
}
}
fn print_scaffold_report(report: &ScaffoldReport) {
let kind_label = match report.kind {
NewKind::Rule => "rule",
NewKind::Skill => "skill",
NewKind::Agent => "agent",
};
println!(
"scaffolded {kind_label} `{}` at {}",
report.name,
report.written.display()
);
println!("edit the file and replace the TODO body before publishing.");
}
fn run_search(query: &str, limit: usize) -> i32 {
match search::search(query, limit) {
Err(err) => {
eprintln!("error: {}", err);
EXIT_ERROR
}
Ok(results) if results.is_empty() => {
println!("no skills found for '{}'", query);
EXIT_SUCCESS
}
Ok(results) => {
for skill in &results {
let repo = skill
.source
.trim_start_matches("github/")
.trim_start_matches("gitlab/");
println!(
"{}\t{} installs\tupskill add {} --skill {}",
skill.name, skill.installs, repo, skill.name
);
}
EXIT_SUCCESS
}
}
}