use crate::detect::Environment;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub struct SkillConfig {
pub name: String,
pub content: String,
pub version: String,
pub environment: Environment,
}
impl SkillConfig {
pub fn new(name: impl Into<String>, content: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
content: content.into(),
version: version.into(),
environment: Environment::detect(),
}
}
pub fn with_environment(
name: impl Into<String>,
content: impl Into<String>,
version: impl Into<String>,
environment: Environment,
) -> Self {
Self {
name: name.into(),
content: content.into(),
version: version.into(),
environment,
}
}
pub fn skill_path(&self, root: Option<&Path>) -> PathBuf {
self.environment.skill_path(&self.name, root)
}
pub fn install(&self, root: Option<&Path>) -> Result<()> {
let path = self.skill_path(root);
if path.exists() {
let existing = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
if existing == self.content {
eprintln!("Skill already up to date (v{}).", self.version);
return Ok(());
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&path, &self.content)
.with_context(|| format!("failed to write {}", path.display()))?;
eprintln!("Installed skill v{} → {}", self.version, path.display());
Ok(())
}
pub fn check(&self, root: Option<&Path>) -> Result<bool> {
let path = self.skill_path(root);
if !path.exists() {
eprintln!("Not installed. Run `{} skill install` to install.", self.name);
return Ok(false);
}
let existing = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
if existing == self.content {
eprintln!("Up to date (v{}).", self.version);
Ok(true)
} else {
eprintln!(
"Outdated. Run `{} skill install` to update to v{}.",
self.name, self.version
);
Ok(false)
}
}
pub fn install_for(&self, env: Environment, root: Option<&Path>) -> Result<()> {
let rel = env.skill_rel_path(&self.name);
let path = match root {
Some(r) => r.join(rel),
None => rel,
};
if path.exists() {
let existing = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
if existing == self.content {
eprintln!("[{}] already up to date (v{}).", env, self.version);
return Ok(());
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&path, &self.content)
.with_context(|| format!("failed to write {}", path.display()))?;
eprintln!("[{}] installed skill v{} → {}", env, self.version, path.display());
Ok(())
}
pub fn install_all(&self, root: Option<&Path>) -> Result<()> {
for (env, _) in Environment::all_skill_rel_paths(&self.name) {
self.install_for(env, root)?;
}
Ok(())
}
pub fn uninstall(&self, root: Option<&Path>) -> Result<()> {
let path = self.skill_path(root);
if !path.exists() {
eprintln!("Skill not installed.");
return Ok(());
}
std::fs::remove_file(&path)
.with_context(|| format!("failed to remove {}", path.display()))?;
if let Some(parent) = path.parent()
&& parent.read_dir().is_ok_and(|mut d| d.next().is_none())
{
let _ = std::fs::remove_dir(parent);
}
eprintln!("Uninstalled skill from {}", path.display());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> SkillConfig {
SkillConfig::with_environment(
"test-tool",
"# Test Skill\n\nSome content.\n",
"1.0.0",
crate::detect::Environment::ClaudeCode,
)
}
#[test]
fn skill_path_with_root() {
let config = test_config();
let path = config.skill_path(Some(Path::new("/project")));
assert_eq!(path, PathBuf::from("/project/.claude/skills/test-tool/SKILL.md"));
}
#[test]
fn skill_path_without_root() {
let config = test_config();
let path = config.skill_path(None);
assert_eq!(path, PathBuf::from(".claude/skills/test-tool/SKILL.md"));
}
#[test]
fn install_creates_file() {
let dir = tempfile::tempdir().unwrap();
let config = test_config();
config.install(Some(dir.path())).unwrap();
let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, config.content);
}
#[test]
fn install_idempotent() {
let dir = tempfile::tempdir().unwrap();
let config = test_config();
config.install(Some(dir.path())).unwrap();
config.install(Some(dir.path())).unwrap();
let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, config.content);
}
#[test]
fn install_overwrites_outdated() {
let dir = tempfile::tempdir().unwrap();
let config = test_config();
let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "old content").unwrap();
config.install(Some(dir.path())).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, config.content);
}
#[test]
fn check_not_installed() {
let dir = tempfile::tempdir().unwrap();
let config = test_config();
let result = config.check(Some(dir.path())).unwrap();
assert!(!result);
}
#[test]
fn check_up_to_date() {
let dir = tempfile::tempdir().unwrap();
let config = test_config();
config.install(Some(dir.path())).unwrap();
let result = config.check(Some(dir.path())).unwrap();
assert!(result);
}
#[test]
fn check_outdated() {
let dir = tempfile::tempdir().unwrap();
let config = test_config();
let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "old content").unwrap();
let result = config.check(Some(dir.path())).unwrap();
assert!(!result);
}
#[test]
fn uninstall_removes_file() {
let dir = tempfile::tempdir().unwrap();
let config = test_config();
config.install(Some(dir.path())).unwrap();
config.uninstall(Some(dir.path())).unwrap();
let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
assert!(!path.exists());
}
#[test]
fn uninstall_not_installed() {
let dir = tempfile::tempdir().unwrap();
let config = test_config();
config.uninstall(Some(dir.path())).unwrap();
}
}