//! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom use crate::skills::{ build_skill_md, discover_skills, infer_skill_name, is_global_skill_dir, parse_skill_frontmatter, resolve_discoverable_skill_dir, resolve_skill_dir, skill_base_dir, validate_skill_name, }; use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use sacp::Error; use serde::Deserialize; use std::path::PathBuf; pub fn parse_frontmatter Deserialize<'de>>( content: &str, ) -> Result, serde_yaml::Error> { let parts: Vec<&str> = content.split("---").collect(); if parts.len() < 3 { return Ok(None); } let yaml_content = parts[1].trim(); let metadata: T = serde_yaml::from_str(yaml_content)?; let body = parts[2..].join("---").trim().to_string(); Ok(Some((metadata, body))) } fn require_skill_type(source_type: SourceType) -> Result<(), Error> { if source_type != SourceType::Skill { return Err(Error::invalid_params().data(format!( "Source type '{}' is not supported. Only 'skill' is currently supported.", source_type ))); } Ok(()) } fn source_entry( source_type: SourceType, name: &str, description: &str, content: &str, dir: &std::path::Path, global: bool, ) -> SourceEntry { SourceEntry { source_type, name: name.to_string(), description: description.to_string(), content: content.to_string(), directory: dir.to_string_lossy().to_string(), global, supporting_files: Vec::new(), } } pub fn create_source( source_type: SourceType, name: &str, description: &str, content: &str, global: bool, project_dir: Option<&str>, ) -> Result { require_skill_type(source_type)?; validate_skill_name(name)?; let dir = skill_base_dir(global, project_dir)?.join(name); if dir.exists() { return Err( Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) ); } fs::create_dir_all(&dir).map_err(|e| { Error::internal_error().data(format!("Failed to create source directory: {e}")) })?; let file_path = dir.join("SKILL.md"); let md = build_skill_md(name, description, content); fs::write(&file_path, md) .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; Ok(source_entry( source_type, name, description, content, &dir, global, )) } pub fn update_source( source_type: SourceType, path: &str, name: &str, description: &str, content: &str, ) -> Result { require_skill_type(source_type)?; validate_skill_name(name)?; let dir = resolve_discoverable_skill_dir(path)?; let current_dir_name = dir .file_name() .and_then(|value| value.to_str()) .ok_or_else(|| Error::internal_error().data("Failed to resolve source directory name"))?; let target_dir = if name == current_dir_name { dir.clone() } else { let base_dir = dir.parent().ok_or_else(|| { Error::internal_error().data("Failed to resolve source base directory") })?; let target_dir = base_dir.join(name); if target_dir.exists() { return Err( Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) ); } fs::rename(&dir, &target_dir).map_err(|e| { Error::internal_error().data(format!("Failed to rename source directory: {e}")) })?; target_dir }; let file_path = target_dir.join("SKILL.md"); let md = build_skill_md(name, description, content); fs::write(&file_path, md) .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; Ok(source_entry( source_type, name, description, content, &target_dir, is_global_skill_dir(&target_dir), )) } pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> { require_skill_type(source_type)?; let dir = resolve_skill_dir(path)?; fs::remove_dir_all(&dir) .map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?; Ok(()) } pub fn list_sources( source_type: Option, project_dir: Option<&str>, ) -> Result, Error> { if let Some(t) = source_type { require_skill_type(t)?; } let working_dir = project_dir .map(str::trim) .filter(|p| !p.is_empty()) .map(PathBuf::from); let mut sources: Vec = discover_skills(working_dir.as_deref()) .into_iter() .filter(|s| s.source_type == SourceType::Skill) .collect(); sources.sort_by(|a, b| a.name.cmp(&b.name)); Ok(sources) } pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, String), Error> { require_skill_type(source_type)?; let dir = resolve_discoverable_skill_dir(path)?; let md = dir.join("SKILL.md"); let raw = fs::read_to_string(&md) .map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?; let (description, content) = parse_skill_frontmatter(&raw); let name = infer_skill_name(&dir); let export = serde_json::json!({ "version": 1, "type": "skill", "name": name, "description": description, "content": content, }); let json = serde_json::to_string_pretty(&export) .map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?; let filename = format!("{}.skill.json", name); Ok((json, filename)) } pub fn import_sources( data: &str, global: bool, project_dir: Option<&str>, ) -> Result, Error> { let value: serde_json::Value = serde_json::from_str(data) .map_err(|e| Error::invalid_params().data(format!("Invalid JSON: {e}")))?; let version = value .get("version") .and_then(|v| v.as_u64()) .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"version\" field"))?; if version != 1 { return Err( Error::invalid_params().data(format!("Unsupported source export version: {}", version)) ); } match value .get("type") .and_then(|v| v.as_str()) .unwrap_or("skill") { "skill" => {} other => { return Err(Error::invalid_params().data(format!( "Source type '{}' is not supported. Only 'skill' is currently supported.", other ))); } }; let name = value .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"name\" field"))? .to_string(); if name.is_empty() { return Err(Error::invalid_params().data("Source name must not be empty")); } let description = value .get("description") .and_then(|v| v.as_str()) .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"description\" field"))? .to_string(); if description.is_empty() { return Err(Error::invalid_params().data("Source description must not be empty")); } let content = value .get("content") .or_else(|| value.get("instructions")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); validate_skill_name(&name)?; let base = skill_base_dir(global, project_dir)?; let mut final_name = name.clone(); if base.join(&final_name).exists() { final_name = format!("{}-imported", name); let mut counter = 2u32; while base.join(&final_name).exists() { final_name = format!("{}-imported-{}", name, counter); counter += 1; } } let dir = base.join(&final_name); fs::create_dir_all(&dir).map_err(|e| { Error::internal_error().data(format!("Failed to create source directory: {e}")) })?; let file_path = dir.join("SKILL.md"); let md = build_skill_md(&final_name, &description, &content); fs::write(&file_path, md) .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; Ok(vec![source_entry( SourceType::Skill, &final_name, &description, &content, &dir, global, )]) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn skill_name_validation() { assert!(validate_skill_name("my-skill").is_ok()); assert!(validate_skill_name("abc123").is_ok()); assert!(validate_skill_name("double--hyphen").is_ok()); assert!(validate_skill_name("").is_err()); assert!(validate_skill_name("-leading").is_err()); assert!(validate_skill_name("trailing-").is_err()); assert!(validate_skill_name("CAPS").is_err()); assert!(validate_skill_name("../escape").is_err()); assert!(validate_skill_name(&"a".repeat(64)).is_ok()); assert!(validate_skill_name(&"a".repeat(65)).is_err()); } #[test] fn create_list_update_delete_project_skill() { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); let created = create_source( SourceType::Skill, "my-skill", "does the thing", "step one\nstep two", false, Some(project), ) .unwrap(); assert_eq!(created.name, "my-skill"); assert!(!created.global); let dir = PathBuf::from(&created.directory); assert!(dir.join("SKILL.md").exists()); let listed = list_sources(Some(SourceType::Skill), Some(project)).unwrap(); assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global)); let updated = update_source( SourceType::Skill, created.directory.as_str(), "my-skill", "now does a different thing", "step three", ) .unwrap(); assert_eq!(updated.description, "now does a different thing"); assert_eq!(updated.name, "my-skill"); delete_source(SourceType::Skill, created.directory.as_str()).unwrap(); assert!(!dir.exists()); } #[test] fn create_rejects_duplicate_name() { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap(); let err = create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap_err(); assert!(format!("{:?}", err).contains("already exists")); } #[test] fn project_scope_requires_project_dir() { let err = create_source(SourceType::Skill, "x", "d", "c", false, None).unwrap_err(); assert!(format!("{:?}", err).contains("projectDir")); } #[test] fn export_then_import_roundtrip() { let tmp = TempDir::new().unwrap(); let project_a = tmp.path().join("a"); let project_b = tmp.path().join("b"); std::fs::create_dir_all(&project_a).unwrap(); std::fs::create_dir_all(&project_b).unwrap(); create_source( SourceType::Skill, "portable", "describes itself", "body goes here", false, Some(project_a.to_str().unwrap()), ) .unwrap(); let portable_dir = project_a.join(".goose").join("skills").join("portable"); let (json, filename) = export_source(SourceType::Skill, portable_dir.to_str().unwrap()).unwrap(); assert_eq!(filename, "portable.skill.json"); let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap(); assert_eq!(imported.len(), 1); assert_eq!(imported[0].name, "portable"); assert_eq!(imported[0].description, "describes itself"); assert_eq!(imported[0].content, "body goes here"); } #[test] fn export_allows_discovered_read_only_skill() { let tmp = TempDir::new().unwrap(); let project = tmp.path(); let claude_skill_dir = project.join(".claude").join("skills").join("portable"); std::fs::create_dir_all(&claude_skill_dir).unwrap(); std::fs::write( claude_skill_dir.join("SKILL.md"), build_skill_md("portable", "describes itself", "body goes here"), ) .unwrap(); let listed = list_sources(Some(SourceType::Skill), Some(project.to_str().unwrap())).unwrap(); let exported_skill = listed .iter() .find(|skill| skill.name == "portable") .expect("expected listed skill"); let (json, filename) = export_source(SourceType::Skill, exported_skill.directory.as_str()).unwrap(); assert_eq!(filename, "portable.skill.json"); assert!(json.contains("\"name\": \"portable\"")); } #[test] fn update_allows_discovered_read_only_skill() { let tmp = TempDir::new().unwrap(); let project = tmp.path(); let claude_skill_dir = project.join(".claude").join("skills").join("portable"); std::fs::create_dir_all(&claude_skill_dir).unwrap(); std::fs::write( claude_skill_dir.join("SKILL.md"), build_skill_md("portable", "describes itself", "body goes here"), ) .unwrap(); let updated = update_source( SourceType::Skill, claude_skill_dir.to_str().unwrap(), "portable", "updated description", "updated body", ) .unwrap(); assert_eq!(updated.name, "portable"); assert_eq!(updated.description, "updated description"); assert_eq!(updated.content, "updated body"); let raw = std::fs::read_to_string(claude_skill_dir.join("SKILL.md")).unwrap(); assert!(raw.contains("description: 'updated description'")); assert!(raw.contains("updated body")); } #[test] fn import_collision_appends_suffix() { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); create_source(SourceType::Skill, "busy", "d", "c", false, Some(project)).unwrap(); let payload = serde_json::json!({ "version": 1, "type": "skill", "name": "busy", "description": "d", "content": "c", }) .to_string(); let imported = import_sources(&payload, false, Some(project)).unwrap(); assert_eq!(imported[0].name, "busy-imported"); } #[test] fn update_rejects_nonexistent_source() { let tmp = TempDir::new().unwrap(); let missing_dir = tmp .path() .join(".goose") .join("skills") .join("no-such-skill"); let err = update_source( SourceType::Skill, missing_dir.to_str().unwrap(), "no-such-skill", "d", "c", ) .unwrap_err(); assert!(format!("{:?}", err).contains("not found")); } #[test] fn delete_rejects_nonexistent_source() { let tmp = TempDir::new().unwrap(); let missing_dir = tmp .path() .join(".goose") .join("skills") .join("no-such-skill"); let err = delete_source(SourceType::Skill, missing_dir.to_str().unwrap()).unwrap_err(); assert!(format!("{:?}", err).contains("not found")); } #[test] fn rejects_non_skill_source_type() { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); let err = create_source( SourceType::BuiltinSkill, "x", "d", "c", false, Some(project), ) .unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); let err = update_source(SourceType::Recipe, "x", "x", "d", "c").unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); let err = delete_source(SourceType::Subrecipe, "x").unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); let err = list_sources(Some(SourceType::BuiltinSkill), Some(project)).unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); let err = export_source(SourceType::Recipe, "x").unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); } #[test] fn update_derives_name_from_frontmatter() { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); create_source( SourceType::Skill, "my-dir", "orig", "body", false, Some(project), ) .unwrap(); let skill_dir = tmp.path().join(".goose").join("skills").join("my-dir"); let updated = update_source( SourceType::Skill, skill_dir.to_str().unwrap(), "my-dir", "new description", "new body", ) .unwrap(); // Name is derived from the frontmatter written by create_source assert_eq!(updated.name, "my-dir"); } #[test] fn update_rejects_path_traversal() { let tmp = TempDir::new().unwrap(); let project = tmp.path(); let escaped_dir = project.join(".goose").join("escaped"); std::fs::create_dir_all(&escaped_dir).unwrap(); std::fs::write( escaped_dir.join("SKILL.md"), "---\nname: escaped\ndescription: escaped\n---\ncontent", ) .unwrap(); let attempted_escape = project.join(".goose").join("escaped"); let err = update_source( SourceType::Skill, attempted_escape.to_str().unwrap(), "escaped", "new description", "new content", ) .unwrap_err(); assert!(format!("{:?}", err).contains("not found")); } }