use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "skill-harness",
about = "Lifecycle management for AI agent skills"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Install {
name: String,
#[arg(short, long)]
file: PathBuf,
#[arg(long, default_value = "auto")]
harness: String,
#[arg(short, long)]
root: Option<PathBuf>,
},
InstallDir {
name: String,
#[arg(short, long)]
source: Option<PathBuf>,
#[arg(long, default_value = "auto")]
harness: String,
#[arg(short, long)]
root: Option<PathBuf>,
},
CheckDir {
name: String,
#[arg(short, long)]
source: Option<PathBuf>,
#[arg(long, default_value = "auto")]
harness: String,
#[arg(short, long)]
root: Option<PathBuf>,
},
Check {
name: String,
#[arg(short, long)]
file: PathBuf,
#[arg(long, default_value = "auto")]
harness: String,
#[arg(short, long)]
root: Option<PathBuf>,
},
Uninstall {
name: String,
#[arg(long, default_value = "auto")]
harness: String,
#[arg(short, long)]
root: Option<PathBuf>,
},
Compose {
#[command(subcommand)]
command: ComposeCommands,
},
Okf {
#[command(subcommand)]
command: OkfCommands,
},
List {
#[arg(short, long)]
root: Option<PathBuf>,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Install {
name,
file,
harness,
root,
} => {
let content = std::fs::read_to_string(&file)?;
for message in skill_harness::okf::single_file_resource_warnings(&file, &content) {
eprintln!("warning: {message}");
}
let config = make_config(&name, &content, &harness)?;
config.install(root.as_deref())?;
}
Commands::InstallDir {
name,
source,
harness,
root,
} => {
let source = resolve_skill_source(&name, source)?;
let content = std::fs::read_to_string(source.join("SKILL.md"))?;
let config = make_config(&name, &content, &harness)?;
config.install_directory(&source, root.as_deref())?;
}
Commands::CheckDir {
name,
source,
harness,
root,
} => {
let source = resolve_skill_source(&name, source)?;
let content = std::fs::read_to_string(source.join("SKILL.md"))?;
let config = make_config(&name, &content, &harness)?;
let ok = config.check_directory(&source, root.as_deref())?;
if !ok {
std::process::exit(1);
}
}
Commands::Check {
name,
file,
harness,
root,
} => {
let content = std::fs::read_to_string(&file)?;
let config = make_config(&name, &content, &harness)?;
let ok = config.check(root.as_deref())?;
if !ok {
std::process::exit(1);
}
}
Commands::Uninstall {
name,
harness,
root,
} => {
let config = make_config(&name, "", &harness)?;
config.uninstall(root.as_deref())?;
}
Commands::Compose { command } => match command {
ComposeCommands::Validate { plan } => {
let report = skill_harness::compose::validate_composition_plan_path(&plan)?;
if report.is_valid() {
println!(
"ok: {} defines {} candidate skill(s)",
plan.display(),
report.candidate_names.len()
);
} else {
for message in report.error_messages() {
eprintln!("{message}");
}
std::process::exit(1);
}
}
},
Commands::Okf { command } => match command {
OkfCommands::Validate { path } => {
let report = skill_harness::okf::validate_okf_bundle(&path)?;
println!(
"OKF bundle: {} ({} concept file(s), {} warning(s), {} error(s))",
path.display(),
report.concept_count,
report.warnings.len(),
report.errors.len()
);
for message in report.warning_messages() {
eprintln!("warning: {message}");
}
if !report.is_valid() {
for message in report.error_messages() {
eprintln!("{message}");
}
std::process::exit(1);
}
}
},
Commands::List { root } => {
let root = root.unwrap_or_else(|| PathBuf::from("."));
list_skills(&root);
}
}
Ok(())
}
#[derive(Subcommand)]
enum ComposeCommands {
Validate {
plan: PathBuf,
},
}
#[derive(Subcommand)]
enum OkfCommands {
Validate {
path: PathBuf,
},
}
fn make_config(
name: &str,
content: &str,
harness: &str,
) -> Result<skill_harness::manage::SkillConfig> {
if harness != "auto" {
let target = skill_harness::manage::HarnessTarget::parse(harness)
.ok_or_else(|| anyhow::anyhow!("unknown harness target: {harness}"))?;
return Ok(skill_harness::manage::SkillConfig::for_harness(
name,
content,
env!("CARGO_PKG_VERSION"),
target,
));
}
Ok(make_auto_config(name, content))
}
fn make_auto_config(name: &str, content: &str) -> skill_harness::manage::SkillConfig {
#[cfg(feature = "detect")]
{
skill_harness::manage::skill_for_environment(name, content, env!("CARGO_PKG_VERSION"))
}
#[cfg(not(feature = "detect"))]
{
skill_harness::manage::SkillConfig::generic(name, content, env!("CARGO_PKG_VERSION"))
}
}
fn resolve_skill_source(name: &str, source: Option<PathBuf>) -> Result<PathBuf> {
if let Some(source) = source {
return Ok(source);
}
if name == "compose-skills" {
return Ok(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("skills/compose-skills"));
}
Err(anyhow::anyhow!(
"--source is required for {name}; only compose-skills has a bundled canonical source"
))
}
fn list_skills(root: &std::path::Path) {
let patterns = [
".agent/skills/*/SKILL.md",
".claude/skills/*/SKILL.md",
".codex/skills/*/SKILL.md",
".opencode/skills/*/SKILL.md",
];
let mut found = false;
for pattern in &patterns {
let full = root.join(pattern).to_string_lossy().to_string();
if let Ok(entries) = glob::glob(&full) {
for entry in entries.flatten() {
let name = entry
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let rel = entry.strip_prefix(root).unwrap_or(&entry);
println!(" {} → {}", name, rel.display());
found = true;
}
}
}
if !found {
println!("No skills installed.");
}
}