use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub const SKILLS_DIR: &str = "skills";
pub const SKILL_MANIFEST: &str = "skill.toml";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SkillManifest {
pub skill: SkillMeta,
#[serde(default)]
pub hooks: Option<SkillHooks>,
#[serde(default)]
pub config: Option<HashMap<String, SkillConfigOption>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SkillMeta {
pub name: String,
pub version: String,
pub description: String,
pub author: Option<String>,
#[serde(default)]
pub category: SkillCategory,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[serde(rename_all = "kebab-case")]
pub enum SkillCategory {
#[default]
Template,
Analyzer,
Formatter,
Integration,
Utility,
}
impl std::fmt::Display for SkillCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkillCategory::Template => write!(f, "template"),
SkillCategory::Analyzer => write!(f, "analyzer"),
SkillCategory::Formatter => write!(f, "formatter"),
SkillCategory::Integration => write!(f, "integration"),
SkillCategory::Utility => write!(f, "utility"),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct SkillHooks {
pub pre_gen: Option<String>,
pub post_gen: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SkillConfigOption {
pub r#type: String,
#[serde(default)]
pub default: Option<toml::Value>,
pub description: String,
#[serde(default)]
pub required: bool,
}
#[derive(Debug, Clone)]
pub struct Skill {
pub manifest: SkillManifest,
pub path: PathBuf,
pub source: SkillSource,
}
#[allow(dead_code)]
impl Skill {
pub fn name(&self) -> &str {
&self.manifest.skill.name
}
pub fn description(&self) -> &str {
&self.manifest.skill.description
}
pub fn category(&self) -> &SkillCategory {
&self.manifest.skill.category
}
pub fn source(&self) -> &SkillSource {
&self.source
}
pub fn is_builtin(&self) -> bool {
matches!(self.source, SkillSource::Builtin)
}
pub fn is_project_skill(&self) -> bool {
matches!(self.source, SkillSource::Project)
}
pub fn has_pre_gen(&self) -> bool {
self.manifest
.hooks
.as_ref()
.and_then(|h| h.pre_gen.as_ref())
.is_some()
}
pub fn has_post_gen(&self) -> bool {
self.manifest
.hooks
.as_ref()
.and_then(|h| h.post_gen.as_ref())
.is_some()
}
pub fn pre_gen_path(&self) -> Option<PathBuf> {
self.manifest
.hooks
.as_ref()
.and_then(|h| h.pre_gen.as_ref().map(|script| self.path.join(script)))
}
pub fn post_gen_path(&self) -> Option<PathBuf> {
self.manifest
.hooks
.as_ref()
.and_then(|h| h.post_gen.as_ref().map(|script| self.path.join(script)))
}
pub fn load_prompt_template(&self) -> Result<Option<String>> {
let prompt_path = self.path.join("prompt.md");
if prompt_path.exists() {
let content = fs::read_to_string(&prompt_path).with_context(|| {
format!("Failed to read prompt template: {}", prompt_path.display())
})?;
Ok(Some(content))
} else {
Ok(None)
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum SkillSource {
Builtin,
User,
Project,
}
impl std::fmt::Display for SkillSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SkillSource::Builtin => write!(f, "built-in"),
SkillSource::User => write!(f, "user"),
SkillSource::Project => write!(f, "project"),
}
}
}
pub struct SkillsManager {
user_skills_dir: PathBuf,
project_skills_dir: Option<PathBuf>,
skills: Vec<Skill>,
}
#[allow(dead_code)]
impl SkillsManager {
pub fn new() -> Result<Self> {
let user_skills_dir = Self::user_skills_dir()?;
let project_skills_dir = Self::project_skills_dir()?;
Ok(Self {
user_skills_dir,
project_skills_dir,
skills: Vec::new(),
})
}
fn user_skills_dir() -> Result<PathBuf> {
let config_dir = if let Ok(config_home) = std::env::var("RCO_CONFIG_HOME") {
PathBuf::from(config_home)
} else {
dirs::home_dir()
.context("Could not find home directory")?
.join(".config")
.join("rustycommit")
};
Ok(config_dir.join(SKILLS_DIR))
}
fn project_skills_dir() -> Result<Option<PathBuf>> {
use crate::git;
if let Ok(repo_root) = git::get_repo_root() {
let project_skills = Path::new(&repo_root).join(".rco").join("skills");
if project_skills.exists() {
return Ok(Some(project_skills));
}
}
Ok(None)
}
pub fn has_project_skills(&self) -> bool {
self.project_skills_dir.is_some()
}
pub fn project_skills_path(&self) -> Option<&Path> {
self.project_skills_dir.as_deref()
}
pub fn ensure_skills_dir(&self) -> Result<()> {
if !self.user_skills_dir.exists() {
fs::create_dir_all(&self.user_skills_dir).with_context(|| {
format!(
"Failed to create skills directory: {}",
self.user_skills_dir.display()
)
})?;
}
Ok(())
}
pub fn discover(&mut self) -> Result<&mut Self> {
self.skills.clear();
let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
if let Some(ref project_dir) = self.project_skills_dir {
if project_dir.exists() {
for entry in fs::read_dir(project_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Ok(skill) = Self::load_skill(&path, SkillSource::Project) {
seen_names.insert(skill.name().to_string());
self.skills.push(skill);
}
}
}
}
}
if self.user_skills_dir.exists() {
for entry in fs::read_dir(&self.user_skills_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Ok(skill) = Self::load_skill(&path, SkillSource::User) {
if !seen_names.contains(skill.name()) {
self.skills.push(skill);
}
}
}
}
}
self.skills.sort_by(|a, b| a.name().cmp(b.name()));
Ok(self)
}
fn load_skill(path: &Path, source: SkillSource) -> Result<Skill> {
let manifest_path = path.join(SKILL_MANIFEST);
let manifest_content = fs::read_to_string(&manifest_path).with_context(|| {
format!("Failed to read skill manifest: {}", manifest_path.display())
})?;
let manifest: SkillManifest = toml::from_str(&manifest_content).with_context(|| {
format!(
"Failed to parse skill manifest: {}",
manifest_path.display()
)
})?;
Ok(Skill {
manifest,
path: path.to_path_buf(),
source,
})
}
pub fn skills(&self) -> &[Skill] {
&self.skills
}
pub fn find(&self, name: &str) -> Option<&Skill> {
self.skills.iter().find(|s| s.name() == name)
}
pub fn find_mut(&mut self, name: &str) -> Option<&mut Skill> {
self.skills.iter_mut().find(|s| s.name() == name)
}
pub fn by_category(&self, category: &SkillCategory) -> Vec<&Skill> {
self.skills
.iter()
.filter(|s| std::mem::discriminant(s.category()) == std::mem::discriminant(category))
.collect()
}
pub fn create_skill(&self, name: &str, category: SkillCategory) -> Result<PathBuf> {
self.ensure_skills_dir()?;
let skill_dir = self.user_skills_dir.join(name);
if skill_dir.exists() {
anyhow::bail!("Skill '{}' already exists at {}", name, skill_dir.display());
}
fs::create_dir_all(&skill_dir)?;
let manifest = SkillManifest {
skill: SkillMeta {
name: name.to_string(),
version: "1.0.0".to_string(),
description: format!("A {} skill for rusty-commit", category),
author: None,
category,
tags: vec![],
},
hooks: None,
config: None,
};
let manifest_content = toml::to_string_pretty(&manifest)?;
fs::write(skill_dir.join(SKILL_MANIFEST), manifest_content)?;
let prompt_template = r#"# Custom Prompt Template
You are a commit message generator. Analyze the following diff and generate a commit message.
## Diff
```diff
{diff}
```
## Context
{context}
## Instructions
Generate a commit message that:
- Follows the conventional commit format
- Is clear and concise
- Describes the changes accurately
"#;
fs::write(skill_dir.join("prompt.md"), prompt_template)?;
Ok(skill_dir)
}
pub fn remove_skill(&mut self, name: &str) -> Result<()> {
if let Some(skill) = self.find(name) {
if !matches!(skill.source, SkillSource::User) {
anyhow::bail!(
"Cannot remove {} skill '{}'. Only user skills can be removed.",
skill.source,
name
);
}
}
let skill_dir = self.user_skills_dir.join(name);
if !skill_dir.exists() {
anyhow::bail!("Skill '{}' not found", name);
}
fs::remove_dir_all(&skill_dir).with_context(|| {
format!("Failed to remove skill directory: {}", skill_dir.display())
})?;
self.skills.retain(|s| s.name() != name);
Ok(())
}
pub fn skills_dir(&self) -> &Path {
&self.user_skills_dir
}
pub fn ensure_project_skills_dir(&self) -> Result<Option<PathBuf>> {
use crate::git;
if let Ok(repo_root) = git::get_repo_root() {
let project_skills = Path::new(&repo_root).join(".rco").join("skills");
if !project_skills.exists() {
fs::create_dir_all(&project_skills).with_context(|| {
format!(
"Failed to create project skills directory: {}",
project_skills.display()
)
})?;
}
return Ok(Some(project_skills));
}
Ok(None)
}
}
impl Default for SkillsManager {
fn default() -> Self {
Self::new().expect("Failed to create skills manager")
}
}
#[allow(dead_code)]
pub mod builtin {
pub fn conventional_prompt(diff: &str, context: Option<&str>, language: &str) -> String {
let context_str = context.unwrap_or("None");
format!(
r#"You are an expert at writing conventional commit messages.
Analyze the following git diff and generate a conventional commit message.
## Rules
- Use format: <type>(<scope>): <description>
- Types:
- feat: A new feature
- fix: A bug fix
- docs: Documentation only changes
- style: Changes that don't affect code meaning (formatting, semicolons, etc.)
- refactor: Code change that neither fixes a bug nor adds a feature
- perf: Code change that improves performance
- test: Adding or correcting tests
- build: Changes to build system or dependencies
- ci: Changes to CI configuration
- chore: Other changes that don't modify src or test files
- Keep the description under 72 characters
- Use imperative mood ("add" not "added")
- Be concise but descriptive
- Scope is optional but recommended for monorepos or large projects
- For breaking changes, add ! after type/scope: feat(api)!: change API response format
## Context
{}
## Language
{}
## Diff
```diff
{}
```
Generate ONLY the commit message, no explanation:"#,
context_str, language, diff
)
}
pub fn gitmoji_prompt(diff: &str, context: Option<&str>, language: &str) -> String {
let context_str = context.unwrap_or("None");
format!(
r#"You are an expert at writing GitMoji commit messages.
Analyze the following git diff and generate a GitMoji commit message.
## Rules
- Start with an appropriate emoji
- Use format: :emoji: <description> OR emoji <description>
- Common emojis (from gitmoji.dev):
- ✨ :sparkles: (feat) - Introduce new features
- 🐛 :bug: (fix) - Fix a bug
- 📝 :memo: (docs) - Add or update documentation
- 💄 :lipstick: (style) - Add or update the UI/style files
- ♻️ :recycle: (refactor) - Refactor code
- ✅ :white_check_mark: (test) - Add or update tests
- 🔧 :wrench: (chore) - Add or update configuration files
- ⚡️ :zap: (perf) - Improve performance
- 👷 :construction_worker: (ci) - Add or update CI build system
- 📦 :package: (build) - Add or update compiled files/packages
- 🎨 :art: - Improve structure/format of the code
- 🔥 :fire: - Remove code or files
- 🚀 :rocket: - Deploy stuff
- 🔒 :lock: - Fix security issues
- ⬆️ :arrow_up: - Upgrade dependencies
- ⬇️ :arrow_down: - Downgrade dependencies
- 📌 :pushpin: - Pin dependencies to specific versions
- ➕ :heavy_plus_sign: - Add dependencies
- ➖ :heavy_minus_sign: - Remove dependencies
- 🔀 :twisted_rightwards_arrows: - Merge branches
- 💥 :boom: - Introduce breaking changes
- 🚑 :ambulance: - Critical hotfix
- 🍱 :bento: - Add or update assets
- 🗑️ :wastebasket: - Deprecate code
- ⚰️ :coffin: - Remove dead code
- 🧪 :test_tube: - Add failing test
- 🩹 :adhesive_bandage: - Simple fix for a non-critical issue
- 🌐 :globe_with_meridians: - Internationalization and localization
- 💡 :bulb: - Add or update comments in source code
- 🗃️ :card_file_box: - Database related changes
- Keep the description under 72 characters
- Use imperative mood
- For breaking changes, add 💥 after the emoji
## Context
{}
## Language
{}
## Diff
```diff
{}
```
Generate ONLY the commit message, no explanation:"#,
context_str, language, diff
)
}
}
pub mod external {
use super::*;
use std::process::Command;
#[derive(Debug, Clone)]
pub enum ExternalSource {
ClaudeCode,
GitHub {
owner: String,
repo: String,
path: Option<String>,
},
Gist { id: String },
Url { url: String },
}
impl std::fmt::Display for ExternalSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExternalSource::ClaudeCode => write!(f, "claude-code"),
ExternalSource::GitHub { owner, repo, .. } => {
write!(f, "github:{}/{}", owner, repo)
}
ExternalSource::Gist { id } => write!(f, "gist:{}", id),
ExternalSource::Url { url } => write!(f, "url:{}", url),
}
}
}
pub fn parse_source(source: &str) -> Result<ExternalSource> {
if source == "claude-code" || source == "claude" {
Ok(ExternalSource::ClaudeCode)
} else if let Some(github_ref) = source.strip_prefix("github:") {
let parts: Vec<&str> = github_ref.split('/').collect();
if parts.len() < 2 {
anyhow::bail!("Invalid GitHub reference. Use format: github:owner/repo or github:owner/repo/path");
}
let owner = parts[0].to_string();
let repo = parts[1].to_string();
let path = if parts.len() > 2 {
Some(parts[2..].join("/"))
} else {
None
};
Ok(ExternalSource::GitHub { owner, repo, path })
} else if let Some(gist_id) = source.strip_prefix("gist:") {
Ok(ExternalSource::Gist {
id: gist_id.to_string(),
})
} else if source.starts_with("http://") || source.starts_with("https://") {
Ok(ExternalSource::Url {
url: source.to_string(),
})
} else {
anyhow::bail!("Unknown source format: {}. Use 'claude-code', 'github:owner/repo', 'gist:id', or a URL", source)
}
}
pub fn import_from_claude_code(target_dir: &Path) -> Result<Vec<String>> {
let claude_skills_dir = dirs::home_dir()
.context("Could not find home directory")?
.join(".claude")
.join("skills");
if !claude_skills_dir.exists() {
anyhow::bail!("Claude Code skills directory not found at ~/.claude/skills/");
}
let mut imported = Vec::new();
for entry in fs::read_dir(&claude_skills_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let skill_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let target_skill_dir = target_dir.join(&skill_name);
if target_skill_dir.exists() {
tracing::warn!("Skill '{}' already exists, skipping", skill_name);
continue;
}
fs::create_dir_all(&target_skill_dir)?;
convert_claude_skill(&path, &target_skill_dir, &skill_name)?;
imported.push(skill_name);
}
}
Ok(imported)
}
pub fn convert_claude_skill(source: &Path, target: &Path, name: &str) -> Result<()> {
if !source.exists() {
anyhow::bail!("Source directory does not exist: {:?}", source);
}
fs::create_dir_all(target)
.with_context(|| format!("Failed to create target directory: {:?}", target))?;
let description = if source.join("README.md").exists() {
let readme = fs::read_to_string(source.join("README.md"))
.with_context(|| format!("Failed to read README.md from {:?}", source))?;
readme
.lines()
.next()
.unwrap_or("Imported from Claude Code")
.to_string()
} else if source.join("SKILL.md").exists() {
let skill_md = fs::read_to_string(source.join("SKILL.md"))
.with_context(|| format!("Failed to read SKILL.md from {:?}", source))?;
skill_md
.lines()
.next()
.unwrap_or("Imported from Claude Code")
.to_string()
} else {
format!("Imported from Claude Code: {}", name)
};
let manifest = SkillManifest {
skill: SkillMeta {
name: name.to_string(),
version: "1.0.0".to_string(),
description,
author: Some("Imported from Claude Code".to_string()),
category: SkillCategory::Template,
tags: vec!["claude-code".to_string(), "imported".to_string()],
},
hooks: None,
config: None,
};
fs::write(
target.join("skill.toml"),
toml::to_string_pretty(&manifest)?,
)?;
let instruction_files = [
"SKILL.md",
"INSTRUCTIONS.md",
"README.md",
"PROMPT.md",
"prompt.md",
];
let mut found_instructions = false;
for file in &instruction_files {
let source_file = source.join(file);
if source_file.exists() {
let content = fs::read_to_string(&source_file)?;
let prompt = format!(
"# Imported from Claude Code Skill: {}\n\n{}\n\n## Diff\n\n```diff\n{{diff}}\n```\n\n## Context\n\n{{context}}",
name,
content
);
fs::write(target.join("prompt.md"), prompt)?;
found_instructions = true;
break;
}
}
if !found_instructions {
let prompt = format!(
"# Skill: {}\n\nThis skill was imported from Claude Code.\n\n## Diff\n\n```diff\n{{diff}}\n```\n\n## Context\n\n{{context}}",
name
);
fs::write(target.join("prompt.md"), prompt)?;
}
for entry in fs::read_dir(source)? {
let entry = entry?;
let file_name = entry.file_name();
let file_str = file_name.to_string_lossy();
if file_str.ends_with(".json") && file_str.contains("tool") {
continue; }
if ["skill.toml", "prompt.md", "README.md", "INSTRUCTIONS.md"]
.contains(&file_str.as_ref())
{
continue;
}
let target_file = target.join(&file_name);
if entry.path().is_file() {
fs::copy(entry.path(), target_file)?;
}
}
Ok(())
}
pub fn import_from_github(
owner: &str,
repo: &str,
path: Option<&str>,
target_dir: &Path,
) -> Result<Vec<String>> {
use std::env;
let temp_dir = env::temp_dir().join(format!("rco-github-import-{}-{}", owner, repo));
if temp_dir.exists() {
let _ = fs::remove_dir_all(&temp_dir);
}
println!("Cloning {}/{}...", owner, repo);
let status = Command::new("git")
.args([
"clone",
"--depth",
"1",
&format!("https://github.com/{}/{}", owner, repo),
temp_dir.to_string_lossy().as_ref(),
])
.status()
.context("Failed to run git clone. Is git installed?")?;
if !status.success() {
anyhow::bail!("Failed to clone repository {}/{}", owner, repo);
}
let source_path = if let Some(p) = path {
temp_dir.join(p)
} else {
temp_dir.join(".rco").join("skills")
};
if !source_path.exists() {
let _ = fs::remove_dir_all(&temp_dir);
anyhow::bail!(
"No skills found at {} in {}/{}",
source_path.display(),
owner,
repo
);
}
let mut imported = Vec::new();
for entry in fs::read_dir(&source_path)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let skill_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let target_skill_dir = target_dir.join(&skill_name);
if target_skill_dir.exists() {
tracing::warn!("Skill '{}' already exists, skipping", skill_name);
continue;
}
copy_dir_all(&path, &target_skill_dir)?;
let skill_toml = target_skill_dir.join("skill.toml");
if skill_toml.exists() {
if let Ok(content) = fs::read_to_string(&skill_toml) {
if let Ok(mut manifest) = toml::from_str::<SkillManifest>(&content) {
manifest.skill.tags.push("github".to_string());
manifest.skill.tags.push("imported".to_string());
let _ = fs::write(&skill_toml, toml::to_string_pretty(&manifest)?);
}
}
}
imported.push(skill_name);
}
}
let _ = fs::remove_dir_all(&temp_dir);
Ok(imported)
}
pub fn import_from_gist(gist_id: &str, target_dir: &Path) -> Result<String> {
let gist_url = format!("https://api.github.com/gists/{}", gist_id);
let client = reqwest::blocking::Client::new();
let response = client
.get(&gist_url)
.header("User-Agent", "rusty-commit")
.send()
.context("Failed to fetch gist from GitHub API")?;
if !response.status().is_success() {
anyhow::bail!("Failed to fetch gist: HTTP {}", response.status());
}
let gist_data: serde_json::Value =
response.json().context("Failed to parse gist response")?;
let files = gist_data["files"]
.as_object()
.ok_or_else(|| anyhow::anyhow!("Invalid gist data: no files"))?;
if files.is_empty() {
anyhow::bail!("Gist contains no files");
}
let (filename, file_data) = files.iter().next().unwrap();
let skill_name = filename.trim_end_matches(".md").trim_end_matches(".toml");
let target_skill_dir = target_dir.join(skill_name);
if target_skill_dir.exists() {
anyhow::bail!("Skill '{}' already exists", skill_name);
}
fs::create_dir_all(&target_skill_dir)?;
if let Some(content) = file_data["content"].as_str() {
if filename.ends_with(".toml") {
fs::write(target_skill_dir.join("skill.toml"), content)?;
} else {
let prompt = format!(
"# Imported from Gist: {}\n\n{}\n\n## Diff\n\n```diff\n{{diff}}\n```\n\n## Context\n\n{{context}}",
gist_id,
content
);
fs::write(target_skill_dir.join("prompt.md"), prompt)?;
let manifest = SkillManifest {
skill: SkillMeta {
name: skill_name.to_string(),
version: "1.0.0".to_string(),
description: format!("Imported from Gist: {}", gist_id),
author: gist_data["owner"]["login"].as_str().map(|s| s.to_string()),
category: SkillCategory::Template,
tags: vec!["gist".to_string(), "imported".to_string()],
},
hooks: None,
config: None,
};
fs::write(
target_skill_dir.join("skill.toml"),
toml::to_string_pretty(&manifest)?,
)?;
}
}
Ok(skill_name.to_string())
}
pub fn import_from_url(url: &str, name: Option<&str>, target_dir: &Path) -> Result<String> {
let client = reqwest::blocking::Client::new();
let response = client
.get(url)
.header("User-Agent", "rusty-commit")
.send()
.context("Failed to download from URL")?;
if !response.status().is_success() {
anyhow::bail!("Failed to download: HTTP {}", response.status());
}
let content = response.text()?;
let skill_name = name.map(|s| s.to_string()).unwrap_or_else(|| {
url.split('/')
.next_back()
.and_then(|s| s.split('.').next())
.unwrap_or("imported-skill")
.to_string()
});
let target_skill_dir = target_dir.join(&skill_name);
if target_skill_dir.exists() {
anyhow::bail!("Skill '{}' already exists", skill_name);
}
fs::create_dir_all(&target_skill_dir)?;
if content.trim().starts_with('[') && content.contains("[skill]") {
fs::write(target_skill_dir.join("skill.toml"), content)?;
} else {
let prompt = format!(
"# Imported from URL\n\n{}\n\n## Diff\n\n```diff\n{{diff}}\n```\n\n## Context\n\n{{context}}",
content
);
fs::write(target_skill_dir.join("prompt.md"), prompt)?;
let manifest = SkillManifest {
skill: SkillMeta {
name: skill_name.clone(),
version: "1.0.0".to_string(),
description: format!("Imported from {}", url),
author: None,
category: SkillCategory::Template,
tags: vec!["url".to_string(), "imported".to_string()],
},
hooks: None,
config: None,
};
fs::write(
target_skill_dir.join("skill.toml"),
toml::to_string_pretty(&manifest)?,
)?;
}
Ok(skill_name)
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let file_name = path.file_name().unwrap();
let dst_path = dst.join(file_name);
if path.is_dir() {
copy_dir_all(&path, &dst_path)?;
} else {
fs::copy(&path, &dst_path)?;
}
}
Ok(())
}
pub fn list_claude_code_skills() -> Result<Vec<(String, String)>> {
let claude_skills_dir = dirs::home_dir()
.context("Could not find home directory")?
.join(".claude")
.join("skills");
if !claude_skills_dir.exists() {
return Ok(Vec::new());
}
let mut skills = Vec::new();
for entry in fs::read_dir(&claude_skills_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let description = if path.join("README.md").exists() {
let readme = fs::read_to_string(path.join("README.md")).unwrap_or_default();
readme
.lines()
.next()
.unwrap_or("No description")
.to_string()
} else {
"Claude Code skill".to_string()
};
skills.push((name, description));
}
}
Ok(skills)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_skill_category_display() {
assert_eq!(SkillCategory::Template.to_string(), "template");
assert_eq!(SkillCategory::Analyzer.to_string(), "analyzer");
assert_eq!(SkillCategory::Formatter.to_string(), "formatter");
}
#[test]
fn test_manifest_parsing() {
let toml = r#"
[skill]
name = "test-skill"
version = "1.0.0"
description = "A test skill"
author = "Test Author"
category = "template"
tags = ["test", "example"]
[skill.hooks]
pre_gen = "pre_gen.sh"
post_gen = "post_gen.sh"
"#;
let manifest: SkillManifest = toml::from_str(toml).unwrap();
assert_eq!(manifest.skill.name, "test-skill");
assert_eq!(manifest.skill.version, "1.0.0");
assert!(matches!(manifest.skill.category, SkillCategory::Template));
assert_eq!(manifest.skill.tags.len(), 2);
}
}