Dedupe and organize skills/sources (#8731)

This commit is contained in:
Jack Amadeo
2026-04-23 15:07:45 -04:00
committed by GitHub
parent 586fa2b3c8
commit 5dd9f08d57
28 changed files with 1245 additions and 786 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ impl GooseCompleter {
/// Complete skill names for the /skills command /// Complete skill names for the /skills command
fn complete_skill_names(&self, line: &str) -> Result<(usize, Vec<Pair>)> { fn complete_skill_names(&self, line: &str) -> Result<(usize, Vec<Pair>)> {
use goose::agents::platform_extensions::skills::list_installed_skills; use goose::skills::list_installed_skills;
let cwd = std::env::current_dir().unwrap_or_default(); let cwd = std::env::current_dir().unwrap_or_default();
let skills = list_installed_skills(Some(&cwd)); let skills = list_installed_skills(Some(&cwd));
+1 -1
View File
@@ -907,7 +907,7 @@ impl CliSession {
async fn handle_list_skills(&mut self) -> Result<()> { async fn handle_list_skills(&mut self) -> Result<()> {
use comfy_table::{presets, Cell, ContentArrangement, Table}; use comfy_table::{presets, Cell, ContentArrangement, Table};
use goose::agents::platform_extensions::skills::list_installed_skills; use goose::skills::list_installed_skills;
let cwd = std::env::current_dir().unwrap_or_default(); let cwd = std::env::current_dir().unwrap_or_default();
let skills = list_installed_skills(Some(&cwd)); let skills = list_installed_skills(Some(&cwd));
+53 -23
View File
@@ -286,15 +286,33 @@ pub struct ProviderConfigKey {
} }
/// The type of source entity. /// The type of source entity.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(
Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum SourceType { pub enum SourceType {
#[default] #[default]
Skill, Skill,
BuiltinSkill,
Recipe,
Subrecipe,
Agent,
} }
/// A source — a user-editable entity backed by an on-disk directory. Sources impl std::fmt::Display for SourceType {
/// may be either `global` (shared across all projects) or project-specific. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SourceType::Skill => write!(f, "skill"),
SourceType::BuiltinSkill => write!(f, "builtin skill"),
SourceType::Recipe => write!(f, "recipe"),
SourceType::Subrecipe => write!(f, "subrecipe"),
SourceType::Agent => write!(f, "agent"),
}
}
}
/// A source discovered by Goose and backed by an on-disk path. Sources may be
/// either `global` (shared across all projects) or project-specific.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SourceEntry { pub struct SourceEntry {
@@ -303,14 +321,31 @@ pub struct SourceEntry {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub content: String, pub content: String,
/// Absolute path to the source's directory on disk. /// Absolute path to the source on disk. A directory for skills, a file for
/// recipes and agents.
pub directory: String, pub directory: String,
/// True when the source lives in the user's global sources directory; false /// True when the source lives in the user's global sources directory; false
/// when it lives inside a specific project. /// when it lives inside a specific project.
pub global: bool, pub global: bool,
/// Paths (absolute) of additional files that live alongside the source.
/// Only skills currently populate this; empty for other source types.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supporting_files: Vec<String>,
} }
/// Create a new source (global or project-scoped). impl SourceEntry {
/// Render this source as a markdown block suitable for injecting into an
/// LLM context. Used by the skills and summon runtimes when loading a
/// source into the current conversation.
pub fn to_load_text(&self) -> String {
format!(
"## {} ({})\n\n{}\n\n### Content\n\n{}",
self.name, self.source_type, self.description, self.content
)
}
}
/// Create a new source in an explicit target scope (global or project-scoped).
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/sources/create", response = CreateSourceResponse)] #[request(method = "_goose/sources/create", response = CreateSourceResponse)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -332,8 +367,11 @@ pub struct CreateSourceResponse {
pub source: SourceEntry, pub source: SourceEntry,
} }
/// List sources. If `type` is omitted, sources of all known types are returned. /// List discovered sources.
/// Both global and project-scoped sources are included when `project_dir` is set. ///
/// Today this endpoint only returns skills. If `type` is omitted, it defaults
/// to listing skill sources. Both global and project-scoped skills are included
/// when `project_dir` is set.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/sources/list", response = ListSourcesResponse)] #[request(method = "_goose/sources/list", response = ListSourcesResponse)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -350,19 +388,17 @@ pub struct ListSourcesResponse {
pub sources: Vec<SourceEntry>, pub sources: Vec<SourceEntry>,
} }
/// Update an existing source's description and content. /// Update an existing source's name, description, and content by absolute path.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/sources/update", response = UpdateSourceResponse)] #[request(method = "_goose/sources/update", response = UpdateSourceResponse)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UpdateSourceRequest { pub struct UpdateSourceRequest {
#[serde(rename = "type")] #[serde(rename = "type")]
pub source_type: SourceType, pub source_type: SourceType,
pub path: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub content: String, pub content: String,
pub global: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_dir: Option<String>,
} }
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
@@ -371,30 +407,24 @@ pub struct UpdateSourceResponse {
pub source: SourceEntry, pub source: SourceEntry,
} }
/// Delete a source and its on-disk directory. /// Delete a source and its on-disk directory by absolute path.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/sources/delete", response = EmptyResponse)] #[request(method = "_goose/sources/delete", response = EmptyResponse)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DeleteSourceRequest { pub struct DeleteSourceRequest {
#[serde(rename = "type")] #[serde(rename = "type")]
pub source_type: SourceType, pub source_type: SourceType,
pub name: String, pub path: String,
pub global: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_dir: Option<String>,
} }
/// Export a source as a portable JSON payload. /// Export a source at an absolute path as a portable JSON payload.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/sources/export", response = ExportSourceResponse)] #[request(method = "_goose/sources/export", response = ExportSourceResponse)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ExportSourceRequest { pub struct ExportSourceRequest {
#[serde(rename = "type")] #[serde(rename = "type")]
pub source_type: SourceType, pub source_type: SourceType,
pub name: String, pub path: String,
pub global: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_dir: Option<String>,
} }
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
@@ -405,8 +435,8 @@ pub struct ExportSourceResponse {
} }
/// Import a source from a JSON export payload produced by `_goose/sources/export`. /// Import a source from a JSON export payload produced by `_goose/sources/export`.
/// The imported source is written under the given scope; on name collisions a /// The imported source is written into the explicit target scope; on name
/// `-imported` suffix is appended. /// collisions a `-imported` suffix is appended.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/sources/import", response = ImportSourcesResponse)] #[request(method = "_goose/sources/import", response = ImportSourcesResponse)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -11,6 +11,7 @@ use goose::config::declarative_providers::LoadedProvider;
use goose::config::paths::Paths; use goose::config::paths::Paths;
use goose::config::ExtensionEntry; use goose::config::ExtensionEntry;
use goose::config::{Config, ConfigError}; use goose::config::{Config, ConfigError};
use goose::custom_requests::SourceType;
use goose::model::ModelConfig; use goose::model::ModelConfig;
use goose::providers::base::{ProviderMetadata, ProviderType}; use goose::providers::base::{ProviderMetadata, ProviderType};
use goose::providers::canonical::maybe_get_canonical_model; use goose::providers::canonical::maybe_get_canonical_model;
@@ -427,9 +428,7 @@ pub async fn get_slash_commands(
} }
let working_dir = query.working_dir.map(std::path::PathBuf::from); let working_dir = query.working_dir.map(std::path::PathBuf::from);
for source in for source in goose::skills::list_installed_skills(working_dir.as_deref()) {
goose::agents::platform_extensions::skills::list_installed_skills(working_dir.as_deref())
{
commands.push(SlashCommand { commands.push(SlashCommand {
command: source.name, command: source.name,
help: source.description, help: source.description,
@@ -443,10 +442,9 @@ pub async fn get_slash_commands(
for source in for source in
goose::agents::platform_extensions::summon::discover_filesystem_sources(discover_dir) goose::agents::platform_extensions::summon::discover_filesystem_sources(discover_dir)
{ {
use goose::agents::platform_extensions::SourceKind;
if matches!( if matches!(
source.kind, source.source_type,
SourceKind::Agent | SourceKind::Recipe | SourceKind::Subrecipe SourceType::Agent | SourceType::Recipe | SourceType::Subrecipe
) && !source.content.is_empty() ) && !source.content.is_empty()
{ {
commands.push(SlashCommand { commands.push(SlashCommand {
+29 -44
View File
@@ -804,14 +804,18 @@
"content", "content",
"global" "global"
], ],
"description": "Create a new source (global or project-scoped).", "description": "Create a new source in an explicit target scope (global or project-scoped).",
"x-side": "agent", "x-side": "agent",
"x-method": "_goose/sources/create" "x-method": "_goose/sources/create"
}, },
"SourceType": { "SourceType": {
"type": "string", "type": "string",
"enum": [ "enum": [
"skill" "skill",
"builtinSkill",
"recipe",
"subrecipe",
"agent"
], ],
"description": "The type of source entity." "description": "The type of source entity."
}, },
@@ -845,11 +849,18 @@
}, },
"directory": { "directory": {
"type": "string", "type": "string",
"description": "Absolute path to the source's directory on disk." "description": "Absolute path to the source on disk. A directory for skills, a file for\nrecipes and agents."
}, },
"global": { "global": {
"type": "boolean", "type": "boolean",
"description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project." "description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project."
},
"supportingFiles": {
"type": "array",
"items": {
"type": "string"
},
"description": "Paths (absolute) of additional files that live alongside the source.\nOnly skills currently populate this; empty for other source types."
} }
}, },
"required": [ "required": [
@@ -860,7 +871,7 @@
"directory", "directory",
"global" "global"
], ],
"description": "A source — a user-editable entity backed by an on-disk directory. Sources\nmay be either `global` (shared across all projects) or project-specific." "description": "A source discovered by Goose and backed by an on-disk path. Sources may be\neither `global` (shared across all projects) or project-specific."
}, },
"ListSourcesRequest": { "ListSourcesRequest": {
"type": "object", "type": "object",
@@ -882,7 +893,7 @@
] ]
} }
}, },
"description": "List sources. If `type` is omitted, sources of all known types are returned.\nBoth global and project-scoped sources are included when `project_dir` is set.", "description": "List discovered sources.\n\nToday this endpoint only returns skills. If `type` is omitted, it defaults\nto listing skill sources. Both global and project-scoped skills are included\nwhen `project_dir` is set.",
"x-side": "agent", "x-side": "agent",
"x-method": "_goose/sources/list" "x-method": "_goose/sources/list"
}, },
@@ -908,6 +919,9 @@
"type": { "type": {
"$ref": "#/$defs/SourceType" "$ref": "#/$defs/SourceType"
}, },
"path": {
"type": "string"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@@ -916,25 +930,16 @@
}, },
"content": { "content": {
"type": "string" "type": "string"
},
"global": {
"type": "boolean"
},
"projectDir": {
"type": [
"string",
"null"
]
} }
}, },
"required": [ "required": [
"type", "type",
"path",
"name", "name",
"description", "description",
"content", "content"
"global"
], ],
"description": "Update an existing source's description and content.", "description": "Update an existing source's name, description, and content by absolute path.",
"x-side": "agent", "x-side": "agent",
"x-method": "_goose/sources/update" "x-method": "_goose/sources/update"
}, },
@@ -957,25 +962,15 @@
"type": { "type": {
"$ref": "#/$defs/SourceType" "$ref": "#/$defs/SourceType"
}, },
"name": { "path": {
"type": "string" "type": "string"
},
"global": {
"type": "boolean"
},
"projectDir": {
"type": [
"string",
"null"
]
} }
}, },
"required": [ "required": [
"type", "type",
"name", "path"
"global"
], ],
"description": "Delete a source and its on-disk directory.", "description": "Delete a source and its on-disk directory by absolute path.",
"x-side": "agent", "x-side": "agent",
"x-method": "_goose/sources/delete" "x-method": "_goose/sources/delete"
}, },
@@ -985,25 +980,15 @@
"type": { "type": {
"$ref": "#/$defs/SourceType" "$ref": "#/$defs/SourceType"
}, },
"name": { "path": {
"type": "string" "type": "string"
},
"global": {
"type": "boolean"
},
"projectDir": {
"type": [
"string",
"null"
]
} }
}, },
"required": [ "required": [
"type", "type",
"name", "path"
"global"
], ],
"description": "Export a source as a portable JSON payload.", "description": "Export a source at an absolute path as a portable JSON payload.",
"x-side": "agent", "x-side": "agent",
"x-method": "_goose/sources/export" "x-method": "_goose/sources/export"
}, },
@@ -1044,7 +1029,7 @@
"data", "data",
"global" "global"
], ],
"description": "Import a source from a JSON export payload produced by `_goose/sources/export`.\nThe imported source is written under the given scope; on name collisions a\n`-imported` suffix is appended.", "description": "Import a source from a JSON export payload produced by `_goose/sources/export`.\nThe imported source is written into the explicit target scope; on name\ncollisions a `-imported` suffix is appended.",
"x-side": "agent", "x-side": "agent",
"x-method": "_goose/sources/import" "x-method": "_goose/sources/import"
}, },
+3 -14
View File
@@ -3332,11 +3332,10 @@ impl GooseAcpAgent {
) -> Result<UpdateSourceResponse, sacp::Error> { ) -> Result<UpdateSourceResponse, sacp::Error> {
let source = crate::sources::update_source( let source = crate::sources::update_source(
req.source_type, req.source_type,
&req.path,
&req.name, &req.name,
&req.description, &req.description,
&req.content, &req.content,
req.global,
req.project_dir.as_deref(),
)?; )?;
Ok(UpdateSourceResponse { source }) Ok(UpdateSourceResponse { source })
} }
@@ -3346,12 +3345,7 @@ impl GooseAcpAgent {
&self, &self,
req: DeleteSourceRequest, req: DeleteSourceRequest,
) -> Result<EmptyResponse, sacp::Error> { ) -> Result<EmptyResponse, sacp::Error> {
crate::sources::delete_source( crate::sources::delete_source(req.source_type, &req.path)?;
req.source_type,
&req.name,
req.global,
req.project_dir.as_deref(),
)?;
Ok(EmptyResponse {}) Ok(EmptyResponse {})
} }
@@ -3360,12 +3354,7 @@ impl GooseAcpAgent {
&self, &self,
req: ExportSourceRequest, req: ExportSourceRequest,
) -> Result<ExportSourceResponse, sacp::Error> { ) -> Result<ExportSourceResponse, sacp::Error> {
let (json, filename) = crate::sources::export_source( let (json, filename) = crate::sources::export_source(req.source_type, &req.path)?;
req.source_type,
&req.name,
req.global,
req.project_dir.as_deref(),
)?;
Ok(ExportSourceResponse { json, filename }) Ok(ExportSourceResponse { json, filename })
} }
+4 -4
View File
@@ -140,8 +140,8 @@ impl Agent {
} }
async fn handle_skills_command(&self, session_id: &str) -> Result<Option<Message>> { async fn handle_skills_command(&self, session_id: &str) -> Result<Option<Message>> {
use super::platform_extensions::skills::list_installed_skills; use crate::skills::list_installed_skills;
use super::platform_extensions::SourceKind; use goose_sdk::custom_requests::SourceType;
let working_dir = self let working_dir = self
.config .config
@@ -153,7 +153,7 @@ impl Agent {
let sources = list_installed_skills(working_dir.as_deref()); let sources = list_installed_skills(working_dir.as_deref());
let skills: Vec<_> = sources let skills: Vec<_> = sources
.iter() .iter()
.filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill)) .filter(|s| matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill))
.collect(); .collect();
let mut output = String::new(); let mut output = String::new();
@@ -165,7 +165,7 @@ impl Agent {
} else { } else {
output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len())); output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len()));
for skill in &skills { for skill in &skills {
let kind_label = if skill.kind == SourceKind::BuiltinSkill { let kind_label = if skill.source_type == SourceType::BuiltinSkill {
" *(builtin)*" " *(builtin)*"
} else { } else {
"" ""
-1
View File
@@ -1,5 +1,4 @@
mod agent; mod agent;
pub(crate) mod builtin_skills;
pub mod container; pub mod container;
pub mod execute_commands; pub mod execute_commands;
pub mod extension; pub mod extension;
@@ -6,74 +6,16 @@ pub mod code_execution;
pub mod developer; pub mod developer;
pub mod ext_manager; pub mod ext_manager;
pub mod orchestrator; pub mod orchestrator;
pub mod skills;
pub mod summarize; pub mod summarize;
pub mod summon; pub mod summon;
pub mod todo; pub mod todo;
pub mod tom; pub mod tom;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use crate::agents::mcp_client::McpClientTrait; use crate::agents::mcp_client::McpClientTrait;
use crate::session::Session; use crate::session::Session;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct Source {
pub name: String,
pub kind: SourceKind,
pub description: String,
pub path: PathBuf,
pub content: String,
pub supporting_files: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SourceKind {
Subrecipe,
Recipe,
Skill,
Agent,
BuiltinSkill,
}
impl std::fmt::Display for SourceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SourceKind::Subrecipe => write!(f, "subrecipe"),
SourceKind::Recipe => write!(f, "recipe"),
SourceKind::Skill => write!(f, "skill"),
SourceKind::Agent => write!(f, "agent"),
SourceKind::BuiltinSkill => write!(f, "builtin skill"),
}
}
}
impl Source {
pub fn to_load_text(&self) -> String {
format!(
"## {} ({})\n\n{}\n\n### Content\n\n{}",
self.name, self.kind, self.description, self.content
)
}
}
pub fn parse_frontmatter<T: for<'de> Deserialize<'de>>(
content: &str,
) -> Result<Option<(T, String)>, 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)))
}
pub use ext_manager::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE; pub use ext_manager::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
@@ -248,15 +190,15 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
); );
map.insert( map.insert(
skills::EXTENSION_NAME, crate::skills::EXTENSION_NAME,
PlatformExtensionDef { PlatformExtensionDef {
name: skills::EXTENSION_NAME, name: crate::skills::EXTENSION_NAME,
display_name: "Skills", display_name: "Skills",
description: "Discover and provide skill instructions from filesystem and builtins", description: "Discover and provide skill instructions from filesystem and builtins",
default_enabled: true, default_enabled: true,
unprefixed_tools: true, unprefixed_tools: true,
hidden: false, hidden: false,
client_factory: |ctx| Box::new(skills::SkillsClient::new(ctx).unwrap()), client_factory: |ctx| Box::new(crate::skills::SkillsClient::new(ctx).unwrap()),
}, },
); );
@@ -1,4 +1,3 @@
use super::{parse_frontmatter, Source, SourceKind};
use crate::agents::extension::PlatformExtensionContext; use crate::agents::extension::PlatformExtensionContext;
use crate::agents::mcp_client::{Error, McpClientTrait}; use crate::agents::mcp_client::{Error, McpClientTrait};
use crate::agents::subagent_handler::{run_subagent_task, OnMessageCallback, SubagentRunParams}; use crate::agents::subagent_handler::{run_subagent_task, OnMessageCallback, SubagentRunParams};
@@ -13,8 +12,10 @@ use crate::recipe::local_recipes::load_local_recipe_file;
use crate::recipe::{Recipe, Settings, RECIPE_FILE_EXTENSIONS}; use crate::recipe::{Recipe, Settings, RECIPE_FILE_EXTENSIONS};
use crate::session::extension_data::EnabledExtensionsState; use crate::session::extension_data::EnabledExtensionsState;
use crate::session::SessionType; use crate::session::SessionType;
use crate::sources::parse_frontmatter;
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use goose_sdk::custom_requests::{SourceEntry, SourceType};
use rmcp::model::{ use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, Meta, CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, Meta,
ServerCapabilities, ServerNotification, Tool, ServerCapabilities, ServerNotification, Tool,
@@ -33,11 +34,11 @@ use tracing::{info, warn};
pub static EXTENSION_NAME: &str = "summon"; pub static EXTENSION_NAME: &str = "summon";
fn kind_plural(kind: SourceKind) -> &'static str { fn kind_plural(kind: SourceType) -> &'static str {
match kind { match kind {
SourceKind::Subrecipe => "Subrecipes", SourceType::Subrecipe => "Subrecipes",
SourceKind::Recipe => "Recipes", SourceType::Recipe => "Recipes",
SourceKind::Agent => "Agents", SourceType::Agent => "Agents",
_ => "Other", _ => "Other",
} }
} }
@@ -95,7 +96,7 @@ struct AgentMetadata {
model: Option<String>, model: Option<String>,
} }
fn parse_agent_content(content: &str, path: &Path) -> Option<Source> { fn parse_agent_content(content: &str, path: &Path) -> Option<SourceEntry> {
let (metadata, body): (AgentMetadata, String) = match parse_frontmatter(content) { let (metadata, body): (AgentMetadata, String) = match parse_frontmatter(content) {
Ok(Some(parsed)) => parsed, Ok(Some(parsed)) => parsed,
Ok(None) => return None, Ok(None) => return None,
@@ -119,20 +120,21 @@ fn parse_agent_content(content: &str, path: &Path) -> Option<Source> {
format!("Agent{}", model_info) format!("Agent{}", model_info)
}); });
Some(Source { Some(SourceEntry {
source_type: SourceType::Agent,
name: metadata.name, name: metadata.name,
kind: SourceKind::Agent,
description, description,
path: path.to_path_buf(),
content: body, content: body,
directory: path.to_string_lossy().into_owned(),
global: false,
supporting_files: Vec::new(), supporting_files: Vec::new(),
}) })
} }
fn scan_recipes_from_dir( fn scan_recipes_from_dir(
dir: &Path, dir: &Path,
kind: SourceKind, kind: SourceType,
sources: &mut Vec<Source>, sources: &mut Vec<SourceEntry>,
seen: &mut std::collections::HashSet<String>, seen: &mut std::collections::HashSet<String>,
) { ) {
let entries = match std::fs::read_dir(dir) { let entries = match std::fs::read_dir(dir) {
@@ -164,12 +166,13 @@ fn scan_recipes_from_dir(
match Recipe::from_file_path(&path) { match Recipe::from_file_path(&path) {
Ok(recipe) => { Ok(recipe) => {
seen.insert(name.clone()); seen.insert(name.clone());
sources.push(Source { sources.push(SourceEntry {
source_type: kind,
name, name,
kind,
description: recipe.description.clone(), description: recipe.description.clone(),
path: path.clone(),
content: recipe.instructions.clone().unwrap_or_default(), content: recipe.instructions.clone().unwrap_or_default(),
directory: path.to_string_lossy().into_owned(),
global: false,
supporting_files: Vec::new(), supporting_files: Vec::new(),
}); });
} }
@@ -182,7 +185,7 @@ fn scan_recipes_from_dir(
fn scan_agents_from_dir( fn scan_agents_from_dir(
dir: &Path, dir: &Path,
sources: &mut Vec<Source>, sources: &mut Vec<SourceEntry>,
seen: &mut std::collections::HashSet<String>, seen: &mut std::collections::HashSet<String>,
) { ) {
let entries = match std::fs::read_dir(dir) { let entries = match std::fs::read_dir(dir) {
@@ -218,8 +221,8 @@ fn scan_agents_from_dir(
} }
} }
pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> { pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<SourceEntry> {
let mut sources: Vec<Source> = Vec::new(); let mut sources: Vec<SourceEntry> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let home = dirs::home_dir(); let home = dirs::home_dir();
@@ -266,7 +269,7 @@ pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
.collect(); .collect();
for dir in local_recipe_dirs { for dir in local_recipe_dirs {
scan_recipes_from_dir(&dir, SourceKind::Recipe, &mut sources, &mut seen); scan_recipes_from_dir(&dir, SourceType::Recipe, &mut sources, &mut seen);
} }
for dir in local_agent_dirs { for dir in local_agent_dirs {
@@ -274,7 +277,7 @@ pub fn discover_filesystem_sources(working_dir: &Path) -> Vec<Source> {
} }
for dir in global_recipe_dirs { for dir in global_recipe_dirs {
scan_recipes_from_dir(&dir, SourceKind::Recipe, &mut sources, &mut seen); scan_recipes_from_dir(&dir, SourceType::Recipe, &mut sources, &mut seen);
} }
for dir in global_agent_dirs { for dir in global_agent_dirs {
@@ -315,7 +318,7 @@ fn is_session_id(s: &str) -> bool {
pub struct SummonClient { pub struct SummonClient {
info: InitializeResult, info: InitializeResult,
context: PlatformExtensionContext, context: PlatformExtensionContext,
source_cache: Mutex<Option<(Instant, PathBuf, Vec<Source>)>>, source_cache: Mutex<Option<(Instant, PathBuf, Vec<SourceEntry>)>>,
background_tasks: Mutex<HashMap<String, BackgroundTask>>, background_tasks: Mutex<HashMap<String, BackgroundTask>>,
completed_tasks: Mutex<HashMap<String, CompletedTask>>, completed_tasks: Mutex<HashMap<String, CompletedTask>>,
notification_subscribers: Arc<Mutex<Vec<mpsc::Sender<ServerNotification>>>>, notification_subscribers: Arc<Mutex<Vec<mpsc::Sender<ServerNotification>>>>,
@@ -477,11 +480,11 @@ impl SummonClient {
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) .unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
} }
async fn get_sources(&self, session_id: &str, working_dir: &Path) -> Vec<Source> { async fn get_sources(&self, session_id: &str, working_dir: &Path) -> Vec<SourceEntry> {
let fs_sources = self.get_filesystem_sources(working_dir).await; let fs_sources = self.get_filesystem_sources(working_dir).await;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut sources: Vec<Source> = Vec::new(); let mut sources: Vec<SourceEntry> = Vec::new();
self.add_subrecipes(session_id, &mut sources, &mut seen) self.add_subrecipes(session_id, &mut sources, &mut seen)
.await; .await;
@@ -493,11 +496,11 @@ impl SummonClient {
} }
} }
sources.sort_by(|a, b| (&a.kind, &a.name).cmp(&(&b.kind, &b.name))); sources.sort_by(|a, b| (&a.source_type, &a.name).cmp(&(&b.source_type, &b.name)));
sources sources
} }
async fn get_filesystem_sources(&self, working_dir: &Path) -> Vec<Source> { async fn get_filesystem_sources(&self, working_dir: &Path) -> Vec<SourceEntry> {
let mut cache = self.source_cache.lock().await; let mut cache = self.source_cache.lock().await;
if let Some((cached_at, cached_dir, sources)) = cache.as_ref() { if let Some((cached_at, cached_dir, sources)) = cache.as_ref() {
if cached_dir == working_dir && cached_at.elapsed() < Duration::from_secs(60) { if cached_dir == working_dir && cached_at.elapsed() < Duration::from_secs(60) {
@@ -514,11 +517,11 @@ impl SummonClient {
session_id: &str, session_id: &str,
name: &str, name: &str,
working_dir: &Path, working_dir: &Path,
) -> Result<Option<Source>, String> { ) -> Result<Option<SourceEntry>, String> {
let sources = self.get_sources(session_id, working_dir).await; let sources = self.get_sources(session_id, working_dir).await;
if let Some(mut source) = sources.iter().find(|s| s.name == name).cloned() { if let Some(mut source) = sources.iter().find(|s| s.name == name).cloned() {
if source.kind == SourceKind::Subrecipe && source.content.is_empty() { if source.source_type == SourceType::Subrecipe && source.content.is_empty() {
source.content = self.load_subrecipe_content(session_id, &source.name).await; source.content = self.load_subrecipe_content(session_id, &source.name).await;
} }
return Ok(Some(source)); return Ok(Some(source));
@@ -557,14 +560,14 @@ impl SummonClient {
} }
} }
fn discover_filesystem_sources(&self, working_dir: &Path) -> Vec<Source> { fn discover_filesystem_sources(&self, working_dir: &Path) -> Vec<SourceEntry> {
discover_filesystem_sources(working_dir) discover_filesystem_sources(working_dir)
} }
async fn add_subrecipes( async fn add_subrecipes(
&self, &self,
session_id: &str, session_id: &str,
sources: &mut Vec<Source>, sources: &mut Vec<SourceEntry>,
seen: &mut std::collections::HashSet<String>, seen: &mut std::collections::HashSet<String>,
) { ) {
let session = match self let session = match self
@@ -590,12 +593,13 @@ impl SummonClient {
let description = self.build_subrecipe_description(sr).await; let description = self.build_subrecipe_description(sr).await;
sources.push(Source { sources.push(SourceEntry {
source_type: SourceType::Subrecipe,
name: sr.name.clone(), name: sr.name.clone(),
kind: SourceKind::Subrecipe,
description, description,
path: PathBuf::from(&sr.path),
content: String::new(), content: String::new(),
directory: sr.path.clone(),
global: false,
supporting_files: Vec::new(), supporting_files: Vec::new(),
}); });
} }
@@ -841,8 +845,8 @@ impl SummonClient {
} }
} }
for kind in [SourceKind::Subrecipe, SourceKind::Recipe, SourceKind::Agent] { for kind in [SourceType::Subrecipe, SourceType::Recipe, SourceType::Agent] {
let kind_sources: Vec<_> = sources.iter().filter(|s| s.kind == kind).collect(); let kind_sources: Vec<_> = sources.iter().filter(|s| s.source_type == kind).collect();
if !kind_sources.is_empty() { if !kind_sources.is_empty() {
output.push_str(&format!("\n{}:\n", kind_plural(kind))); output.push_str(&format!("\n{}:\n", kind_plural(kind)));
for source in kind_sources { for source in kind_sources {
@@ -875,7 +879,7 @@ impl SummonClient {
let output = format!( let output = format!(
"# Loaded: {} ({})\n\n{}\n\n---\nThis knowledge is now available in your context.", "# Loaded: {} ({})\n\n{}\n\n---\nThis knowledge is now available in your context.",
source.name, source.kind, content source.name, source.source_type, content
); );
Ok(vec![Content::text(output)]) Ok(vec![Content::text(output)])
@@ -1080,16 +1084,16 @@ impl SummonClient {
.await? .await?
.ok_or_else(|| format!("Source '{}' not found", source_name))?; .ok_or_else(|| format!("Source '{}' not found", source_name))?;
let mut recipe = match source.kind { let mut recipe = match source.source_type {
SourceKind::Recipe | SourceKind::Subrecipe => { SourceType::Recipe | SourceType::Subrecipe => {
self.build_recipe_from_source(&source, params, session_id) self.build_recipe_from_source(&source, params, session_id)
.await? .await?
} }
SourceKind::Agent => self.build_recipe_from_agent(&source, params)?, SourceType::Agent => self.build_recipe_from_agent(&source, params)?,
_ => { _ => {
return Err(format!( return Err(format!(
"Source '{}' has kind '{}' which cannot be delegated from summon", "Source '{}' has kind '{}' which cannot be delegated from summon",
source_name, source.kind source_name, source.source_type
)) ))
} }
}; };
@@ -1108,7 +1112,7 @@ impl SummonClient {
async fn build_recipe_from_source( async fn build_recipe_from_source(
&self, &self,
source: &Source, source: &SourceEntry,
params: &DelegateParams, params: &DelegateParams,
session_id: &str, session_id: &str,
) -> Result<Recipe, String> { ) -> Result<Recipe, String> {
@@ -1119,7 +1123,7 @@ impl SummonClient {
.await .await
.map_err(|e| format!("Failed to get session: {}", e))?; .map_err(|e| format!("Failed to get session: {}", e))?;
if source.kind == SourceKind::Subrecipe { if source.source_type == SourceType::Subrecipe {
let sub_recipes = session.recipe.as_ref().and_then(|r| r.sub_recipes.as_ref()); let sub_recipes = session.recipe.as_ref().and_then(|r| r.sub_recipes.as_ref());
if let Some(sub_recipes) = sub_recipes { if let Some(sub_recipes) = sub_recipes {
@@ -1156,7 +1160,7 @@ impl SummonClient {
} }
} }
let recipe_file = load_local_recipe_file(source.path.to_str().unwrap_or("")) let recipe_file = load_local_recipe_file(&source.directory)
.map_err(|e| format!("Failed to load recipe '{}': {}", source.name, e))?; .map_err(|e| format!("Failed to load recipe '{}': {}", source.name, e))?;
let param_values: Vec<(String, String)> = params let param_values: Vec<(String, String)> = params
@@ -1186,13 +1190,13 @@ impl SummonClient {
fn build_recipe_from_agent( fn build_recipe_from_agent(
&self, &self,
source: &Source, source: &SourceEntry,
params: &DelegateParams, params: &DelegateParams,
) -> Result<Recipe, String> { ) -> Result<Recipe, String> {
let agent_content = if source.path.as_os_str().is_empty() { let agent_content = if source.directory.is_empty() {
return Err("Agent source has no path".to_string()); return Err("Agent source has no path".to_string());
} else { } else {
std::fs::read_to_string(&source.path) std::fs::read_to_string(&source.directory)
.map_err(|e| format!("Failed to read agent file: {}", e))? .map_err(|e| format!("Failed to read agent file: {}", e))?
}; };
@@ -1747,14 +1751,14 @@ You review code."#;
let recipe = sources let recipe = sources
.iter() .iter()
.find(|s| s.name == "deploy" && s.kind == SourceKind::Recipe) .find(|s| s.name == "deploy" && s.source_type == SourceType::Recipe)
.unwrap(); .unwrap();
assert_eq!(recipe.description, "Deploy to production"); assert_eq!(recipe.description, "Deploy to production");
assert_eq!(recipe.content, "Run deploy steps"); assert_eq!(recipe.content, "Run deploy steps");
let agent = sources let agent = sources
.iter() .iter()
.find(|s| s.name == "reviewer" && s.kind == SourceKind::Agent) .find(|s| s.name == "reviewer" && s.source_type == SourceType::Agent)
.unwrap(); .unwrap();
assert_eq!(agent.description, "Code reviewer"); assert_eq!(agent.description, "Code reviewer");
assert!(agent.content.contains("You review code")); assert!(agent.content.contains("You review code"));
+1
View File
@@ -38,6 +38,7 @@ pub mod scheduler_trait;
pub mod security; pub mod security;
pub mod session; pub mod session;
pub mod session_context; pub mod session_context;
pub mod skills;
pub mod slash_commands; pub mod slash_commands;
pub mod sources; pub mod sources;
pub mod subprocess; pub mod subprocess;
@@ -1,7 +1,6 @@
use include_dir::{include_dir, Dir}; use include_dir::{include_dir, Dir};
static BUILTIN_SKILLS_DIR: Dir = static BUILTIN_SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/skills/builtins");
include_dir!("$CARGO_MANIFEST_DIR/src/agents/builtin_skills/skills");
pub fn get_all() -> Vec<&'static str> { pub fn get_all() -> Vec<&'static str> {
BUILTIN_SKILLS_DIR BUILTIN_SKILLS_DIR
@@ -1,201 +1,19 @@
use super::{parse_frontmatter, Source, SourceKind}; use super::discover_skills;
use crate::agents::builtin_skills;
use crate::agents::extension::PlatformExtensionContext; use crate::agents::extension::PlatformExtensionContext;
use crate::agents::mcp_client::{Error, McpClientTrait}; use crate::agents::mcp_client::{Error, McpClientTrait};
use crate::agents::tool_execution::ToolCallContext; use crate::agents::ToolCallContext;
use crate::config::paths::Paths;
use async_trait::async_trait; use async_trait::async_trait;
use goose_sdk::custom_requests::{SourceEntry, SourceType};
use rmcp::model::{ use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult,
ServerCapabilities, ServerNotification, Tool, ServerCapabilities, ServerNotification, Tool,
}; };
use serde::Deserialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::warn;
pub static EXTENSION_NAME: &str = "skills"; pub static EXTENSION_NAME: &str = "skills";
#[derive(Debug, Deserialize)]
struct SkillMetadata {
name: String,
description: String,
}
fn parse_skill_content(content: &str, path: PathBuf) -> Option<Source> {
let (metadata, body): (SkillMetadata, String) = match parse_frontmatter(content) {
Ok(Some(parsed)) => parsed,
Ok(None) => return None,
Err(e) => {
warn!("Failed to parse skill frontmatter: {}", e);
return None;
}
};
if metadata.name.contains('/') {
warn!("Skill name '{}' contains '/', skipping", metadata.name);
return None;
}
Some(Source {
name: metadata.name,
kind: SourceKind::Skill,
description: metadata.description,
path,
content: body,
supporting_files: Vec::new(),
})
}
fn should_skip_dir(path: &Path) -> bool {
matches!(
path.file_name().and_then(|name| name.to_str()),
Some(".git") | Some(".hg") | Some(".svn")
)
}
fn walk_files_recursively<F, G>(
dir: &Path,
visited_dirs: &mut HashSet<PathBuf>,
should_descend: &mut G,
visit_file: &mut F,
) where
F: FnMut(&Path),
G: FnMut(&Path) -> bool,
{
let canonical_dir = match std::fs::canonicalize(dir) {
Ok(path) => path,
Err(_) => return,
};
if !visited_dirs.insert(canonical_dir) {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if should_descend(&path) {
walk_files_recursively(&path, visited_dirs, should_descend, visit_file);
}
} else if path.is_file() {
visit_file(&path);
}
}
}
fn scan_skills_from_dir(dir: &Path, seen: &mut HashSet<String>) -> Vec<Source> {
let mut skill_files = Vec::new();
let mut visited_dirs = HashSet::new();
walk_files_recursively(
dir,
&mut visited_dirs,
&mut |path| !should_skip_dir(path),
&mut |path| {
if path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") {
skill_files.push(path.to_path_buf());
}
},
);
let mut sources = Vec::new();
for skill_file in skill_files {
let Some(skill_dir) = skill_file.parent() else {
continue;
};
let content = match std::fs::read_to_string(&skill_file) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read skill file {}: {}", skill_file.display(), e);
continue;
}
};
if let Some(mut source) = parse_skill_content(&content, skill_dir.to_path_buf()) {
if !seen.contains(&source.name) {
// Find supporting files in the skill directory
let mut files = Vec::new();
let mut visited_support_dirs = HashSet::new();
walk_files_recursively(
skill_dir,
&mut visited_support_dirs,
&mut |path| !should_skip_dir(path) && !path.join("SKILL.md").is_file(),
&mut |path| {
if path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") {
files.push(path.to_path_buf());
}
},
);
source.supporting_files = files;
seen.insert(source.name.clone());
sources.push(source);
}
}
}
sources
}
fn discover_skills(working_dir: &Path) -> Vec<Source> {
let mut sources = Vec::new();
let mut seen = HashSet::new();
let home = dirs::home_dir();
let config = Paths::config_dir();
let local_dirs = vec![
working_dir.join(".goose/skills"),
working_dir.join(".claude/skills"),
working_dir.join(".agents/skills"),
];
let global_dirs: Vec<PathBuf> = [
home.as_ref().map(|h| h.join(".agents/skills")),
Some(config.join("skills")),
home.as_ref().map(|h| h.join(".claude/skills")),
home.as_ref().map(|h| h.join(".config/agents/skills")),
]
.into_iter()
.flatten()
.collect();
for dir in local_dirs {
sources.extend(scan_skills_from_dir(&dir, &mut seen));
}
for dir in global_dirs {
sources.extend(scan_skills_from_dir(&dir, &mut seen));
}
for content in builtin_skills::get_all() {
if let Some(source) = parse_skill_content(content, PathBuf::new()) {
if !seen.contains(&source.name) {
seen.insert(source.name.clone());
sources.push(Source {
kind: SourceKind::BuiltinSkill,
..source
});
}
}
}
sources
}
pub fn list_installed_skills(working_dir: Option<&Path>) -> Vec<Source> {
let dir = working_dir
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
discover_skills(&dir)
}
pub struct SkillsClient { pub struct SkillsClient {
info: InitializeResult, info: InitializeResult,
working_dir: PathBuf, working_dir: PathBuf,
@@ -211,12 +29,14 @@ impl SkillsClient {
let mut instructions = String::new(); let mut instructions = String::new();
if context.session.is_some() { if context.session.is_some() {
let sources = discover_skills(&working_dir); let sources = discover_skills(Some(&working_dir));
let mut skills: Vec<&Source> = sources let mut skills: Vec<&SourceEntry> = sources
.iter() .iter()
.filter(|s| s.kind == SourceKind::Skill || s.kind == SourceKind::BuiltinSkill) .filter(|s| {
s.source_type == SourceType::Skill || s.source_type == SourceType::BuiltinSkill
})
.collect(); .collect();
skills.sort_by(|a, b| (&a.name, &a.path).cmp(&(&b.name, &b.path))); skills.sort_by(|a, b| (&a.name, &a.directory).cmp(&(&b.name, &b.directory)));
if !skills.is_empty() { if !skills.is_empty() {
instructions.push_str( instructions.push_str(
@@ -300,24 +120,24 @@ impl McpClientTrait for SkillsClient {
)])); )]));
} }
let skills = discover_skills(&self.working_dir); let skills = discover_skills(Some(&self.working_dir));
// Direct skill match
if let Some(skill) = skills.iter().find(|s| s.name == skill_name) { if let Some(skill) = skills.iter().find(|s| s.name == skill_name) {
let mut output = format!( let mut output = format!(
"# Loaded Skill: {} ({})\n\n{}\n", "# Loaded Skill: {} ({})\n\n{}\n",
skill.name, skill.name,
skill.kind, skill.source_type,
skill.to_load_text() skill.to_load_text()
); );
if !skill.supporting_files.is_empty() { if !skill.supporting_files.is_empty() {
let skill_dir = Path::new(&skill.directory);
output.push_str(&format!( output.push_str(&format!(
"\n## Supporting Files\n\nSkill directory: {}\n\n", "\n## Supporting Files\n\nSkill directory: {}\n\n",
skill.path.display() skill.directory
)); ));
for file in &skill.supporting_files { for file in &skill.supporting_files {
if let Ok(relative) = file.strip_prefix(&skill.path) { if let Ok(relative) = Path::new(file).strip_prefix(skill_dir) {
let rel_str = relative.to_string_lossy().replace('\\', "/"); let rel_str = relative.to_string_lossy().replace('\\', "/");
output.push_str(&format!( output.push_str(&format!(
"- {} → load_skill(name: \"{}/{}\")\n", "- {} → load_skill(name: \"{}/{}\")\n",
@@ -331,27 +151,27 @@ impl McpClientTrait for SkillsClient {
return Ok(CallToolResult::success(vec![Content::text(output)])); return Ok(CallToolResult::success(vec![Content::text(output)]));
} }
// Supporting file match (skill_name contains '/')
if let Some((parent_skill_name, raw_relative_path)) = skill_name.split_once('/') { if let Some((parent_skill_name, raw_relative_path)) = skill_name.split_once('/') {
let relative_path = raw_relative_path.replace('\\', "/"); let relative_path = raw_relative_path.replace('\\', "/");
if let Some(skill) = skills.iter().find(|s| { if let Some(skill) = skills.iter().find(|s| {
s.name == parent_skill_name s.name == parent_skill_name
&& matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill) && matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill)
}) { }) {
let canonical_skill_dir = skill let skill_dir = PathBuf::from(&skill.directory);
.path let canonical_skill_dir = skill_dir
.canonicalize() .canonicalize()
.unwrap_or_else(|_| skill.path.clone()); .unwrap_or_else(|_| skill_dir.clone());
for file_path in &skill.supporting_files { for file_path in &skill.supporting_files {
let Ok(rel) = file_path.strip_prefix(&skill.path) else { let file_path_buf = Path::new(file_path);
let Ok(rel) = file_path_buf.strip_prefix(&skill_dir) else {
continue; continue;
}; };
if rel.to_string_lossy().replace('\\', "/") != relative_path { if rel.to_string_lossy().replace('\\', "/") != relative_path {
continue; continue;
} }
return Ok(match file_path.canonicalize() { return Ok(match file_path_buf.canonicalize() {
Ok(canonical) if canonical.starts_with(&canonical_skill_dir) => { Ok(canonical) if canonical.starts_with(&canonical_skill_dir) => {
match std::fs::read_to_string(&canonical) { match std::fs::read_to_string(&canonical) {
Ok(content) => { Ok(content) => {
@@ -381,7 +201,8 @@ impl McpClientTrait for SkillsClient {
.supporting_files .supporting_files
.iter() .iter()
.filter_map(|f| { .filter_map(|f| {
f.strip_prefix(&skill.path) Path::new(f)
.strip_prefix(&skill_dir)
.ok() .ok()
.map(|r| r.to_string_lossy().replace('\\', "/")) .map(|r| r.to_string_lossy().replace('\\', "/"))
}) })
@@ -403,7 +224,6 @@ impl McpClientTrait for SkillsClient {
} }
} }
// No match — suggest similar skills
let suggestions: Vec<&str> = skills let suggestions: Vec<&str> = skills
.iter() .iter()
.filter(|s| { .filter(|s| {
+386
View File
@@ -0,0 +1,386 @@
//! Everything specific to skills: filesystem discovery (`SKILL.md` walking +
//! built-ins) and the runtime MCP client (`client` submodule). User-facing
//! CRUD lives in `crate::sources`, which generalizes across source types.
mod builtin;
pub mod client;
pub use client::{SkillsClient, EXTENSION_NAME};
use crate::config::paths::Paths;
use crate::sources::parse_frontmatter;
use goose_sdk::custom_requests::{SourceEntry, SourceType};
use sacp::Error;
use serde::Deserialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::warn;
#[derive(Debug, Deserialize)]
pub struct SkillFrontmatter {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: String,
}
/// Canonical writable location for global user skills: `~/.agents/skills`.
pub fn global_skills_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".agents").join("skills"))
}
/// Canonical writable location for project-scoped skills:
/// `<project>/.goose/skills`.
pub fn project_skills_dir(project_dir: &Path) -> PathBuf {
project_dir.join(".goose").join("skills")
}
pub(crate) fn skills_dir_global_or_err() -> Result<PathBuf, Error> {
global_skills_dir()
.ok_or_else(|| Error::internal_error().data("Could not determine home directory"))
}
pub(crate) fn skills_dir_project_or_err(project_dir: &str) -> Result<PathBuf, Error> {
if project_dir.trim().is_empty() {
return Err(
Error::invalid_params().data("projectDir must not be empty when global is false")
);
}
Ok(project_skills_dir(Path::new(project_dir)))
}
pub(crate) fn skill_base_dir(global: bool, project_dir: Option<&str>) -> Result<PathBuf, Error> {
if global {
skills_dir_global_or_err()
} else {
let pd = project_dir.ok_or_else(|| {
Error::invalid_params().data("projectDir is required when global is false")
})?;
skills_dir_project_or_err(pd)
}
}
pub(crate) fn validate_skill_name(name: &str) -> Result<(), Error> {
if name.is_empty() {
return Err(Error::invalid_params().data("Skill name must not be empty"));
}
if name.len() > 64 {
return Err(Error::invalid_params().data(format!(
"Invalid skill name \"{}\". Names must be at most 64 characters.",
name
)));
}
if !name
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
{
return Err(Error::invalid_params().data(format!(
"Invalid skill name \"{}\". Names may only contain lowercase letters, digits, and hyphens.",
name
)));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(Error::invalid_params().data(format!(
"Invalid skill name \"{}\". Names must not start or end with a hyphen.",
name
)));
}
Ok(())
}
fn canonicalize_or_original(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn inferred_discoverable_skill_root(path: &Path) -> Option<PathBuf> {
let canonical_path = canonicalize_or_original(path);
let mut global_roots = Vec::new();
if let Some(global_root) = global_skills_dir() {
global_roots.push(global_root);
}
global_roots.push(Paths::config_dir().join("skills"));
if let Some(home) = dirs::home_dir() {
global_roots.push(home.join(".claude").join("skills"));
global_roots.push(home.join(".config").join("agents").join("skills"));
}
for root in global_roots {
let canonical_root = canonicalize_or_original(&root);
if canonical_path.starts_with(&canonical_root) {
return Some(canonical_root);
}
}
canonical_path.ancestors().find_map(|ancestor| {
let parent = ancestor.parent()?;
let is_project_skills_root = ancestor.file_name().and_then(|name| name.to_str())
== Some("skills")
&& matches!(
parent.file_name().and_then(|name| name.to_str()),
Some(".goose") | Some(".claude") | Some(".agents")
);
is_project_skills_root.then(|| ancestor.to_path_buf())
})
}
pub(crate) fn resolve_discoverable_skill_dir(path: &str) -> Result<PathBuf, Error> {
if path.is_empty() {
return Err(Error::invalid_params().data("Source path must not be empty"));
}
let canonical_dir = Path::new(path)
.canonicalize()
.map_err(|_| Error::invalid_params().data(format!("Source \"{}\" not found", path)))?;
if inferred_discoverable_skill_root(&canonical_dir).is_none()
|| !canonical_dir.is_dir()
|| !canonical_dir.join("SKILL.md").is_file()
{
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", path)));
}
Ok(canonical_dir)
}
pub(crate) fn resolve_skill_dir(path: &str) -> Result<PathBuf, Error> {
resolve_discoverable_skill_dir(path)
}
pub(crate) fn is_global_skill_dir(path: &Path) -> bool {
global_skills_dir().as_deref().is_some_and(|root| {
canonicalize_or_original(path).starts_with(canonicalize_or_original(root))
})
}
pub(crate) fn infer_skill_name(dir: &Path) -> String {
let md = dir.join("SKILL.md");
if let Ok(raw) = std::fs::read_to_string(&md) {
if let Ok(Some((meta, _))) = parse_frontmatter::<SkillFrontmatter>(&raw) {
if let Some(n) = meta.name.filter(|n| !n.is_empty()) {
return n;
}
}
}
dir.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed")
.to_string()
}
pub(crate) fn build_skill_md(name: &str, description: &str, content: &str) -> String {
let safe_desc = description.replace('\'', "''");
let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc);
if !content.is_empty() {
md.push('\n');
md.push_str(content);
md.push('\n');
}
md
}
pub(crate) fn parse_skill_frontmatter(raw: &str) -> (String, String) {
if !raw.trim_start().starts_with("---") {
return (String::new(), raw.to_string());
}
match parse_frontmatter::<SkillFrontmatter>(raw) {
Ok(Some((meta, body))) => (meta.description, body),
_ => (String::new(), raw.to_string()),
}
}
/// Every directory the agent reads skills from, paired with whether each is a
/// global (home-rooted) location. Order matches discovery precedence: project
/// dirs first, then global dirs.
pub fn all_skill_dirs(working_dir: Option<&Path>) -> Vec<(PathBuf, bool)> {
let mut dirs: Vec<(PathBuf, bool)> = Vec::new();
if let Some(wd) = working_dir {
dirs.push((wd.join(".goose").join("skills"), false));
dirs.push((wd.join(".claude").join("skills"), false));
dirs.push((wd.join(".agents").join("skills"), false));
}
let home = dirs::home_dir();
if let Some(h) = home.as_ref() {
dirs.push((h.join(".agents").join("skills"), true));
}
dirs.push((Paths::config_dir().join("skills"), true));
if let Some(h) = home.as_ref() {
dirs.push((h.join(".claude").join("skills"), true));
dirs.push((h.join(".config").join("agents").join("skills"), true));
}
dirs
}
fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option<SourceEntry> {
let (metadata, body): (SkillFrontmatter, String) = match parse_frontmatter(content) {
Ok(Some(parsed)) => parsed,
Ok(None) => return None,
Err(e) => {
warn!("Failed to parse skill frontmatter: {}", e);
return None;
}
};
let name = match metadata.name.filter(|n| !n.is_empty()) {
Some(n) => n,
None => {
warn!(
"Skill at '{}' is missing a required 'name' in frontmatter, skipping",
path.display()
);
return None;
}
};
if name.contains('/') {
warn!("Skill name '{}' contains '/', skipping", name);
return None;
}
Some(SourceEntry {
source_type: SourceType::Skill,
name,
description: metadata.description,
content: body,
directory: path.to_string_lossy().into_owned(),
global,
supporting_files: Vec::new(),
})
}
fn should_skip_dir(path: &Path) -> bool {
matches!(
path.file_name().and_then(|name| name.to_str()),
Some(".git") | Some(".hg") | Some(".svn")
)
}
fn walk_files_recursively<F, G>(
dir: &Path,
visited_dirs: &mut HashSet<PathBuf>,
should_descend: &mut G,
visit_file: &mut F,
) where
F: FnMut(&Path),
G: FnMut(&Path) -> bool,
{
let canonical_dir = match std::fs::canonicalize(dir) {
Ok(path) => path,
Err(_) => return,
};
if !visited_dirs.insert(canonical_dir) {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if should_descend(&path) {
walk_files_recursively(&path, visited_dirs, should_descend, visit_file);
}
} else if path.is_file() {
visit_file(&path);
}
}
}
fn scan_skills_from_dir(dir: &Path, global: bool, seen: &mut HashSet<String>) -> Vec<SourceEntry> {
let mut skill_files = Vec::new();
let mut visited_dirs = HashSet::new();
walk_files_recursively(
dir,
&mut visited_dirs,
&mut |path| !should_skip_dir(path),
&mut |path| {
if path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") {
skill_files.push(path.to_path_buf());
}
},
);
let mut sources = Vec::new();
for skill_file in skill_files {
let Some(skill_dir) = skill_file.parent() else {
continue;
};
let content = match std::fs::read_to_string(&skill_file) {
Ok(c) => c,
Err(e) => {
warn!("Failed to read skill file {}: {}", skill_file.display(), e);
continue;
}
};
if let Some(mut source) = parse_skill_content(&content, skill_dir, global) {
if !seen.contains(&source.name) {
let mut files = Vec::new();
let mut visited_support_dirs = HashSet::new();
walk_files_recursively(
skill_dir,
&mut visited_support_dirs,
&mut |path| !should_skip_dir(path) && !path.join("SKILL.md").is_file(),
&mut |path| {
if path.file_name().and_then(|n| n.to_str()) != Some("SKILL.md") {
files.push(path.to_string_lossy().into_owned());
}
},
);
source.supporting_files = files;
seen.insert(source.name.clone());
sources.push(source);
}
}
}
sources
}
/// Discover skills from all configured filesystem locations and built-ins.
/// Each returned entry has `global` set according to the directory it was
/// found in (or `true` for built-ins).
pub fn discover_skills(working_dir: Option<&Path>) -> Vec<SourceEntry> {
let mut sources: Vec<SourceEntry> = Vec::new();
let mut seen = HashSet::new();
for (dir, is_global) in all_skill_dirs(working_dir) {
for source in scan_skills_from_dir(&dir, is_global, &mut seen) {
sources.push(source);
}
}
for content in builtin::get_all() {
if let Some(source) = parse_skill_content(content, &PathBuf::new(), true) {
if !seen.contains(&source.name) {
seen.insert(source.name.clone());
sources.push(SourceEntry {
source_type: SourceType::BuiltinSkill,
..source
});
}
}
}
sources
}
pub fn list_installed_skills(working_dir: Option<&Path>) -> Vec<SourceEntry> {
let fallback;
let wd = match working_dir {
Some(p) => Some(p),
None => {
fallback = std::env::current_dir().ok();
fallback.as_deref()
}
};
discover_skills(wd)
}
+279 -228
View File
@@ -1,126 +1,47 @@
//! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom //! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom
//! methods. A source is a user-editable entity stored under a per-scope root
//! directory — `~/.agents/skills` for global sources and `<project>/.goose/skills`
//! for project-specific sources.
use crate::agents::platform_extensions::parse_frontmatter; 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 fs_err as fs;
use goose_sdk::custom_requests::{SourceEntry, SourceType}; use goose_sdk::custom_requests::{SourceEntry, SourceType};
use sacp::Error; use sacp::Error;
use serde::Deserialize; use serde::Deserialize;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
#[derive(Deserialize)] pub fn parse_frontmatter<T: for<'de> Deserialize<'de>>(
struct SkillFront { content: &str,
#[serde(default)] ) -> Result<Option<(T, String)>, serde_yaml::Error> {
description: String, 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)))
} }
const GLOBAL_SKILLS_SUBPATH: &[&str] = &[".agents", "skills"]; fn require_skill_type(source_type: SourceType) -> Result<(), Error> {
const PROJECT_SKILLS_SUBPATH: &[&str] = &[".goose", "skills"]; if source_type != SourceType::Skill {
fn home_dir() -> Result<PathBuf, Error> {
dirs::home_dir()
.ok_or_else(|| Error::internal_error().data("Could not determine home directory"))
}
fn skills_dir_global() -> Result<PathBuf, Error> {
let mut dir = home_dir()?;
for part in GLOBAL_SKILLS_SUBPATH {
dir = dir.join(part);
}
Ok(dir)
}
fn skills_dir_project(project_dir: &str) -> Result<PathBuf, Error> {
if project_dir.trim().is_empty() {
return Err(
Error::invalid_params().data("projectDir must not be empty when global is false")
);
}
let mut dir = PathBuf::from(project_dir);
for part in PROJECT_SKILLS_SUBPATH {
dir = dir.join(part);
}
Ok(dir)
}
fn source_base_dir(
source_type: SourceType,
global: bool,
project_dir: Option<&str>,
) -> Result<PathBuf, Error> {
match source_type {
SourceType::Skill => {
if global {
skills_dir_global()
} else {
let pd = project_dir.ok_or_else(|| {
Error::invalid_params().data("projectDir is required when global is false")
})?;
skills_dir_project(pd)
}
}
}
}
/// Kebab-case validation: `^[a-z0-9]+(-[a-z0-9]+)*$`. Prevents path traversal
/// via names like `../../.ssh/authorized_keys`.
fn validate_source_name(name: &str) -> Result<(), Error> {
if name.is_empty() {
return Err(Error::invalid_params().data("Source name must not be empty"));
}
let mut expect_alnum = true;
for ch in name.chars() {
if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
expect_alnum = false;
} else if ch == '-' && !expect_alnum {
expect_alnum = true;
} else {
return Err(Error::invalid_params().data(format!(
"Invalid source name \"{}\". Names must be kebab-case (lowercase letters, digits, and hyphens; \
must not start or end with a hyphen or contain consecutive hyphens).",
name
)));
}
}
if expect_alnum {
return Err(Error::invalid_params().data(format!( return Err(Error::invalid_params().data(format!(
"Invalid source name \"{}\". Names must not end with a hyphen.", "Source type '{}' is not supported. Only 'skill' is currently supported.",
name source_type
))); )));
} }
Ok(()) Ok(())
} }
fn build_skill_md(name: &str, description: &str, content: &str) -> String {
// YAML single-quoted strings escape a literal single quote by doubling it.
let safe_desc = description.replace('\'', "''");
let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc);
if !content.is_empty() {
md.push('\n');
md.push_str(content);
md.push('\n');
}
md
}
fn parse_skill_frontmatter(raw: &str) -> (String, String) {
if !raw.trim_start().starts_with("---") {
return (String::new(), raw.to_string());
}
match parse_frontmatter::<SkillFront>(raw) {
Ok(Some((meta, body))) => (meta.description, body),
_ => (String::new(), raw.to_string()),
}
}
fn source_entry( fn source_entry(
source_type: SourceType, source_type: SourceType,
name: &str, name: &str,
description: &str, description: &str,
content: &str, content: &str,
dir: &Path, dir: &std::path::Path,
global: bool, global: bool,
) -> SourceEntry { ) -> SourceEntry {
SourceEntry { SourceEntry {
@@ -130,6 +51,7 @@ fn source_entry(
content: content.to_string(), content: content.to_string(),
directory: dir.to_string_lossy().to_string(), directory: dir.to_string_lossy().to_string(),
global, global,
supporting_files: Vec::new(),
} }
} }
@@ -141,8 +63,9 @@ pub fn create_source(
global: bool, global: bool,
project_dir: Option<&str>, project_dir: Option<&str>,
) -> Result<SourceEntry, Error> { ) -> Result<SourceEntry, Error> {
validate_source_name(name)?; require_skill_type(source_type)?;
let dir = source_base_dir(source_type, global, project_dir)?.join(name); validate_skill_name(name)?;
let dir = skill_base_dir(global, project_dir)?.join(name);
if dir.exists() { if dir.exists() {
return Err( return Err(
@@ -170,20 +93,42 @@ pub fn create_source(
pub fn update_source( pub fn update_source(
source_type: SourceType, source_type: SourceType,
path: &str,
name: &str, name: &str,
description: &str, description: &str,
content: &str, content: &str,
global: bool,
project_dir: Option<&str>,
) -> Result<SourceEntry, Error> { ) -> Result<SourceEntry, Error> {
validate_source_name(name)?; require_skill_type(source_type)?;
let dir = source_base_dir(source_type, global, project_dir)?.join(name); validate_skill_name(name)?;
if !dir.exists() { let dir = resolve_discoverable_skill_dir(path)?;
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); 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 file_path = dir.join("SKILL.md"); 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); let md = build_skill_md(name, description, content);
fs::write(&file_path, md) fs::write(&file_path, md)
.map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?;
@@ -193,23 +138,14 @@ pub fn update_source(
name, name,
description, description,
content, content,
&dir, &target_dir,
global, is_global_skill_dir(&target_dir),
)) ))
} }
pub fn delete_source( pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> {
source_type: SourceType, require_skill_type(source_type)?;
name: &str, let dir = resolve_skill_dir(path)?;
global: bool,
project_dir: Option<&str>,
) -> Result<(), Error> {
validate_source_name(name)?;
let dir = source_base_dir(source_type, global, project_dir)?.join(name);
if !dir.exists() {
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name)));
}
fs::remove_dir_all(&dir) fs::remove_dir_all(&dir)
.map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?; .map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?;
Ok(()) Ok(())
@@ -219,97 +155,45 @@ pub fn list_sources(
source_type: Option<SourceType>, source_type: Option<SourceType>,
project_dir: Option<&str>, project_dir: Option<&str>,
) -> Result<Vec<SourceEntry>, Error> { ) -> Result<Vec<SourceEntry>, Error> {
let kinds: Vec<SourceType> = match source_type { if let Some(t) = source_type {
Some(k) => vec![k], require_skill_type(t)?;
None => vec![SourceType::Skill],
};
let mut sources = Vec::new();
for kind in kinds {
match kind {
SourceType::Skill => {
if let Some(pd) = project_dir {
if !pd.trim().is_empty() {
let dir = skills_dir_project(pd)?;
sources.extend(read_skill_dir(&dir, false)?);
}
}
let dir = skills_dir_global()?;
sources.extend(read_skill_dir(&dir, true)?);
}
}
} }
let working_dir = project_dir
.map(str::trim)
.filter(|p| !p.is_empty())
.map(PathBuf::from);
let mut sources: Vec<SourceEntry> = 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)); sources.sort_by(|a, b| a.name.cmp(&b.name));
Ok(sources) Ok(sources)
} }
fn read_skill_dir(dir: &Path, global: bool) -> Result<Vec<SourceEntry>, Error> { pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, String), Error> {
if !dir.exists() { require_skill_type(source_type)?;
return Ok(Vec::new()); let dir = resolve_discoverable_skill_dir(path)?;
}
let entries = fs::read_dir(dir)
.map_err(|e| Error::internal_error().data(format!("Failed to read skills dir: {e}")))?;
let mut out = Vec::new();
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() {
continue;
}
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let raw = fs::read_to_string(&skill_md).unwrap_or_default();
let (description, content) = parse_skill_frontmatter(&raw);
out.push(source_entry(
SourceType::Skill,
&name,
&description,
&content,
&path,
global,
));
}
Ok(out)
}
pub fn export_source(
source_type: SourceType,
name: &str,
global: bool,
project_dir: Option<&str>,
) -> Result<(String, String), Error> {
validate_source_name(name)?;
let dir = source_base_dir(source_type, global, project_dir)?.join(name);
if !dir.exists() {
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name)));
}
let md = dir.join("SKILL.md"); let md = dir.join("SKILL.md");
let raw = fs::read_to_string(&md) let raw = fs::read_to_string(&md)
.map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?; .map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?;
let (description, content) = parse_skill_frontmatter(&raw); let (description, content) = parse_skill_frontmatter(&raw);
let type_slug = match source_type { let name = infer_skill_name(&dir);
SourceType::Skill => "skill",
};
let export = serde_json::json!({ let export = serde_json::json!({
"version": 1, "version": 1,
"type": type_slug, "type": "skill",
"name": name, "name": name,
"description": description, "description": description,
"content": content, "content": content,
}); });
let json = serde_json::to_string_pretty(&export) let json = serde_json::to_string_pretty(&export)
.map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?; .map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?;
let filename = format!("{}.{}.json", name, type_slug); let filename = format!("{}.skill.json", name);
Ok((json, filename)) Ok((json, filename))
} }
@@ -331,15 +215,17 @@ pub fn import_sources(
); );
} }
// Default to `skill` to preserve compatibility with pre-sources skill exports. match value
let source_type = match value
.get("type") .get("type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("skill") .unwrap_or("skill")
{ {
"skill" => SourceType::Skill, "skill" => {}
other => { other => {
return Err(Error::invalid_params().data(format!("Unsupported source type: {}", other))); return Err(Error::invalid_params().data(format!(
"Source type '{}' is not supported. Only 'skill' is currently supported.",
other
)));
} }
}; };
@@ -361,7 +247,6 @@ pub fn import_sources(
return Err(Error::invalid_params().data("Source description must not be empty")); return Err(Error::invalid_params().data("Source description must not be empty"));
} }
// Accept both the new `content` key and the legacy skills `instructions` key.
let content = value let content = value
.get("content") .get("content")
.or_else(|| value.get("instructions")) .or_else(|| value.get("instructions"))
@@ -369,9 +254,9 @@ pub fn import_sources(
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
validate_source_name(&name)?; validate_skill_name(&name)?;
let base = source_base_dir(source_type, global, project_dir)?; let base = skill_base_dir(global, project_dir)?;
let mut final_name = name.clone(); let mut final_name = name.clone();
if base.join(&final_name).exists() { if base.join(&final_name).exists() {
final_name = format!("{}-imported", name); final_name = format!("{}-imported", name);
@@ -392,7 +277,7 @@ pub fn import_sources(
.map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?;
Ok(vec![source_entry( Ok(vec![source_entry(
source_type, SourceType::Skill,
&final_name, &final_name,
&description, &description,
&content, &content,
@@ -407,15 +292,17 @@ mod tests {
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
fn kebab_case_validation() { fn skill_name_validation() {
assert!(validate_source_name("my-skill").is_ok()); assert!(validate_skill_name("my-skill").is_ok());
assert!(validate_source_name("abc123").is_ok()); assert!(validate_skill_name("abc123").is_ok());
assert!(validate_source_name("").is_err()); assert!(validate_skill_name("double--hyphen").is_ok());
assert!(validate_source_name("-leading").is_err()); assert!(validate_skill_name("").is_err());
assert!(validate_source_name("trailing-").is_err()); assert!(validate_skill_name("-leading").is_err());
assert!(validate_source_name("double--hyphen").is_err()); assert!(validate_skill_name("trailing-").is_err());
assert!(validate_source_name("CAPS").is_err()); assert!(validate_skill_name("CAPS").is_err());
assert!(validate_source_name("../escape").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] #[test]
@@ -434,24 +321,25 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(created.name, "my-skill"); assert_eq!(created.name, "my-skill");
assert!(!created.global); assert!(!created.global);
assert!(PathBuf::from(&created.directory).join("SKILL.md").exists()); let dir = PathBuf::from(&created.directory);
assert!(dir.join("SKILL.md").exists());
let listed = list_sources(Some(SourceType::Skill), Some(project)).unwrap(); let listed = list_sources(Some(SourceType::Skill), Some(project)).unwrap();
assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global)); assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global));
let updated = update_source( let updated = update_source(
SourceType::Skill, SourceType::Skill,
created.directory.as_str(),
"my-skill", "my-skill",
"now does a different thing", "now does a different thing",
"step three", "step three",
false,
Some(project),
) )
.unwrap(); .unwrap();
assert_eq!(updated.description, "now does a different thing"); assert_eq!(updated.description, "now does a different thing");
assert_eq!(updated.name, "my-skill");
delete_source(SourceType::Skill, "my-skill", false, Some(project)).unwrap(); delete_source(SourceType::Skill, created.directory.as_str()).unwrap();
assert!(!PathBuf::from(&created.directory).exists()); assert!(!dir.exists());
} }
#[test] #[test]
@@ -489,13 +377,9 @@ mod tests {
) )
.unwrap(); .unwrap();
let (json, filename) = export_source( let portable_dir = project_a.join(".goose").join("skills").join("portable");
SourceType::Skill, let (json, filename) =
"portable", export_source(SourceType::Skill, portable_dir.to_str().unwrap()).unwrap();
false,
Some(project_a.to_str().unwrap()),
)
.unwrap();
assert_eq!(filename, "portable.skill.json"); assert_eq!(filename, "portable.skill.json");
let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap(); let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap();
@@ -505,6 +389,61 @@ mod tests {
assert_eq!(imported[0].content, "body goes here"); 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] #[test]
fn import_collision_appends_suffix() { fn import_collision_appends_suffix() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -523,4 +462,116 @@ mod tests {
let imported = import_sources(&payload, false, Some(project)).unwrap(); let imported = import_sources(&payload, false, Some(project)).unwrap();
assert_eq!(imported[0].name, "busy-imported"); 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"));
}
} }
+1 -1
View File
@@ -150,7 +150,7 @@ React UI ──► @aaif/goose-sdk (TS) ──► goose-acp (WebSocket, ACP
The skills → sources migration in [#8675](https://github.com/block/goose/pull/8675) is the clearest illustration of the rule. **It deleted 319 lines of Tauri-command code in `src-tauri/src/commands/skills.rs` and replaced them with ACP custom methods.** If you find yourself wanting to add an `invoke()` command that proxies to `goose`, that PR is what "doing it the other way" looks like. Copy this shape when adding new endpoints: The skills → sources migration in [#8675](https://github.com/block/goose/pull/8675) is the clearest illustration of the rule. **It deleted 319 lines of Tauri-command code in `src-tauri/src/commands/skills.rs` and replaced them with ACP custom methods.** If you find yourself wanting to add an `invoke()` command that proxies to `goose`, that PR is what "doing it the other way" looks like. Copy this shape when adding new endpoints:
1. **Define the request/response in `crates/goose-sdk/src/custom_requests.rs`.** Use the `JsonRpcRequest` / `JsonRpcResponse` derives and the `#[request(method = "_goose/<area>/<action>", response = ...)]` attribute. Sources uses namespaced methods like `_goose/sources/create`, `_goose/sources/list`, `_goose/sources/update`, `_goose/sources/delete`, `_goose/sources/export`, `_goose/sources/import` with paired request/response structs (`CreateSourceRequest` / `CreateSourceResponse`, etc.). 1. **Define the request/response in `crates/goose-sdk/src/custom_requests.rs`.** Use the `JsonRpcRequest` / `JsonRpcResponse` derives and the `#[request(method = "_goose/<area>/<action>", response = ...)]` attribute. Sources uses namespaced methods like `_goose/sources/create`, `_goose/sources/list`, `_goose/sources/update`, `_goose/sources/delete`, `_goose/sources/export`, `_goose/sources/import` with paired request/response structs (`CreateSourceRequest` / `CreateSourceResponse`, etc.). Keep the docs on those structs aligned with the implementation: today `_goose/sources/list` is still skill-only; create/import take an explicit target scope (`global`, plus `projectDir` for project sources), while update/delete/export operate on an existing skill by absolute `path`.
2. **Implement the handler in `crates/goose-acp/src/server.rs`** with `#[custom_method(YourRequest)]`. Keep it thin: unpack the request, call into the `goose` crate, wrap the result. The sources handlers are ~5 lines each — e.g. `on_list_sources` just calls `goose::sources::list_sources(...)` and returns the typed response. Errors map to `sacp::Error::invalid_params()` / `internal_error()`. 2. **Implement the handler in `crates/goose-acp/src/server.rs`** with `#[custom_method(YourRequest)]`. Keep it thin: unpack the request, call into the `goose` crate, wrap the result. The sources handlers are ~5 lines each — e.g. `on_list_sources` just calls `goose::sources::list_sources(...)` and returns the typed response. Errors map to `sacp::Error::invalid_params()` / `internal_error()`.
3. **Put the real logic in the `goose` crate.** Sources lives in `crates/goose/src/sources.rs` — filesystem CRUD, frontmatter parsing, scope resolution, all of it. `goose-acp` knows nothing about where skills are stored on disk; it just forwards typed arguments. This separation is the point. 3. **Put the real logic in the `goose` crate.** Sources lives in `crates/goose/src/sources.rs` — filesystem CRUD, frontmatter parsing, scope resolution, all of it. `goose-acp` knows nothing about where skills are stored on disk; it just forwards typed arguments. This separation is the point.
4. **Regenerate the SDK.** The TS methods on `GooseClient` are generated into `ui/sdk/src/generated/`. Do not hand-edit generated files. 4. **Regenerate the SDK.** The TS methods on `GooseClient` are generated into `ui/sdk/src/generated/`. Do not hand-edit generated files.
+46 -32
View File
@@ -1,28 +1,36 @@
import type { SourceEntry } from "@aaif/goose-sdk";
import { getClient } from "@/shared/api/acpConnection"; import { getClient } from "@/shared/api/acpConnection";
const SKILL_SOURCE_TYPE = "skill" as const;
export interface SkillInfo { export interface SkillInfo {
name: string; name: string;
description: string; description: string;
instructions: string; instructions: string;
path: string; path: string;
fileLocation: string;
} }
// Shape returned by _goose/sources/*. Narrowed to skill-type sources here. type SkillSourceEntry = SourceEntry & { type: typeof SKILL_SOURCE_TYPE };
interface SourceEntry {
type: "skill"; function isSkillSource(source: SourceEntry): source is SkillSourceEntry {
name: string; return source.type === SKILL_SOURCE_TYPE;
description: string;
content: string;
directory: string;
global: boolean;
} }
function toSkillInfo(source: SourceEntry): SkillInfo { function getSkillFileLocation(directory: string): string {
const separator = directory.includes("\\") ? "\\" : "/";
return directory.endsWith(separator)
? `${directory}SKILL.md`
: `${directory}${separator}SKILL.md`;
}
function toSkillInfo(source: SkillSourceEntry): SkillInfo {
return { return {
name: source.name, name: source.name,
description: source.description, description: source.description,
instructions: source.content, instructions: source.content,
path: source.directory, path: source.directory,
fileLocation: getSkillFileLocation(source.directory),
}; };
} }
@@ -32,8 +40,8 @@ export async function createSkill(
instructions: string, instructions: string,
): Promise<void> { ): Promise<void> {
const client = await getClient(); const client = await getClient();
await client.extMethod("_goose/sources/create", { await client.goose.GooseSourcesCreate({
type: "skill", type: SKILL_SOURCE_TYPE,
name, name,
description, description,
content: instructions, content: instructions,
@@ -43,46 +51,51 @@ export async function createSkill(
export async function listSkills(): Promise<SkillInfo[]> { export async function listSkills(): Promise<SkillInfo[]> {
const client = await getClient(); const client = await getClient();
const raw = await client.extMethod("_goose/sources/list", { type: "skill" }); const response = await client.goose.GooseSourcesList({
const sources = (raw.sources ?? []) as SourceEntry[]; type: SKILL_SOURCE_TYPE,
return sources.map(toSkillInfo); });
return response.sources.filter(isSkillSource).map(toSkillInfo);
} }
export async function deleteSkill(name: string): Promise<void> { export async function deleteSkill(path: string): Promise<void> {
const client = await getClient(); const client = await getClient();
await client.extMethod("_goose/sources/delete", { await client.goose.GooseSourcesDelete({
type: "skill", type: SKILL_SOURCE_TYPE,
name, path,
global: true,
}); });
} }
export async function updateSkill( export async function updateSkill(
path: string,
name: string, name: string,
description: string, description: string,
instructions: string, instructions: string,
): Promise<SkillInfo> { ): Promise<SkillInfo> {
const client = await getClient(); const client = await getClient();
const raw = await client.extMethod("_goose/sources/update", { const response = await client.goose.GooseSourcesUpdate({
type: "skill", type: SKILL_SOURCE_TYPE,
path,
name, name,
description, description,
content: instructions, content: instructions,
global: true,
}); });
return toSkillInfo(raw.source as SourceEntry);
if (!isSkillSource(response.source)) {
throw new Error(`Unexpected source type returned: ${response.source.type}`);
}
return toSkillInfo(response.source);
} }
export async function exportSkill( export async function exportSkill(
name: string, path: string,
): Promise<{ json: string; filename: string }> { ): Promise<{ json: string; filename: string }> {
const client = await getClient(); const client = await getClient();
const raw = await client.extMethod("_goose/sources/export", { const response = await client.goose.GooseSourcesExport({
type: "skill", type: SKILL_SOURCE_TYPE,
name, path,
global: true,
}); });
return { json: raw.json as string, filename: raw.filename as string }; return { json: response.json, filename: response.filename };
} }
export async function importSkills( export async function importSkills(
@@ -92,12 +105,13 @@ export async function importSkills(
if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) { if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) {
throw new Error("File must have a .skill.json or .json extension"); throw new Error("File must have a .skill.json or .json extension");
} }
const data = new TextDecoder().decode(new Uint8Array(fileBytes)); const data = new TextDecoder().decode(new Uint8Array(fileBytes));
const client = await getClient(); const client = await getClient();
const raw = await client.extMethod("_goose/sources/import", { const response = await client.goose.GooseSourcesImport({
data, data,
global: true, global: true,
}); });
const sources = (raw.sources ?? []) as SourceEntry[];
return sources.map(toSkillInfo); return response.sources.filter(isSkillSource).map(toSkillInfo);
} }
@@ -1,6 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
@@ -14,13 +13,56 @@ import {
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { createSkill, updateSkill } from "../api/skills"; import { createSkill, updateSkill } from "../api/skills";
const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; const MAX_SKILL_NAME_LENGTH = 64;
function isValidSkillName(name: string): boolean {
return (
name.length > 0 &&
name.length <= MAX_SKILL_NAME_LENGTH &&
!name.startsWith("-") &&
!name.endsWith("-") &&
[...name].every(
(char) =>
(char >= "a" && char <= "z") ||
(char >= "0" && char <= "9") ||
char === "-",
)
);
}
function formatSkillName(raw: string): string {
return raw
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/^-/, "")
.slice(0, MAX_SKILL_NAME_LENGTH);
}
function getRenamedSkillFileLocation(
fileLocation: string,
name: string,
): string {
const separator = fileLocation.includes("\\") ? "\\" : "/";
const parts = fileLocation.split(separator);
if (parts.length >= 2) {
parts[parts.length - 2] = name;
}
return parts.join(separator);
}
interface CreateSkillDialogProps { interface CreateSkillDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onCreated?: () => void; onCreated?: () => void;
editingSkill?: { name: string; description: string; instructions: string }; editingSkill?: {
name: string;
description: string;
instructions: string;
path: string;
fileLocation: string;
};
} }
export function CreateSkillDialog({ export function CreateSkillDialog({
@@ -53,17 +95,11 @@ export function CreateSkillDialog({
} }
}, [isOpen, editingSkill]); }, [isOpen, editingSkill]);
const nameValid = name.length > 0 && KEBAB_CASE_REGEX.test(name); const nameValid = isValidSkillName(name);
const canSave = nameValid && description.trim().length > 0 && !saving; const canSave = nameValid && description.trim().length > 0 && !saving;
const handleNameChange = (raw: string) => { const handleNameChange = (raw: string) => {
if (isEditing) return; // name is read-only in edit mode setName(formatSkillName(raw));
const formatted = raw
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-/, "");
setName(formatted);
setError(null); setError(null);
}; };
@@ -82,7 +118,12 @@ export function CreateSkillDialog({
setError(null); setError(null);
try { try {
if (isEditing) { if (isEditing) {
await updateSkill(name, description.trim(), instructions); await updateSkill(
editingSkill.path,
name,
description.trim(),
instructions,
);
} else { } else {
await createSkill(name, description.trim(), instructions); await createSkill(name, description.trim(), instructions);
} }
@@ -121,8 +162,6 @@ export function CreateSkillDialog({
value={name} value={name}
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("dialog.namePlaceholder")} placeholder={t("dialog.namePlaceholder")}
readOnly={isEditing}
className={cn(isEditing && "opacity-60 cursor-not-allowed")}
/> />
{name.length > 0 && !nameValid && ( {name.length > 0 && !nameValid && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
@@ -147,6 +186,13 @@ export function CreateSkillDialog({
/> />
</div> </div>
{isEditing && editingSkill && (
<p className="-mt-2 break-all text-[11px] text-muted-foreground">
{t("dialog.pathOnDisk")}:{" "}
{getRenamedSkillFileLocation(editingSkill.fileLocation, name)}
</p>
)}
{/* Instructions */} {/* Instructions */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground"> <Label className="text-xs font-medium text-muted-foreground">
+23 -16
View File
@@ -98,7 +98,14 @@ export function SkillsView() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState< const [editingSkill, setEditingSkill] = useState<
{ name: string; description: string; instructions: string } | undefined | {
name: string;
description: string;
instructions: string;
path: string;
fileLocation: string;
}
| undefined
>(undefined); >(undefined);
const [skills, setSkills] = useState<SkillInfo[]>([]); const [skills, setSkills] = useState<SkillInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -107,11 +114,11 @@ export function SkillsView() {
const importInputRef = useRef<HTMLInputElement>(null); const importInputRef = useRef<HTMLInputElement>(null);
const loadSkills = useCallback(async () => { const loadSkills = useCallback(async () => {
setLoading(true);
try { try {
const result = await listSkills(); const result = await listSkills();
setSkills(result); setSkills(result);
} catch { } catch {
// skills directory may not exist yet
setSkills([]); setSkills([]);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -129,7 +136,7 @@ export function SkillsView() {
const handleConfirmDeleteSkill = async () => { const handleConfirmDeleteSkill = async () => {
if (!deletingSkill) return; if (!deletingSkill) return;
try { try {
await deleteSkill(deletingSkill.name); await deleteSkill(deletingSkill.path);
await loadSkills(); await loadSkills();
} catch { } catch {
// best-effort // best-effort
@@ -142,6 +149,8 @@ export function SkillsView() {
name: skill.name, name: skill.name,
description: skill.description, description: skill.description,
instructions: skill.instructions, instructions: skill.instructions,
path: skill.path,
fileLocation: skill.fileLocation,
}); });
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -164,7 +173,7 @@ export function SkillsView() {
const handleExport = async (skill: SkillInfo) => { const handleExport = async (skill: SkillInfo) => {
try { try {
const result = await exportSkill(skill.name); const result = await exportSkill(skill.path);
const blob = new Blob([result.json], { type: "application/json" }); const blob = new Blob([result.json], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
@@ -195,7 +204,6 @@ export function SkillsView() {
console.error("Failed to import skill:", err); console.error("Failed to import skill:", err);
} }
// Reset the input so the same file can be re-selected
if (importInputRef.current) { if (importInputRef.current) {
importInputRef.current.value = ""; importInputRef.current.value = "";
} }
@@ -242,7 +250,6 @@ export function SkillsView() {
<div className="flex flex-1 flex-col h-full min-h-0"> <div className="flex flex-1 flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
<div className="max-w-5xl mx-auto w-full px-6 py-8 space-y-5 page-transition"> <div className="max-w-5xl mx-auto w-full px-6 py-8 space-y-5 page-transition">
{/* Header */}
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 className="text-lg font-semibold font-display tracking-tight"> <h1 className="text-lg font-semibold font-display tracking-tight">
@@ -281,14 +288,18 @@ export function SkillsView() {
</div> </div>
</div> </div>
{/* Search */}
<SearchBar <SearchBar
value={search} value={search}
onChange={setSearch} onChange={setSearch}
placeholder={t("view.searchPlaceholder")} placeholder={t("view.searchPlaceholder")}
/> />
{/* Skills list */} {loading && (
<div className="py-8 text-sm text-muted-foreground" role="status">
{t("common:labels.loading")}
</div>
)}
{!loading && filtered.length > 0 && ( {!loading && filtered.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{filtered.map((skill) => ( {filtered.map((skill) => (
@@ -296,8 +307,10 @@ export function SkillsView() {
key={skill.name} key={skill.name}
className="flex items-start justify-between gap-3 rounded-lg border border-border px-4 py-3" className="flex items-start justify-between gap-3 rounded-lg border border-border px-4 py-3"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">{skill.name}</p> <div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium">{skill.name}</p>
</div>
{skill.description && ( {skill.description && (
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{skill.description} {skill.description}
@@ -314,7 +327,6 @@ export function SkillsView() {
</div> </div>
))} ))}
{/* New Skill card */}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -334,7 +346,6 @@ export function SkillsView() {
</div> </div>
)} )}
{/* Empty state */}
{!loading && filtered.length === 0 && ( {!loading && filtered.length === 0 && (
<div <div
{...dropHandlers} {...dropHandlers}
@@ -373,7 +384,6 @@ export function SkillsView() {
</div> </div>
</div> </div>
{/* Hidden file input for drag-and-drop import */}
<input <input
ref={dropFileInputRef} ref={dropFileInputRef}
type="file" type="file"
@@ -382,7 +392,6 @@ export function SkillsView() {
onChange={handleDropFileChange} onChange={handleDropFileChange}
/> />
{/* Create / Edit dialog */}
<CreateSkillDialog <CreateSkillDialog
isOpen={dialogOpen} isOpen={dialogOpen}
onClose={handleDialogClose} onClose={handleDialogClose}
@@ -390,7 +399,6 @@ export function SkillsView() {
editingSkill={editingSkill} editingSkill={editingSkill}
/> />
{/* Delete confirmation dialog */}
<AlertDialog <AlertDialog
open={!!deletingSkill} open={!!deletingSkill}
onOpenChange={(open) => !open && setDeletingSkill(null)} onOpenChange={(open) => !open && setDeletingSkill(null)}
@@ -416,7 +424,6 @@ export function SkillsView() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* Export notification toast */}
{notification && ( {notification && (
<div className="fixed bottom-4 right-4 z-50 rounded-lg border border-border bg-background px-4 py-3 shadow-popover text-sm animate-in fade-in slide-in-from-bottom-2"> <div className="fixed bottom-4 right-4 z-50 rounded-lg border border-border bg-background px-4 py-3 shadow-popover text-sm animate-in fade-in slide-in-from-bottom-2">
{notification} {notification}
@@ -10,6 +10,7 @@ vi.mock("../../api/skills", () => ({
description: "test", description: "test",
instructions: "", instructions: "",
path: "", path: "",
fileLocation: "/mock/.agents/skills/test/SKILL.md",
}), }),
})); }));
@@ -53,6 +54,8 @@ describe("CreateSkillDialog", () => {
name: "my-skill", name: "my-skill",
description: "desc", description: "desc",
instructions: "instr", instructions: "instr",
path: "/mock/.agents/skills/my-skill",
fileLocation: "/mock/.agents/skills/my-skill/SKILL.md",
}} }}
/>, />,
); );
@@ -63,14 +66,24 @@ describe("CreateSkillDialog", () => {
// ── Name validation ──────────────────────────────────────────────── // ── Name validation ────────────────────────────────────────────────
describe("name validation", () => { describe("name validation", () => {
it("allows valid kebab-case names", async () => { it("allows consecutive hyphens to match backend validation", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />); render(<CreateSkillDialog {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name"); const nameInput = screen.getByPlaceholderText("my-skill-name");
const descriptionInput = screen.getByPlaceholderText(
"What it does and when to use it...",
);
await user.type(nameInput, "my-skill"); await user.type(nameInput, "double--hyphen");
expect(nameInput).toHaveValue("my-skill"); await user.type(descriptionInput, "A valid description");
expect(screen.queryByText(/must be kebab-case/i)).not.toBeInTheDocument();
expect(nameInput).toHaveValue("double--hyphen");
expect(
screen.queryByText(/cannot start or end with a hyphen/i),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /create skill/i }),
).toBeEnabled();
}); });
it("auto-formats input (uppercase to lowercase, spaces to hyphens)", async () => { it("auto-formats input (uppercase to lowercase, spaces to hyphens)", async () => {
@@ -82,28 +95,30 @@ describe("CreateSkillDialog", () => {
expect(nameInput).toHaveValue("my-skill"); expect(nameInput).toHaveValue("my-skill");
}); });
it("allows typing hyphens", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.type(nameInput, "code-review");
expect(nameInput).toHaveValue("code-review");
});
it("shows validation error for invalid name with trailing hyphen", async () => { it("shows validation error for invalid name with trailing hyphen", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />); render(<CreateSkillDialog {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name"); const nameInput = screen.getByPlaceholderText("my-skill-name");
// Type a single hyphen — the formatter strips leading hyphens,
// but we can produce an invalid state by clearing and typing a
// non-kebab string. Actually the formatter is aggressive, so let's
// just check that when name is non-empty but invalid, the error shows.
// We type "a-" which gives "a-" — valid prefix but trailing hyphen fails regex.
await user.type(nameInput, "a-"); await user.type(nameInput, "a-");
expect(nameInput).toHaveValue("a-"); expect(nameInput).toHaveValue("a-");
expect(screen.getByText(/must be kebab-case/i)).toBeInTheDocument(); expect(
screen.getByText(/cannot start or end with a hyphen/i),
).toBeInTheDocument();
});
it("truncates names at 64 characters", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
const longName = "a".repeat(65);
await user.type(nameInput, longName);
expect(nameInput).toHaveValue("a".repeat(64));
expect(
screen.queryByText(/cannot start or end with a hyphen/i),
).not.toBeInTheDocument();
}); });
it("save button is disabled when name is empty", () => { it("save button is disabled when name is empty", () => {
@@ -120,6 +135,8 @@ describe("CreateSkillDialog", () => {
name: "code-review", name: "code-review",
description: "Reviews code", description: "Reviews code",
instructions: "Review the code carefully", instructions: "Review the code carefully",
path: "/mock/.agents/skills/code-review",
fileLocation: "/mock/.agents/skills/code-review/SKILL.md",
}; };
it("pre-fills fields with existing skill data", () => { it("pre-fills fields with existing skill data", () => {
@@ -139,12 +156,46 @@ describe("CreateSkillDialog", () => {
).toHaveValue("Review the code carefully"); ).toHaveValue("Review the code carefully");
}); });
it("name field is read-only in edit mode", () => { it("name field is editable in edit mode", async () => {
const user = userEvent.setup();
render( render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />, <CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
); );
const nameInput = screen.getByPlaceholderText("my-skill-name"); const nameInput = screen.getByPlaceholderText("my-skill-name");
expect(nameInput).toHaveAttribute("readOnly");
await user.clear(nameInput);
await user.type(nameInput, "renamed-skill");
expect(nameInput).toHaveValue("renamed-skill");
});
it("shows the skill path on disk as minimal helper text in edit mode", () => {
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
expect(
screen.getByText(
"Path on disk: /mock/.agents/skills/code-review/SKILL.md",
),
).toBeInTheDocument();
});
it("updates the path helper text when the name changes in edit mode", async () => {
const user = userEvent.setup();
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.clear(nameInput);
await user.type(nameInput, "renamed-skill");
expect(
screen.getByText(
"Path on disk: /mock/.agents/skills/renamed-skill/SKILL.md",
),
).toBeInTheDocument();
}); });
it('save button text is "Save Changes" in edit mode', () => { it('save button text is "Save Changes" in edit mode', () => {
@@ -155,6 +206,28 @@ describe("CreateSkillDialog", () => {
screen.getByRole("button", { name: /save changes/i }), screen.getByRole("button", { name: /save changes/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("allows editing skills whose names contain consecutive hyphens", () => {
render(
<CreateSkillDialog
{...defaultProps}
editingSkill={{
name: "double--hyphen",
description: "Existing description",
instructions: "Existing instructions",
path: "/mock/.agents/skills/double--hyphen",
fileLocation: "/mock/.agents/skills/double--hyphen/SKILL.md",
}}
/>,
);
expect(
screen.getByRole("button", { name: /save changes/i }),
).toBeEnabled();
expect(
screen.queryByText(/cannot start or end with a hyphen/i),
).not.toBeInTheDocument();
});
}); });
// ── Form submission ──────────────────────────────────────────────── // ── Form submission ────────────────────────────────────────────────
@@ -193,6 +266,8 @@ describe("CreateSkillDialog", () => {
name: "code-review", name: "code-review",
description: "Reviews code", description: "Reviews code",
instructions: "Review carefully", instructions: "Review carefully",
path: "/mock/.agents/skills/code-review",
fileLocation: "/mock/.agents/skills/code-review/SKILL.md",
}} }}
/>, />,
); );
@@ -207,12 +282,42 @@ describe("CreateSkillDialog", () => {
await user.click(screen.getByRole("button", { name: /save changes/i })); await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(updateSkill).toHaveBeenCalledWith( expect(updateSkill).toHaveBeenCalledWith(
"/mock/.agents/skills/code-review",
"code-review", "code-review",
"Updated description", "Updated description",
"Review carefully", "Review carefully",
); );
}); });
it("calls updateSkill API with the renamed skill name in edit mode", async () => {
const user = userEvent.setup();
render(
<CreateSkillDialog
{...defaultProps}
editingSkill={{
name: "code-review",
description: "Reviews code",
instructions: "Review carefully",
path: "/mock/.agents/skills/code-review",
fileLocation: "/mock/.agents/skills/code-review/SKILL.md",
}}
/>,
);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.clear(nameInput);
await user.type(nameInput, "renamed-skill");
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(updateSkill).toHaveBeenCalledWith(
"/mock/.agents/skills/code-review",
"renamed-skill",
"Reviews code",
"Review carefully",
);
});
it("calls onCreated callback after successful save", async () => { it("calls onCreated callback after successful save", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onCreated = vi.fn(); const onCreated = vi.fn();
@@ -8,13 +8,15 @@ const mockSkills = [
name: "code-review", name: "code-review",
description: "Reviews code", description: "Reviews code",
instructions: "Review the code...", instructions: "Review the code...",
path: "/path", path: "/path/code-review",
fileLocation: "/path/code-review/SKILL.md",
}, },
{ {
name: "test-writer", name: "test-writer",
description: "Writes tests", description: "Writes tests",
instructions: "Write tests...", instructions: "Write tests...",
path: "/path", path: "/path/test-writer",
fileLocation: "/path/test-writer/SKILL.md",
}, },
]; ];
@@ -192,8 +194,43 @@ describe("SkillsView", () => {
await user.click(screen.getByRole("button", { name: "Delete" })); await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => { await waitFor(() => {
expect(deleteSkill).toHaveBeenCalledWith("code-review"); expect(deleteSkill).toHaveBeenCalledWith("/path/code-review");
}); });
}); });
it("does not show the path on disk in the list view and still allows deleting discovered skills", async () => {
listSkills.mockResolvedValue([
{
name: "claude-skill",
description: "Imported from Claude",
instructions: "Use this skill...",
path: "/Users/test/.claude/skills/claude-skill",
fileLocation: "/Users/test/.claude/skills/claude-skill/SKILL.md",
},
]);
const user = userEvent.setup();
render(<SkillsView />);
expect(await screen.findByText("claude-skill")).toBeInTheDocument();
expect(screen.queryByText("Path on disk:")).not.toBeInTheDocument();
expect(
screen.queryByText("/Users/test/.claude/skills/claude-skill/SKILL.md"),
).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Options for claude-skill"));
expect(
screen.getByRole("menuitem", { name: /edit/i }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /duplicate/i }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /export/i }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /delete/i }),
).toBeInTheDocument();
});
}); });
}); });
@@ -9,7 +9,8 @@
"instructionsPlaceholder": "Markdown instructions the agent will follow...", "instructionsPlaceholder": "Markdown instructions the agent will follow...",
"name": "Name", "name": "Name",
"namePlaceholder": "my-skill-name", "namePlaceholder": "my-skill-name",
"nameValidation": "Must be kebab-case (e.g. code-review)", "nameValidation": "Use 164 lowercase letters, numbers, or hyphens. Names cannot start or end with a hyphen.",
"pathOnDisk": "Path on disk",
"newTitle": "New Skill", "newTitle": "New Skill",
"saving": "Saving..." "saving": "Saving..."
}, },
@@ -9,7 +9,8 @@
"instructionsPlaceholder": "Instrucciones en Markdown que seguirá el agente...", "instructionsPlaceholder": "Instrucciones en Markdown que seguirá el agente...",
"name": "Nombre", "name": "Nombre",
"namePlaceholder": "mi-skill", "namePlaceholder": "mi-skill",
"nameValidation": "Debe estar en kebab-case (p. ej. code-review)", "nameValidation": "Usa de 1 a 64 letras minúsculas, números o guiones. El nombre no puede empezar ni terminar con un guion.",
"pathOnDisk": "Ruta en disco",
"newTitle": "Nueva skill", "newTitle": "Nueva skill",
"saving": "Guardando..." "saving": "Guardando..."
}, },
+24 -6
View File
@@ -96,6 +96,7 @@ export function buildInitScript(options?: {
content: s.instructions ?? s.content ?? "", content: s.instructions ?? s.content ?? "",
directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""), directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""),
global: true, global: true,
supportingFiles: [],
}); });
function nowIso() { function nowIso() {
@@ -195,26 +196,43 @@ export function buildInitScript(options?: {
content: message.params?.content ?? "", content: message.params?.content ?? "",
directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"), directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"),
global: message.params?.global ?? true, global: message.params?.global ?? true,
supportingFiles: [],
}, },
}); });
case "_goose/sources/update": case "_goose/sources/update": {
const path = message.params?.path ?? "/mock/.agents/skills/updated-skill";
const nextName = message.params?.name;
const name =
typeof nextName === "string" && nextName.length > 0
? nextName
: String(path).split("/").filter(Boolean).at(-1) ?? "updated-skill";
const segments = String(path).split("/").filter(Boolean);
if (segments.length > 0) {
segments[segments.length - 1] = name;
}
const directory = \`/\${segments.join("/")}\`;
return jsonRpcResult(message.id, { return jsonRpcResult(message.id, {
source: { source: {
name: message.params?.name ?? "updated-skill", name,
type: "skill", type: "skill",
description: message.params?.description ?? "", description: message.params?.description ?? "",
content: message.params?.content ?? "", content: message.params?.content ?? "",
directory: "/mock/.agents/skills/" + (message.params?.name ?? "updated-skill"), directory,
global: message.params?.global ?? true, global: true,
supportingFiles: [],
}, },
}); });
}
case "_goose/sources/delete": case "_goose/sources/delete":
return jsonRpcResult(message.id, {}); return jsonRpcResult(message.id, {});
case "_goose/sources/export": case "_goose/sources/export": {
const path = message.params?.path ?? "/mock/.agents/skills/skill";
const name = String(path).split("/").filter(Boolean).at(-1) ?? "skill";
return jsonRpcResult(message.id, { return jsonRpcResult(message.id, {
json: "{}", json: "{}",
filename: (message.params?.name ?? "skill") + ".skill.json", filename: name + ".skill.json",
}); });
}
case "_goose/sources/import": case "_goose/sources/import":
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) }); return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
default: default:
+33 -7
View File
@@ -95,17 +95,17 @@ test.describe("Skills view", () => {
await expect(nameInput).toHaveValue("my-skill-name"); await expect(nameInput).toHaveValue("my-skill-name");
}); });
test("shows kebab-case validation error for trailing hyphen", async ({ test("shows validation error for trailing hyphen", async ({
tauriMocked: page, tauriMocked: page,
}) => { }) => {
await navigateToSkills(page); await navigateToSkills(page);
await page.getByRole("button", { name: "New Skill" }).first().click(); await page.getByRole("button", { name: "New Skill" }).first().click();
const dialog = page.getByRole("dialog"); const dialog = page.getByRole("dialog");
// Type something that ends with a hyphen (the auto-formatter will produce "test-")
await dialog.getByPlaceholder("my-skill-name").pressSequentially("test "); await dialog.getByPlaceholder("my-skill-name").pressSequentially("test ");
// The regex /^[a-z0-9]+(-[a-z0-9]+)*$/ won't match "test-", so error shows
await expect( await expect(
dialog.getByText("Must be kebab-case (e.g. code-review)"), dialog.getByText(
"Use 164 lowercase letters, numbers, or hyphens. Names cannot start or end with a hyphen.",
),
).toBeVisible(); ).toBeVisible();
}); });
@@ -145,19 +145,45 @@ test.describe("Skills view", () => {
await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible(); await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible();
}); });
test("Edit opens edit dialog with pre-filled fields", async ({ test("Edit opens edit dialog with pre-filled editable fields", async ({
tauriMocked: page, tauriMocked: page,
}) => { }) => {
await navigateToSkills(page); await navigateToSkills(page);
await page.getByLabel("Options for code-review").click(); await page.getByLabel("Options for code-review").click();
await page.getByRole("menuitem", { name: "Edit" }).click(); await page.getByRole("menuitem", { name: "Edit" }).click();
const dialog = page.getByRole("dialog"); const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
await expect(dialog.locator("h2", { hasText: "Edit Skill" })).toBeVisible(); await expect(dialog.locator("h2", { hasText: "Edit Skill" })).toBeVisible();
// Name should be pre-filled and read-only
const nameInput = dialog.getByPlaceholder("my-skill-name"); const nameInput = dialog.getByPlaceholder("my-skill-name");
const descriptionInput = dialog.getByPlaceholder(
"What it does and when to use it...",
);
const instructionsInput = dialog.getByPlaceholder(
"Markdown instructions the agent will follow...",
);
await expect(nameInput).toHaveValue("code-review"); await expect(nameInput).toHaveValue("code-review");
await expect(nameInput).toHaveAttribute("readonly", ""); await expect(descriptionInput).toHaveValue(
"Reviews code for quality and best practices",
);
await expect(instructionsInput).toHaveValue(
"When asked to review code, analyze the diff and provide feedback on code quality, potential bugs, and best practices.",
);
await expect(
dialog.getByText(
"Path on disk: /mock/.agents/skills/code-review/SKILL.md",
),
).toBeVisible();
await nameInput.fill("renamed-skill");
await expect(nameInput).toHaveValue("renamed-skill");
await expect(
dialog.getByText(
"Path on disk: /mock/.agents/skills/renamed-skill/SKILL.md",
),
).toBeVisible();
}); });
test("Delete triggers confirmation dialog", async ({ tauriMocked: page }) => { test("Delete triggers confirmation dialog", async ({ tauriMocked: page }) => {
+24 -20
View File
@@ -403,7 +403,7 @@ export type UnarchiveSessionRequest = {
}; };
/** /**
* Create a new source (global or project-scoped). * Create a new source in an explicit target scope (global or project-scoped).
*/ */
export type CreateSourceRequest = { export type CreateSourceRequest = {
type: SourceType; type: SourceType;
@@ -420,15 +420,15 @@ export type CreateSourceRequest = {
/** /**
* The type of source entity. * The type of source entity.
*/ */
export type SourceType = 'skill'; export type SourceType = 'skill' | 'builtinSkill' | 'recipe' | 'subrecipe' | 'agent';
export type CreateSourceResponse = { export type CreateSourceResponse = {
source: SourceEntry; source: SourceEntry;
}; };
/** /**
* A source a user-editable entity backed by an on-disk directory. Sources * A source discovered by Goose and backed by an on-disk path. Sources may be
* may be either `global` (shared across all projects) or project-specific. * either `global` (shared across all projects) or project-specific.
*/ */
export type SourceEntry = { export type SourceEntry = {
type: SourceType; type: SourceType;
@@ -436,7 +436,8 @@ export type SourceEntry = {
description: string; description: string;
content: string; content: string;
/** /**
* Absolute path to the source's directory on disk. * Absolute path to the source on disk. A directory for skills, a file for
* recipes and agents.
*/ */
directory: string; directory: string;
/** /**
@@ -444,11 +445,19 @@ export type SourceEntry = {
* when it lives inside a specific project. * when it lives inside a specific project.
*/ */
global: boolean; global: boolean;
/**
* Paths (absolute) of additional files that live alongside the source.
* Only skills currently populate this; empty for other source types.
*/
supportingFiles?: Array<string>;
}; };
/** /**
* List sources. If `type` is omitted, sources of all known types are returned. * List discovered sources.
* Both global and project-scoped sources are included when `project_dir` is set. *
* Today this endpoint only returns skills. If `type` is omitted, it defaults
* to listing skill sources. Both global and project-scoped skills are included
* when `project_dir` is set.
*/ */
export type ListSourcesRequest = { export type ListSourcesRequest = {
type?: SourceType | null; type?: SourceType | null;
@@ -460,15 +469,14 @@ export type ListSourcesResponse = {
}; };
/** /**
* Update an existing source's description and content. * Update an existing source's name, description, and content by absolute path.
*/ */
export type UpdateSourceRequest = { export type UpdateSourceRequest = {
type: SourceType; type: SourceType;
path: string;
name: string; name: string;
description: string; description: string;
content: string; content: string;
global: boolean;
projectDir?: string | null;
}; };
export type UpdateSourceResponse = { export type UpdateSourceResponse = {
@@ -476,23 +484,19 @@ export type UpdateSourceResponse = {
}; };
/** /**
* Delete a source and its on-disk directory. * Delete a source and its on-disk directory by absolute path.
*/ */
export type DeleteSourceRequest = { export type DeleteSourceRequest = {
type: SourceType; type: SourceType;
name: string; path: string;
global: boolean;
projectDir?: string | null;
}; };
/** /**
* Export a source as a portable JSON payload. * Export a source at an absolute path as a portable JSON payload.
*/ */
export type ExportSourceRequest = { export type ExportSourceRequest = {
type: SourceType; type: SourceType;
name: string; path: string;
global: boolean;
projectDir?: string | null;
}; };
export type ExportSourceResponse = { export type ExportSourceResponse = {
@@ -502,8 +506,8 @@ export type ExportSourceResponse = {
/** /**
* Import a source from a JSON export payload produced by `_goose/sources/export`. * Import a source from a JSON export payload produced by `_goose/sources/export`.
* The imported source is written under the given scope; on name collisions a * The imported source is written into the explicit target scope; on name
* `-imported` suffix is appended. * collisions a `-imported` suffix is appended.
*/ */
export type ImportSourcesRequest = { export type ImportSourcesRequest = {
data: string; data: string;
+26 -30
View File
@@ -345,10 +345,16 @@ export const zUnarchiveSessionRequest = z.object({
/** /**
* The type of source entity. * The type of source entity.
*/ */
export const zSourceType = z.enum(['skill']); export const zSourceType = z.enum([
'skill',
'builtinSkill',
'recipe',
'subrecipe',
'agent'
]);
/** /**
* Create a new source (global or project-scoped). * Create a new source in an explicit target scope (global or project-scoped).
*/ */
export const zCreateSourceRequest = z.object({ export const zCreateSourceRequest = z.object({
type: zSourceType, type: zSourceType,
@@ -363,8 +369,8 @@ export const zCreateSourceRequest = z.object({
}); });
/** /**
* A source a user-editable entity backed by an on-disk directory. Sources * A source discovered by Goose and backed by an on-disk path. Sources may be
* may be either `global` (shared across all projects) or project-specific. * either `global` (shared across all projects) or project-specific.
*/ */
export const zSourceEntry = z.object({ export const zSourceEntry = z.object({
type: zSourceType, type: zSourceType,
@@ -372,7 +378,8 @@ export const zSourceEntry = z.object({
description: z.string(), description: z.string(),
content: z.string(), content: z.string(),
directory: z.string(), directory: z.string(),
global: z.boolean() global: z.boolean(),
supportingFiles: z.array(z.string()).optional()
}); });
export const zCreateSourceResponse = z.object({ export const zCreateSourceResponse = z.object({
@@ -380,8 +387,11 @@ export const zCreateSourceResponse = z.object({
}); });
/** /**
* List sources. If `type` is omitted, sources of all known types are returned. * List discovered sources.
* Both global and project-scoped sources are included when `project_dir` is set. *
* Today this endpoint only returns skills. If `type` is omitted, it defaults
* to listing skill sources. Both global and project-scoped skills are included
* when `project_dir` is set.
*/ */
export const zListSourcesRequest = z.object({ export const zListSourcesRequest = z.object({
type: z.union([ type: z.union([
@@ -399,18 +409,14 @@ export const zListSourcesResponse = z.object({
}); });
/** /**
* Update an existing source's description and content. * Update an existing source's name, description, and content by absolute path.
*/ */
export const zUpdateSourceRequest = z.object({ export const zUpdateSourceRequest = z.object({
type: zSourceType, type: zSourceType,
path: z.string(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
content: z.string(), content: z.string()
global: z.boolean(),
projectDir: z.union([
z.string(),
z.null()
]).optional()
}); });
export const zUpdateSourceResponse = z.object({ export const zUpdateSourceResponse = z.object({
@@ -418,29 +424,19 @@ export const zUpdateSourceResponse = z.object({
}); });
/** /**
* Delete a source and its on-disk directory. * Delete a source and its on-disk directory by absolute path.
*/ */
export const zDeleteSourceRequest = z.object({ export const zDeleteSourceRequest = z.object({
type: zSourceType, type: zSourceType,
name: z.string(), path: z.string()
global: z.boolean(),
projectDir: z.union([
z.string(),
z.null()
]).optional()
}); });
/** /**
* Export a source as a portable JSON payload. * Export a source at an absolute path as a portable JSON payload.
*/ */
export const zExportSourceRequest = z.object({ export const zExportSourceRequest = z.object({
type: zSourceType, type: zSourceType,
name: z.string(), path: z.string()
global: z.boolean(),
projectDir: z.union([
z.string(),
z.null()
]).optional()
}); });
export const zExportSourceResponse = z.object({ export const zExportSourceResponse = z.object({
@@ -450,8 +446,8 @@ export const zExportSourceResponse = z.object({
/** /**
* Import a source from a JSON export payload produced by `_goose/sources/export`. * Import a source from a JSON export payload produced by `_goose/sources/export`.
* The imported source is written under the given scope; on name collisions a * The imported source is written into the explicit target scope; on name
* `-imported` suffix is appended. * collisions a `-imported` suffix is appended.
*/ */
export const zImportSourcesRequest = z.object({ export const zImportSourcesRequest = z.object({
data: z.string(), data: z.string(),