mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: implement SKILLS.md - claude compatibility. (#5760)
This commit is contained in:
@@ -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<HashMap<&'static str, PlatformExtensionDef>
|
||||
},
|
||||
);
|
||||
|
||||
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
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct SkillsClient {
|
||||
info: InitializeResult,
|
||||
skills: HashMap<String, Skill>,
|
||||
}
|
||||
|
||||
impl SkillsClient {
|
||||
pub fn new(_context: PlatformExtensionContext) -> Result<Self> {
|
||||
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::<Vec<_>>();
|
||||
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<PathBuf> {
|
||||
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<Skill> {
|
||||
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<Vec<PathBuf>> {
|
||||
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<String, Skill> {
|
||||
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<JsonObject>,
|
||||
) -> Result<Vec<Content>, 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<Tool> {
|
||||
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<String>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<ListResourcesResult, Error> {
|
||||
Err(Error::TransportClosed)
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
_uri: &str,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<ReadResourceResult, Error> {
|
||||
Err(Error::TransportClosed)
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_next_cursor: Option<String>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<ListToolsResult, Error> {
|
||||
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<JsonObject>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<CallToolResult, Error> {
|
||||
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<String>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<ListPromptsResult, Error> {
|
||||
Err(Error::TransportClosed)
|
||||
}
|
||||
|
||||
async fn get_prompt(
|
||||
&self,
|
||||
_name: &str,
|
||||
_arguments: Value,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<GetPromptResult, Error> {
|
||||
Err(Error::TransportClosed)
|
||||
}
|
||||
|
||||
async fn subscribe(&self) -> mpsc::Receiver<ServerNotification> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user