use anyhow::Result;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub mod embedded {
pub const BASICS: &str = include_str!("../../skills/task-graph-basics/SKILL.md");
pub const COORDINATOR: &str = include_str!("../../skills/task-graph-coordinator/SKILL.md");
pub const WORKER: &str = include_str!("../../skills/task-graph-worker/SKILL.md");
pub const REPORTING: &str = include_str!("../../skills/task-graph-reporting/SKILL.md");
pub const MIGRATION: &str = include_str!("../../skills/task-graph-migration/SKILL.md");
pub const REPAIR: &str = include_str!("../../skills/task-graph-repair/SKILL.md");
}
#[derive(Debug, Clone)]
pub struct SkillInfo {
pub name: &'static str,
pub full_name: &'static str,
pub description: &'static str,
pub role: &'static str,
}
pub const SKILLS: &[SkillInfo] = &[
SkillInfo {
name: "basics",
full_name: "task-graph-basics",
description: "Foundation - tool reference, connection workflow, shared patterns",
role: "foundation",
},
SkillInfo {
name: "coordinator",
full_name: "task-graph-coordinator",
description: "Orchestrator - create task trees, assign work, monitor progress",
role: "coordinator",
},
SkillInfo {
name: "worker",
full_name: "task-graph-worker",
description: "Executor - claim tasks, report progress, complete work",
role: "worker",
},
SkillInfo {
name: "reporting",
full_name: "task-graph-reporting",
description: "Analytics - generate reports, track costs and velocity",
role: "reporting",
},
SkillInfo {
name: "migration",
full_name: "task-graph-migration",
description: "Import - migrate from GitHub Issues, Linear, Jira, markdown",
role: "migration",
},
SkillInfo {
name: "repair",
full_name: "task-graph-repair",
description: "Maintenance - fix orphaned tasks, broken deps, stale claims",
role: "repair",
},
];
fn get_embedded_skill(name: &str) -> Option<&'static str> {
match name {
"basics" | "task-graph-basics" => Some(embedded::BASICS),
"coordinator" | "task-graph-coordinator" => Some(embedded::COORDINATOR),
"worker" | "task-graph-worker" => Some(embedded::WORKER),
"reporting" | "task-graph-reporting" => Some(embedded::REPORTING),
"migration" | "task-graph-migration" => Some(embedded::MIGRATION),
"repair" | "task-graph-repair" => Some(embedded::REPAIR),
_ => None,
}
}
fn normalize_name(name: &str) -> &str {
name.strip_prefix("task-graph-").unwrap_or(name)
}
fn is_builtin_skill(name: &str) -> bool {
let normalized = normalize_name(name);
SKILLS.iter().any(|s| s.name == normalized)
}
fn get_approvals_path(skills_dir: &Path) -> PathBuf {
skills_dir.join(".approved")
}
pub fn load_approved_skills(skills_dir: Option<&Path>) -> HashSet<String> {
let mut approved = HashSet::new();
if let Some(dir) = skills_dir {
let approvals_path = get_approvals_path(dir);
if approvals_path.exists() {
if let Ok(content) = std::fs::read_to_string(&approvals_path) {
for line in content.lines() {
let name = line.trim();
if !name.is_empty() && !name.starts_with('#') {
approved.insert(name.to_string());
}
}
}
}
}
approved
}
pub fn is_skill_approved(skills_dir: Option<&Path>, name: &str) -> bool {
if is_builtin_skill(name) {
return true;
}
let approved = load_approved_skills(skills_dir);
let normalized = normalize_name(name);
approved.contains(normalized) || approved.contains(name)
}
pub fn approve_skill(skills_dir: &Path, name: &str) -> Result<()> {
validate_skill_name(name)?;
if is_builtin_skill(name) {
return Ok(());
}
let approvals_path = get_approvals_path(skills_dir);
let mut approved = load_approved_skills(Some(skills_dir));
let normalized = normalize_name(name).to_string();
if approved.contains(&normalized) {
return Ok(()); }
approved.insert(normalized.clone());
let content: Vec<String> = approved.into_iter().collect();
std::fs::write(&approvals_path, content.join("\n") + "\n")?;
Ok(())
}
pub fn revoke_skill(skills_dir: &Path, name: &str) -> Result<()> {
validate_skill_name(name)?;
let approvals_path = get_approvals_path(skills_dir);
let mut approved = load_approved_skills(Some(skills_dir));
let normalized = normalize_name(name).to_string();
approved.remove(&normalized);
approved.remove(name);
let content: Vec<String> = approved.into_iter().collect();
if content.is_empty() {
let _ = std::fs::remove_file(&approvals_path);
} else {
std::fs::write(&approvals_path, content.join("\n") + "\n")?;
}
Ok(())
}
fn validate_skill_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(anyhow::anyhow!("Skill name cannot be empty"));
}
if name.len() > 64 {
return Err(anyhow::anyhow!("Skill name too long (max 64 chars)"));
}
if name.contains("..") || name.contains('/') || name.contains('\\') {
return Err(anyhow::anyhow!("Invalid skill name: path traversal not allowed"));
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(anyhow::anyhow!(
"Invalid skill name: only alphanumeric, hyphen, and underscore allowed"
));
}
Ok(())
}
fn get_override_path(skills_dir: &Path, name: &str) -> PathBuf {
let normalized = normalize_name(name);
let full_name = format!("task-graph-{}", normalized);
let short_path = skills_dir.join(normalized).join("SKILL.md");
let full_path = skills_dir.join(&full_name).join("SKILL.md");
if full_path.exists() {
full_path
} else {
short_path
}
}
pub fn get_skill(skills_dir: Option<&Path>, name: &str) -> Result<String> {
validate_skill_name(name)?;
let normalized = normalize_name(name);
if let Some(dir) = skills_dir {
let override_path = get_override_path(dir, name);
if let Ok(canonical_override) = override_path.canonicalize() {
if let Ok(canonical_dir) = dir.canonicalize() {
if canonical_override.starts_with(&canonical_dir) && override_path.exists() {
return std::fs::read_to_string(&override_path).map_err(|e| {
anyhow::anyhow!("Failed to read skill override: {}", e)
});
}
}
}
}
get_embedded_skill(normalized)
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("Unknown skill: {}", name))
}
fn is_overridden(skills_dir: Option<&Path>, name: &str) -> bool {
if let Some(dir) = skills_dir {
get_override_path(dir, name).exists()
} else {
false
}
}
pub fn list_skills(skills_dir: Option<&Path>) -> Result<Value> {
let approved_set = load_approved_skills(skills_dir);
let mut skills_list: Vec<Value> = SKILLS
.iter()
.map(|s| {
let overridden = is_overridden(skills_dir, s.name);
json!({
"name": s.name,
"full_name": s.full_name,
"description": s.description,
"role": s.role,
"uri": format!("skills://{}", s.name),
"overridden": overridden,
"source": if overridden { "local" } else { "embedded" },
"approved": true, "trusted": true,
})
})
.collect();
if let Some(dir) = skills_dir {
if dir.exists() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
let name = path.file_name().unwrap().to_string_lossy().to_string();
let normalized = normalize_name(&name);
if SKILLS.iter().any(|s| s.name == normalized || s.full_name == name) {
continue;
}
let is_approved =
approved_set.contains(normalized) || approved_set.contains(&name);
skills_list.push(json!({
"name": normalized,
"full_name": name,
"description": "Custom skill (requires approval)",
"role": "custom",
"uri": format!("skills://{}", normalized),
"overridden": false,
"source": "local",
"approved": is_approved,
"trusted": false,
}));
}
}
}
}
}
}
Ok(json!({
"skills": skills_list,
"count": skills_list.len(),
"override_dir": skills_dir.map(|p| p.display().to_string()),
}))
}
pub fn get_skill_resource(skills_dir: Option<&Path>, name: &str) -> Result<Value> {
validate_skill_name(name)?;
let normalized = normalize_name(name);
let is_builtin = is_builtin_skill(name);
let overridden = is_overridden(skills_dir, name);
let approved = is_skill_approved(skills_dir, name);
let info = SKILLS.iter().find(|s| s.name == normalized);
if !is_builtin && !approved {
let preview = match get_skill(skills_dir, name) {
Ok(content) => {
let preview_len = content.len().min(500);
let mut preview: String = content.chars().take(preview_len).collect();
if content.len() > 500 {
preview.push_str("\n\n[... content truncated - skill requires approval ...]");
}
Some(preview)
}
Err(_) => None,
};
return Ok(json!({
"name": normalized,
"full_name": name,
"role": "custom",
"description": "Custom skill (requires approval)",
"content": null,
"preview": preview,
"mime_type": "text/markdown",
"overridden": false,
"source": "local",
"approved": false,
"trusted": false,
"error": "Skill requires approval. Use approve_skill tool or add to .task-graph/skills/.approved",
}));
}
let content = get_skill(skills_dir, name)?;
Ok(json!({
"name": info.map(|i| i.name).unwrap_or(normalized),
"full_name": info.map(|i| i.full_name).unwrap_or(name),
"role": info.map(|i| i.role).unwrap_or("custom"),
"description": info.map(|i| i.description).unwrap_or("Custom skill"),
"content": content,
"preview": null,
"mime_type": "text/markdown",
"overridden": overridden,
"source": if is_builtin && !overridden { "embedded" } else { "local" },
"approved": true,
"trusted": is_builtin,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_embedded_skill() {
assert!(get_embedded_skill("basics").is_some());
assert!(get_embedded_skill("task-graph-basics").is_some());
assert!(get_embedded_skill("unknown").is_none());
}
#[test]
fn test_normalize_name() {
assert_eq!(normalize_name("basics"), "basics");
assert_eq!(normalize_name("task-graph-basics"), "basics");
assert_eq!(normalize_name("task-graph-coordinator"), "coordinator");
}
#[test]
fn test_get_skill_embedded() {
let content = get_skill(None, "basics").unwrap();
assert!(!content.is_empty());
assert!(content.starts_with("---"));
}
#[test]
fn test_list_skills() {
let result = list_skills(None).unwrap();
assert_eq!(result["count"], 6);
}
#[test]
fn test_skill_content_not_empty() {
for skill in SKILLS {
let content = get_skill(None, skill.name).unwrap();
assert!(!content.is_empty(), "Skill {} is empty", skill.name);
assert!(
content.starts_with("---"),
"Skill {} missing frontmatter",
skill.name
);
}
}
}