use crate::config::YamlConfig;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillSource {
User,
Project,
}
impl SkillSource {
pub fn label(&self) -> &'static str {
match self {
SkillSource::User => "用户",
SkillSource::Project => "项目",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SkillFrontmatter {
pub name: String,
pub description: String,
#[allow(dead_code)]
#[serde(rename = "argument-hint")]
pub argument_hint: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Skill {
pub frontmatter: SkillFrontmatter,
pub body: String,
pub dir_path: PathBuf,
pub source: SkillSource,
}
pub fn skills_dir() -> PathBuf {
let dir = YamlConfig::data_dir().join("agent").join("skills");
let _ = fs::create_dir_all(&dir);
dir
}
pub fn project_skills_dir() -> Option<PathBuf> {
use super::permission::JcliConfig;
let config_dir = JcliConfig::find_config_dir()?;
let dir = config_dir.join("skills");
if dir.is_dir() { Some(dir) } else { None }
}
fn load_skills_from_dir(dir: &Path, source: SkillSource) -> Vec<Skill> {
let mut skills = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return skills,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if skill_md.exists()
&& let Some(mut skill) = parse_skill_md(&skill_md, &path)
{
skill.source = source;
skills.push(skill);
}
}
skills
}
pub fn load_all_skills() -> Vec<Skill> {
let mut map: HashMap<String, Skill> = HashMap::new();
for skill in load_skills_from_dir(&skills_dir(), SkillSource::User) {
map.insert(skill.frontmatter.name.clone(), skill);
}
if let Some(dir) = project_skills_dir() {
for skill in load_skills_from_dir(&dir, SkillSource::Project) {
map.insert(skill.frontmatter.name.clone(), skill);
}
}
let mut skills: Vec<Skill> = map.into_values().collect();
skills.sort_by(|a, b| a.frontmatter.name.cmp(&b.frontmatter.name));
skills
}
fn parse_skill_md(path: &PathBuf, dir: &Path) -> Option<Skill> {
let content = fs::read_to_string(path).ok()?;
let (fm_str, body) = split_frontmatter(&content)?;
let frontmatter: SkillFrontmatter = serde_yaml::from_str(&fm_str).ok()?;
if frontmatter.name.is_empty() {
return None;
}
Some(Skill {
frontmatter,
body: body.trim().to_string(),
dir_path: dir.to_path_buf(),
source: SkillSource::User, })
}
pub(super) fn split_frontmatter(content: &str) -> Option<(String, String)> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let rest = &trimmed[3..];
let end_idx = rest.find("\n---")?;
let fm = rest[..end_idx].trim().to_string();
let body = rest[end_idx + 4..].to_string();
Some((fm, body))
}
pub fn resolve_skill_content(skill: &Skill) -> String {
let mut result = skill.body.clone();
if let Some(paths) = list_dir_files(&skill.dir_path.join("references")) {
result.push_str("\n\n## 参考文件\n\n以下参考文件可按需使用 Read 工具读取:\n");
for p in &paths {
result.push_str(&format!("- `{}`\n", p));
}
}
if let Some(paths) = list_dir_files(&skill.dir_path.join("scripts")) {
result.push_str("\n\n## 脚本\n\n以下脚本可按需使用 Bash/BackgroundRun 工具执行:\n");
for p in &paths {
result.push_str(&format!("- `{}`\n", p));
}
}
result
}
fn list_dir_files(dir: &Path) -> Option<Vec<String>> {
if !dir.is_dir() {
return None;
}
let entries = fs::read_dir(dir).ok()?;
let mut files: Vec<_> = entries.flatten().collect();
files.sort_by_key(|e| e.file_name());
let paths: Vec<String> = files
.iter()
.filter(|e| e.path().is_file())
.map(|e| e.path().display().to_string())
.collect();
if paths.is_empty() { None } else { Some(paths) }
}
pub fn build_skills_summary(skills: &[Skill], disabled_skills: &[String]) -> String {
let filtered: Vec<&Skill> = skills
.iter()
.filter(|s| !disabled_skills.iter().any(|d| d == &s.frontmatter.name))
.collect();
if filtered.is_empty() {
return "(暂无可用技能)".to_string();
}
let mut md = String::new();
for s in &filtered {
md.push_str(&format!(
"- {}: {}\n",
s.frontmatter.name, s.frontmatter.description
));
}
md.trim_end().to_string()
}