diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index de63751c1e56..f09ce243a8d8 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -1,5 +1,6 @@ use crate::agents::chatrecall_extension; use crate::agents::extension_manager_extension; +use crate::agents::skills_extension; use crate::agents::todo_extension; use std::collections::HashMap; @@ -76,6 +77,16 @@ pub static PLATFORM_EXTENSIONS: Lazy }, ); + map.insert( + skills_extension::EXTENSION_NAME, + PlatformExtensionDef { + name: skills_extension::EXTENSION_NAME, + description: "Load and use skills from .claude/skills or .goose/skills directories", + default_enabled: true, + client_factory: |ctx| Box::new(skills_extension::SkillsClient::new(ctx).unwrap()), + }, + ); + map }, ); diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 7bdaa18afa48..d53f394384c3 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -16,6 +16,7 @@ pub mod retry; mod router_tool_selector; mod router_tools; mod schedule_tool; +pub(crate) mod skills_extension; pub mod sub_recipe_manager; pub mod subagent_execution_tool; pub mod subagent_handler; diff --git a/crates/goose/src/agents/skills_extension.rs b/crates/goose/src/agents/skills_extension.rs new file mode 100644 index 000000000000..c4dcf41ac465 --- /dev/null +++ b/crates/goose/src/agents/skills_extension.rs @@ -0,0 +1,865 @@ +use crate::agents::extension::PlatformExtensionContext; +use crate::agents::mcp_client::{Error, McpClientTrait}; +use crate::config::paths::Paths; +use anyhow::Result; +use async_trait::async_trait; +use indoc::indoc; +use rmcp::model::{ + CallToolResult, Content, GetPromptResult, Implementation, InitializeResult, JsonObject, + ListPromptsResult, ListResourcesResult, ListToolsResult, ProtocolVersion, ReadResourceResult, + ServerCapabilities, ServerNotification, Tool, ToolAnnotations, ToolsCapability, +}; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +pub static EXTENSION_NAME: &str = "skills"; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct LoadSkillParams { + name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SkillMetadata { + name: String, + description: String, +} + +#[derive(Debug, Clone)] +struct Skill { + metadata: SkillMetadata, + body: String, + directory: PathBuf, + supporting_files: Vec, +} + +pub struct SkillsClient { + info: InitializeResult, + skills: HashMap, +} + +impl SkillsClient { + pub fn new(_context: PlatformExtensionContext) -> Result { + let info = InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + resources: None, + prompts: None, + completions: None, + experimental: None, + logging: None, + }, + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Skills".to_string()), + version: "1.0.0".to_string(), + icons: None, + website_url: None, + }, + instructions: Some(String::new()), + }; + + let directories = Self::get_default_skill_directories() + .into_iter() + .filter(|d| d.exists()) + .collect::>(); + let skills = Self::discover_skills_in_directories(&directories); + + let mut client = Self { info, skills }; + client.info.instructions = Some(client.generate_instructions()); + Ok(client) + } + + fn get_default_skill_directories() -> Vec { + let mut dirs = Vec::new(); + + if let Some(home) = dirs::home_dir() { + dirs.push(home.join(".claude/skills")); + } + + dirs.push(Paths::config_dir().join("skills")); + + if let Ok(working_dir) = std::env::current_dir() { + dirs.push(working_dir.join(".claude/skills")); + dirs.push(working_dir.join(".goose/skills")); + } + + dirs + } + + fn parse_skill_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + + let (metadata, body) = Self::parse_frontmatter(&content)?; + + let directory = path + .parent() + .ok_or_else(|| anyhow::anyhow!("Skill file has no parent directory"))? + .to_path_buf(); + + let supporting_files = Self::find_supporting_files(&directory, path)?; + + Ok(Skill { + metadata, + body, + directory, + supporting_files, + }) + } + + fn parse_frontmatter(content: &str) -> Result<(SkillMetadata, String)> { + let parts: Vec<&str> = content.split("---").collect(); + + if parts.len() < 3 { + return Err(anyhow::anyhow!("Invalid frontmatter format")); + } + + let yaml_content = parts[1].trim(); + let metadata: SkillMetadata = serde_yaml::from_str(yaml_content)?; + + let body = parts[2..].join("---").trim().to_string(); + + Ok((metadata, body)) + } + + fn find_supporting_files(directory: &Path, skill_file: &Path) -> Result> { + let mut files = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(directory) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path != skill_file { + files.push(path); + } else if path.is_dir() { + if let Ok(sub_entries) = std::fs::read_dir(&path) { + for sub_entry in sub_entries.flatten() { + let sub_path = sub_entry.path(); + if sub_path.is_file() { + files.push(sub_path); + } + } + } + } + } + } + + Ok(files) + } + + fn discover_skills_in_directories(directories: &[PathBuf]) -> HashMap { + let mut skills = HashMap::new(); + + for dir in directories { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let skill_file = path.join("SKILL.md"); + if skill_file.exists() { + if let Ok(skill) = Self::parse_skill_file(&skill_file) { + skills.insert(skill.metadata.name.clone(), skill); + } + } + } + } + } + } + + skills + } + + fn generate_instructions(&self) -> String { + if self.skills.is_empty() { + return String::new(); + } + + let mut instructions = String::from("You have these skills at your disposal, when it is clear they can help you solve a problem or you are asked to use them:\n\n"); + + let mut skill_list: Vec<_> = self.skills.iter().collect(); + skill_list.sort_by_key(|(name, _)| *name); + + for (name, skill) in skill_list { + instructions.push_str(&format!("- {}: {}\n", name, skill.metadata.description)); + } + + instructions + } + + async fn handle_load_skill( + &self, + arguments: Option, + ) -> Result, String> { + let skill_name = arguments + .as_ref() + .ok_or("Missing arguments")? + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing required parameter: name")?; + + let skill = self + .skills + .get(skill_name) + .ok_or_else(|| format!("Skill '{}' not found", skill_name))?; + + let mut response = format!("# Skill: {}\n\n{}\n\n", skill.metadata.name, skill.body); + + if !skill.supporting_files.is_empty() { + response.push_str(&format!( + "## Supporting Files\n\nSkill directory: {}\n\n", + skill.directory.display() + )); + response.push_str("The following supporting files are available:\n"); + for file in &skill.supporting_files { + if let Ok(relative) = file.strip_prefix(&skill.directory) { + response.push_str(&format!("- {}\n", relative.display())); + } + } + response.push_str("\nUse the view file tools to access these files as needed, or run scripts as directed with dev extension.\n"); + } + + Ok(vec![Content::text(response)]) + } + + fn get_tools() -> Vec { + let schema = schema_for!(LoadSkillParams); + let schema_value = + serde_json::to_value(schema).expect("Failed to serialize LoadSkillParams schema"); + + let input_schema = schema_value + .as_object() + .expect("Schema should be an object") + .clone(); + + vec![Tool::new( + "loadSkill".to_string(), + indoc! {r#" + Load a skill by name and return its content. + + This tool loads the specified skill and returns its body content along with + information about any supporting files in the skill directory. + "#} + .to_string(), + input_schema, + ) + .annotate(ToolAnnotations { + title: Some("Load skill".to_string()), + read_only_hint: Some(true), + destructive_hint: Some(false), + idempotent_hint: Some(true), + open_world_hint: Some(false), + })] + } +} + +#[async_trait] +impl McpClientTrait for SkillsClient { + async fn list_resources( + &self, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn read_resource( + &self, + _uri: &str, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn list_tools( + &self, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + let tools = if self.skills.is_empty() { + Vec::new() + } else { + Self::get_tools() + }; + Ok(ListToolsResult { + tools, + next_cursor: None, + }) + } + + async fn call_tool( + &self, + name: &str, + arguments: Option, + _cancellation_token: CancellationToken, + ) -> Result { + let content = match name { + "loadSkill" => self.handle_load_skill(arguments).await, + _ => Err(format!("Unknown tool: {}", name)), + }; + + match content { + Ok(content) => Ok(CallToolResult::success(content)), + Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {}", + error + ))])), + } + } + + async fn list_prompts( + &self, + _next_cursor: Option, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn get_prompt( + &self, + _name: &str, + _arguments: Value, + _cancellation_token: CancellationToken, + ) -> Result { + Err(Error::TransportClosed) + } + + async fn subscribe(&self) -> mpsc::Receiver { + mpsc::channel(1).1 + } + + fn get_info(&self) -> Option<&InitializeResult> { + Some(&self.info) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_parse_frontmatter() { + let content = r#"--- +name: test-skill +description: A test skill +--- + +# Test Skill + +This is the body of the skill. +"#; + + let (metadata, body) = SkillsClient::parse_frontmatter(content).unwrap(); + assert_eq!(metadata.name, "test-skill"); + assert_eq!(metadata.description, "A test skill"); + assert!(body.contains("# Test Skill")); + assert!(body.contains("This is the body of the skill.")); + } + + #[test] + fn test_parse_frontmatter_missing() { + let content = "# No frontmatter here"; + assert!(SkillsClient::parse_frontmatter(content).is_err()); + } + + #[test] + fn test_parse_frontmatter_unclosed() { + let content = r#"--- +name: test +description: test +"#; + assert!(SkillsClient::parse_frontmatter(content).is_err()); + } + + #[test] + fn test_parse_frontmatter_with_extra_fields() { + let content = r#"--- +name: test-skill +description: A test skill +author: Test Author +version: 1.0.0 +tags: + - test + - example +extra_field: some value +--- + +# Test Skill + +This is the body of the skill. +"#; + + let (metadata, body) = SkillsClient::parse_frontmatter(content).unwrap(); + assert_eq!(metadata.name, "test-skill"); + assert_eq!(metadata.description, "A test skill"); + assert!(body.contains("# Test Skill")); + assert!(body.contains("This is the body of the skill.")); + } + + #[test] + fn test_parse_skill_file() { + let temp_dir = TempDir::new().unwrap(); + let skill_dir = temp_dir.path().join("test-skill"); + fs::create_dir(&skill_dir).unwrap(); + + let skill_file = skill_dir.join("SKILL.md"); + fs::write( + &skill_file, + r#"--- +name: test-skill +description: A test skill +--- + +# Test Skill Content +"#, + ) + .unwrap(); + + fs::write(skill_dir.join("helper.py"), "print('hello')").unwrap(); + fs::create_dir(skill_dir.join("templates")).unwrap(); + fs::write(skill_dir.join("templates/template.txt"), "template").unwrap(); + + let skill = SkillsClient::parse_skill_file(&skill_file).unwrap(); + assert_eq!(skill.metadata.name, "test-skill"); + assert_eq!(skill.metadata.description, "A test skill"); + assert!(skill.body.contains("# Test Skill Content")); + assert_eq!(skill.supporting_files.len(), 2); + } + + #[test] + fn test_discover_skills() { + let temp_dir = TempDir::new().unwrap(); + let skills_dir = temp_dir.path().join("skills"); + fs::create_dir(&skills_dir).unwrap(); + + let skill1_dir = skills_dir.join("test-skill-one-a1b2c3"); + fs::create_dir(&skill1_dir).unwrap(); + fs::write( + skill1_dir.join("SKILL.md"), + r#"--- +name: test-skill-one-a1b2c3 +description: First test skill +--- +Body 1 +"#, + ) + .unwrap(); + + let skill2_dir = skills_dir.join("test-skill-two-d4e5f6"); + fs::create_dir(&skill2_dir).unwrap(); + fs::write( + skill2_dir.join("SKILL.md"), + r#"--- +name: test-skill-two-d4e5f6 +description: Second test skill +--- +Body 2 +"#, + ) + .unwrap(); + + let skill3_dir = skills_dir.join("test-skill-three-g7h8i9"); + fs::create_dir(&skill3_dir).unwrap(); + fs::write( + skill3_dir.join("SKILL.md"), + r#"--- +name: test-skill-three-g7h8i9 +description: Third test skill +--- +Body 3 +"#, + ) + .unwrap(); + + let skills = SkillsClient::discover_skills_in_directories(&[skills_dir]); + + assert_eq!(skills.len(), 3); + assert!(skills.contains_key("test-skill-one-a1b2c3")); + assert!(skills.contains_key("test-skill-two-d4e5f6")); + assert!(skills.contains_key("test-skill-three-g7h8i9")); + } + + #[test] + fn test_discover_skills_from_multiple_directories() { + let temp_dir = TempDir::new().unwrap(); + + let dir1 = temp_dir.path().join("dir1"); + fs::create_dir(&dir1).unwrap(); + let skill1_dir = dir1.join("skill-from-dir1"); + fs::create_dir(&skill1_dir).unwrap(); + fs::write( + skill1_dir.join("SKILL.md"), + r#"--- +name: skill-from-dir1 +description: Skill from directory 1 +--- +Content from dir1 +"#, + ) + .unwrap(); + + let dir2 = temp_dir.path().join("dir2"); + fs::create_dir(&dir2).unwrap(); + let skill2_dir = dir2.join("skill-from-dir2"); + fs::create_dir(&skill2_dir).unwrap(); + fs::write( + skill2_dir.join("SKILL.md"), + r#"--- +name: skill-from-dir2 +description: Skill from directory 2 +--- +Content from dir2 +"#, + ) + .unwrap(); + + let dir3 = temp_dir.path().join("dir3"); + fs::create_dir(&dir3).unwrap(); + let skill3_dir = dir3.join("skill-from-dir3"); + fs::create_dir(&skill3_dir).unwrap(); + fs::write( + skill3_dir.join("SKILL.md"), + r#"--- +name: skill-from-dir3 +description: Skill from directory 3 +--- +Content from dir3 +"#, + ) + .unwrap(); + + let skills = SkillsClient::discover_skills_in_directories(&[dir1, dir2, dir3]); + + assert_eq!(skills.len(), 3); + assert!(skills.contains_key("skill-from-dir1")); + assert!(skills.contains_key("skill-from-dir2")); + assert!(skills.contains_key("skill-from-dir3")); + + assert_eq!( + skills.get("skill-from-dir1").unwrap().metadata.description, + "Skill from directory 1" + ); + assert_eq!( + skills.get("skill-from-dir2").unwrap().metadata.description, + "Skill from directory 2" + ); + assert_eq!( + skills.get("skill-from-dir3").unwrap().metadata.description, + "Skill from directory 3" + ); + } + + #[test] + fn test_empty_instructions_when_no_skills() { + let temp_dir = TempDir::new().unwrap(); + let empty_dir = temp_dir.path().join("empty"); + fs::create_dir(&empty_dir).unwrap(); + + let skills = SkillsClient::discover_skills_in_directories(&[empty_dir]); + assert_eq!(skills.len(), 0); + + let mut client = SkillsClient { + info: InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + resources: None, + prompts: None, + completions: None, + experimental: None, + logging: None, + }, + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Skills".to_string()), + version: "1.0.0".to_string(), + icons: None, + website_url: None, + }, + instructions: Some(String::new()), + }, + skills, + }; + + let instructions = client.generate_instructions(); + assert_eq!(instructions, ""); + assert!(instructions.is_empty()); + + client.info.instructions = Some(instructions); + assert_eq!(client.info.instructions.as_ref().unwrap(), ""); + } + + #[tokio::test] + async fn test_no_tools_when_no_skills() { + let temp_dir = TempDir::new().unwrap(); + let empty_dir = temp_dir.path().join("empty"); + fs::create_dir(&empty_dir).unwrap(); + + let skills = SkillsClient::discover_skills_in_directories(&[empty_dir]); + assert_eq!(skills.len(), 0); + + let client = SkillsClient { + info: InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + resources: None, + prompts: None, + completions: None, + experimental: None, + logging: None, + }, + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Skills".to_string()), + version: "1.0.0".to_string(), + icons: None, + website_url: None, + }, + instructions: Some(String::new()), + }, + skills, + }; + + let result = client + .list_tools(None, CancellationToken::new()) + .await + .unwrap(); + assert_eq!(result.tools.len(), 0); + } + + #[tokio::test] + async fn test_tools_available_when_skills_exist() { + let temp_dir = TempDir::new().unwrap(); + let skills_dir = temp_dir.path().join("skills"); + fs::create_dir(&skills_dir).unwrap(); + + let skill_dir = skills_dir.join("test-skill"); + fs::create_dir(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + r#"--- +name: test-skill +description: A test skill +--- +Content +"#, + ) + .unwrap(); + + let skills = SkillsClient::discover_skills_in_directories(&[skills_dir]); + assert_eq!(skills.len(), 1); + + let client = SkillsClient { + info: InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + resources: None, + prompts: None, + completions: None, + experimental: None, + logging: None, + }, + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Skills".to_string()), + version: "1.0.0".to_string(), + icons: None, + website_url: None, + }, + instructions: Some(String::new()), + }, + skills, + }; + + let result = client + .list_tools(None, CancellationToken::new()) + .await + .unwrap(); + assert_eq!(result.tools.len(), 1); + assert_eq!(result.tools[0].name, "loadSkill"); + } + + #[test] + fn test_instructions_with_skills() { + let temp_dir = TempDir::new().unwrap(); + let skills_dir = temp_dir.path().join("skills"); + fs::create_dir(&skills_dir).unwrap(); + + let skill1_dir = skills_dir.join("alpha-skill"); + fs::create_dir(&skill1_dir).unwrap(); + fs::write( + skill1_dir.join("SKILL.md"), + r#"--- +name: alpha-skill +description: First skill alphabetically +--- +Content +"#, + ) + .unwrap(); + + let skill2_dir = skills_dir.join("beta-skill"); + fs::create_dir(&skill2_dir).unwrap(); + fs::write( + skill2_dir.join("SKILL.md"), + r#"--- +name: beta-skill +description: Second skill alphabetically +--- +Content +"#, + ) + .unwrap(); + + let skills = SkillsClient::discover_skills_in_directories(&[skills_dir]); + assert_eq!(skills.len(), 2); + + let mut client = SkillsClient { + info: InitializeResult { + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: Some(false), + }), + resources: None, + prompts: None, + completions: None, + experimental: None, + logging: None, + }, + server_info: Implementation { + name: EXTENSION_NAME.to_string(), + title: Some("Skills".to_string()), + version: "1.0.0".to_string(), + icons: None, + website_url: None, + }, + instructions: Some(String::new()), + }, + skills, + }; + + let instructions = client.generate_instructions(); + assert!(!instructions.is_empty()); + assert!(instructions.contains("You have these skills at your disposal")); + assert!(instructions.contains("alpha-skill: First skill alphabetically")); + assert!(instructions.contains("beta-skill: Second skill alphabetically")); + + let lines: Vec<&str> = instructions.lines().collect(); + let alpha_line = lines + .iter() + .position(|l| l.contains("alpha-skill")) + .unwrap(); + let beta_line = lines.iter().position(|l| l.contains("beta-skill")).unwrap(); + assert!(alpha_line < beta_line); + + client.info.instructions = Some(instructions); + assert!(!client.info.instructions.as_ref().unwrap().is_empty()); + } + + #[test] + fn test_discover_skills_working_dir_overrides_global() { + let temp_dir = TempDir::new().unwrap(); + + // Simulate ~/.claude/skills (global, lowest priority) + let global_claude = temp_dir.path().join("global-claude"); + fs::create_dir(&global_claude).unwrap(); + let skill_global_claude = global_claude.join("my-skill"); + fs::create_dir(&skill_global_claude).unwrap(); + fs::write( + skill_global_claude.join("SKILL.md"), + r#"--- +name: my-skill +description: From global claude +--- +Global claude content +"#, + ) + .unwrap(); + + // Simulate ~/.config/goose/skills (global, medium priority) + let global_goose = temp_dir.path().join("global-goose"); + fs::create_dir(&global_goose).unwrap(); + let skill_global_goose = global_goose.join("my-skill"); + fs::create_dir(&skill_global_goose).unwrap(); + fs::write( + skill_global_goose.join("SKILL.md"), + r#"--- +name: my-skill +description: From global goose config +--- +Global goose config content +"#, + ) + .unwrap(); + + // Simulate $PWD/.claude/skills (working dir, higher priority) + let working_claude = temp_dir.path().join("working-claude"); + fs::create_dir(&working_claude).unwrap(); + let skill_working_claude = working_claude.join("my-skill"); + fs::create_dir(&skill_working_claude).unwrap(); + fs::write( + skill_working_claude.join("SKILL.md"), + r#"--- +name: my-skill +description: From working dir claude +--- +Working dir claude content +"#, + ) + .unwrap(); + + // Simulate $PWD/.goose/skills (working dir, highest priority) + let working_goose = temp_dir.path().join("working-goose"); + fs::create_dir(&working_goose).unwrap(); + let skill_working_goose = working_goose.join("my-skill"); + fs::create_dir(&skill_working_goose).unwrap(); + fs::write( + skill_working_goose.join("SKILL.md"), + r#"--- +name: my-skill +description: From working dir goose +--- +Working dir goose content +"#, + ) + .unwrap(); + + // Test priority order: global_claude < global_goose < working_claude < working_goose + let skills = SkillsClient::discover_skills_in_directories(&[ + global_claude, + global_goose, + working_claude, + working_goose, + ]); + + assert_eq!(skills.len(), 1); + assert!(skills.contains_key("my-skill")); + // The last directory (working_goose) should win + assert_eq!( + skills.get("my-skill").unwrap().metadata.description, + "From working dir goose" + ); + assert!(skills + .get("my-skill") + .unwrap() + .body + .contains("Working dir goose content")); + } +}