use agent_skills_rs::*;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(name = "agent-skills-rs")]
#[command(about = "A CLI tool with skill installation support", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
#[allow(clippy::enum_variant_names)]
enum Commands {
Commands {
#[arg(long, value_name = "FORMAT")]
output: Option<String>,
},
Schema {
#[arg(long, value_name = "COMMAND")]
command: String,
#[arg(long, value_name = "FORMAT")]
output: Option<String>,
},
InstallSkills {
#[arg(long)]
agent: Vec<String>,
#[arg(long)]
skill: Option<String>,
#[arg(long)]
global: bool,
#[arg(long)]
yes: bool,
#[arg(long)]
non_interactive: bool,
#[arg(long)]
json: bool,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Commands { output } => {
if output.as_deref() == Some("json") {
let json = output_commands_json()?;
println!("{}", json);
} else {
println!("Available commands:");
println!(" commands --output json");
println!(" schema --command <name> --output json-schema");
println!(" install-skills [--global] [--yes] [--non-interactive]");
}
}
Commands::Schema { command, output } => {
if output.as_deref() == Some("json-schema") {
let schema = get_command_schema(&command)?;
println!("{}", schema);
} else {
println!("Use --output json-schema to get the schema");
}
}
Commands::InstallSkills {
agent,
skill,
global,
yes,
non_interactive,
json,
} => {
install_skill_command(
&agent,
skill.as_deref(),
global,
yes || non_interactive,
json,
)?;
}
}
Ok(())
}
fn parse_agents(agents: &[String]) -> Result<Vec<String>> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for agent_str in agents {
for agent in agent_str.split(',') {
let trimmed = agent.trim();
if !trimmed.is_empty() && seen.insert(trimmed.to_string()) {
result.push(trimmed.to_string());
}
}
}
Ok(result)
}
fn resolve_target_dirs(
agents: &[String],
base_dir: &Path,
is_global: bool,
) -> Result<Vec<PathBuf>> {
let mut target_dirs = Vec::new();
for agent in agents {
match agent.as_str() {
"claude" => {
target_dirs.push(base_dir.join(".claude/skills"));
}
"opencode" => {
if is_global {
target_dirs.push(base_dir.join(".config/opencode/skills"));
}
}
_ => {
anyhow::bail!("Unknown agent: '{}'. Known agents: claude, opencode", agent);
}
}
}
Ok(target_dirs)
}
#[derive(Debug, Serialize, Deserialize)]
struct InstallResult {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
installed_skills: Option<Vec<InstalledSkill>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct InstalledSkill {
name: String,
description: String,
canonical_path: String,
target_paths: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
symlink_failed: Option<bool>,
}
fn install_skill_command(
agents: &[String],
skill_filter: Option<&str>,
is_global: bool,
auto_confirm: bool,
json_output: bool,
) -> Result<()> {
macro_rules! log_msg {
($($arg:tt)*) => {
if json_output {
eprintln!($($arg)*);
} else {
println!($($arg)*);
}
};
}
let source = Source {
source_type: SourceType::Self_,
url: None,
subpath: None,
skill_filter: skill_filter.map(|s| s.to_string()),
ref_: None,
};
let base_dir = if is_global {
let base_dirs = BaseDirs::new().context("Failed to determine home directory")?;
base_dirs.home_dir().to_path_buf()
} else {
std::env::current_dir()?
};
let canonical_dir = base_dir.join(".agents/skills");
let lock_path = base_dir.join(".agents/.skill-lock.json");
let normalized_agents = parse_agents(agents)?;
if is_global {
log_msg!("Discovering embedded skills (scope: global)");
} else {
log_msg!("Discovering embedded skills (scope: project)");
}
let config = DiscoveryConfig::default();
let mut skills = discover_skills(&source, &config)?;
if skills.is_empty() {
log_msg!("No skills found.");
if json_output {
let result = InstallResult {
ok: true,
installed_skills: Some(vec![]),
error: None,
};
println!("{}", serde_json::to_string(&result)?);
}
return Ok(());
}
if let Some(filter) = skill_filter {
skills.retain(|s| s.name == filter);
if skills.is_empty() {
log_msg!("No skill matching '{}' found.", filter);
if json_output {
let result = InstallResult {
ok: true,
installed_skills: Some(vec![]),
error: None,
};
println!("{}", serde_json::to_string(&result)?);
}
return Ok(());
}
}
log_msg!("Found {} skill(s):", skills.len());
for skill in &skills {
log_msg!(" - {} ({})", skill.name, skill.description);
}
let target_dirs = if !normalized_agents.is_empty() {
resolve_target_dirs(&normalized_agents, &base_dir, is_global)?
} else {
Vec::new()
};
let mut installed_skills = Vec::new();
for skill in &skills {
if !auto_confirm && !json_output {
println!("\nInstall skill '{}'? (y/n)", skill.name);
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
log_msg!("Skipped.");
continue;
}
}
log_msg!("Installing skill '{}'...", skill.name);
let mut install_config = InstallConfig::new(canonical_dir.clone());
install_config.target_dirs = target_dirs.clone();
let result = install_skill(skill, &install_config)?;
log_msg!(" Installed to: {}", result.path.display());
let mut target_paths = Vec::new();
for target_dir in &target_dirs {
let target_path = target_dir.join(&skill.name);
log_msg!(" Linked to: {}", target_path.display());
target_paths.push(target_path.display().to_string());
}
if result.symlink_failed {
log_msg!(" Note: Some symlinks failed, used copy fallback.");
}
let lock_manager = LockManager::new(lock_path.clone());
lock_manager.update_entry(&skill.name, &source, &result.path)?;
log_msg!(" Lock file updated: {}", lock_path.display());
installed_skills.push(InstalledSkill {
name: skill.name.clone(),
description: skill.description.clone(),
canonical_path: result.path.display().to_string(),
target_paths,
symlink_failed: if result.symlink_failed {
Some(true)
} else {
None
},
});
}
log_msg!("\nInstallation complete!");
if json_output {
let result = InstallResult {
ok: true,
installed_skills: Some(installed_skills),
error: None,
};
println!("{}", serde_json::to_string(&result)?);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use agent_skills_rs::types::SkillMetadata;
#[test]
fn test_parse_agents_single() {
let agents = vec!["claude".to_string()];
let result = parse_agents(&agents).unwrap();
assert_eq!(result, vec!["claude"]);
}
#[test]
fn test_parse_agents_comma_separated() {
let agents = vec!["claude,opencode".to_string()];
let result = parse_agents(&agents).unwrap();
assert_eq!(result, vec!["claude", "opencode"]);
}
#[test]
fn test_parse_agents_multiple_args() {
let agents = vec!["claude".to_string(), "opencode".to_string()];
let result = parse_agents(&agents).unwrap();
assert_eq!(result, vec!["claude", "opencode"]);
}
#[test]
fn test_parse_agents_mixed() {
let agents = vec!["claude".to_string(), "opencode,claude".to_string()];
let result = parse_agents(&agents).unwrap();
assert_eq!(result, vec!["claude", "opencode"]);
}
#[test]
fn test_parse_agents_with_whitespace() {
let agents = vec!["claude , opencode".to_string()];
let result = parse_agents(&agents).unwrap();
assert_eq!(result, vec!["claude", "opencode"]);
}
#[test]
fn test_parse_agents_empty() {
let agents = vec![];
let result = parse_agents(&agents).unwrap();
assert_eq!(result, Vec::<String>::new());
}
#[test]
fn test_parse_agents_empty_string() {
let agents = vec!["".to_string()];
let result = parse_agents(&agents).unwrap();
assert_eq!(result, Vec::<String>::new());
}
#[test]
fn test_resolve_target_dirs_claude() {
let agents = vec!["claude".to_string()];
let base_dir = PathBuf::from("/home/user");
let result = resolve_target_dirs(&agents, &base_dir, false).unwrap();
assert_eq!(result, vec![PathBuf::from("/home/user/.claude/skills")]);
}
#[test]
fn test_resolve_target_dirs_opencode_project_scope() {
let agents = vec!["opencode".to_string()];
let base_dir = PathBuf::from("/home/user/project");
let result = resolve_target_dirs(&agents, &base_dir, false).unwrap();
assert_eq!(result, Vec::<PathBuf>::new());
}
#[test]
fn test_resolve_target_dirs_opencode_global_scope() {
let agents = vec!["opencode".to_string()];
let base_dir = PathBuf::from("/home/user");
let result = resolve_target_dirs(&agents, &base_dir, true).unwrap();
assert_eq!(
result,
vec![PathBuf::from("/home/user/.config/opencode/skills")]
);
}
#[test]
fn test_resolve_target_dirs_multiple_project_scope() {
let agents = vec!["claude".to_string(), "opencode".to_string()];
let base_dir = PathBuf::from("/home/user/project");
let result = resolve_target_dirs(&agents, &base_dir, false).unwrap();
assert_eq!(
result,
vec![PathBuf::from("/home/user/project/.claude/skills")]
);
}
#[test]
fn test_resolve_target_dirs_multiple_global_scope() {
let agents = vec!["claude".to_string(), "opencode".to_string()];
let base_dir = PathBuf::from("/home/user");
let result = resolve_target_dirs(&agents, &base_dir, true).unwrap();
assert_eq!(
result,
vec![
PathBuf::from("/home/user/.claude/skills"),
PathBuf::from("/home/user/.config/opencode/skills")
]
);
}
#[test]
fn test_resolve_target_dirs_unknown_agent() {
let agents = vec!["unknown".to_string()];
let base_dir = PathBuf::from("/home/user");
let result = resolve_target_dirs(&agents, &base_dir, false);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unknown agent: 'unknown'"));
}
#[test]
fn test_resolve_target_dirs_empty() {
let agents = vec![];
let base_dir = PathBuf::from("/home/user");
let result = resolve_target_dirs(&agents, &base_dir, false).unwrap();
assert_eq!(result, Vec::<PathBuf>::new());
}
#[test]
#[cfg(unix)]
fn test_agent_specific_installation_with_symlinks() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let canonical_dir = base_dir.join(".agents/skills");
let claude_target = base_dir.join(".claude/skills");
let skill = Skill {
name: "test-skill".to_string(),
description: "Test skill".to_string(),
path: None,
raw_content: "---\nname: test-skill\ndescription: Test skill\n---\n\n# Test"
.to_string(),
metadata: SkillMetadata::default(),
auxiliary_files: Default::default(),
};
let mut install_config = InstallConfig::new(canonical_dir.clone());
install_config.target_dirs = vec![claude_target.clone()];
let result = install_skill(&skill, &install_config).unwrap();
assert_eq!(result.path, canonical_dir.join("test-skill"));
assert!(result.path.exists());
assert!(result.path.join("SKILL.md").exists());
let target_path = claude_target.join("test-skill");
assert!(target_path.exists());
let metadata = std::fs::symlink_metadata(&target_path).unwrap();
assert!(
metadata.file_type().is_symlink(),
"Target should be a symlink"
);
assert!(!result.symlink_failed, "Symlink should not have failed");
let link_target = std::fs::read_link(&target_path).unwrap();
assert_eq!(link_target, canonical_dir.join("test-skill"));
}
#[test]
fn test_opencode_project_scope_no_target_dir() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let canonical_dir = base_dir.join(".agents/skills");
let agents = vec!["opencode".to_string()];
let target_dirs = resolve_target_dirs(&agents, base_dir, false).unwrap();
assert_eq!(target_dirs.len(), 0);
let skill = Skill {
name: "test-skill".to_string(),
description: "Test skill".to_string(),
path: None,
raw_content: "---\nname: test-skill\ndescription: Test skill\n---\n\n# Test"
.to_string(),
metadata: SkillMetadata::default(),
auxiliary_files: Default::default(),
};
let mut install_config = InstallConfig::new(canonical_dir.clone());
install_config.target_dirs = target_dirs;
let result = install_skill(&skill, &install_config).unwrap();
assert_eq!(result.path, canonical_dir.join("test-skill"));
assert!(result.path.exists());
assert!(result.path.join("SKILL.md").exists());
assert!(!result.symlink_failed);
}
#[test]
fn test_opencode_global_scope_has_target_dir() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let base_dir = temp_dir.path();
let canonical_dir = base_dir.join(".agents/skills");
let agents = vec!["opencode".to_string()];
let target_dirs = resolve_target_dirs(&agents, base_dir, true).unwrap();
assert_eq!(target_dirs.len(), 1);
assert_eq!(target_dirs[0], base_dir.join(".config/opencode/skills"));
let skill = Skill {
name: "test-skill".to_string(),
description: "Test skill".to_string(),
path: None,
raw_content: "---\nname: test-skill\ndescription: Test skill\n---\n\n# Test"
.to_string(),
metadata: SkillMetadata::default(),
auxiliary_files: Default::default(),
};
let mut install_config = InstallConfig::new(canonical_dir.clone());
install_config.target_dirs = target_dirs.clone();
let result = install_skill(&skill, &install_config).unwrap();
assert_eq!(result.path, canonical_dir.join("test-skill"));
assert!(result.path.exists());
let target_path = target_dirs[0].join("test-skill");
assert!(target_path.exists());
assert!(target_path.join("SKILL.md").exists());
}
}