From 18a340f136e5804cc2dfb1d73523b68992fc09d3 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Apr 2026 12:55:58 -0400 Subject: [PATCH 01/20] Consolidate and dedupe skills --- crates/goose-acp/src/server.rs | 12 +- .../src/routes/config_management.rs | 4 +- crates/goose/src/agents/execute_commands.rs | 2 +- crates/goose/src/agents/mod.rs | 1 - .../src/agents/platform_extensions/mod.rs | 7 +- crates/goose/src/lib.rs | 2 +- .../mod.rs => skills/builtin.rs} | 3 +- .../builtins}/goose_doc_guide.md | 0 .../skills.rs => skills/client.rs} | 197 +-------------- crates/goose/src/skills/mod.rs | 234 ++++++++++++++++++ crates/goose/src/{ => skills}/sources.rs | 141 ++++------- 11 files changed, 306 insertions(+), 297 deletions(-) rename crates/goose/src/{agents/builtin_skills/mod.rs => skills/builtin.rs} (70%) rename crates/goose/src/{agents/builtin_skills/skills => skills/builtins}/goose_doc_guide.md (100%) rename crates/goose/src/{agents/platform_extensions/skills.rs => skills/client.rs} (67%) create mode 100644 crates/goose/src/skills/mod.rs rename crates/goose/src/{ => skills}/sources.rs (83%) diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 09d483a2c56a..9e67dc144417 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -3120,7 +3120,7 @@ impl GooseAcpAgent { &self, req: CreateSourceRequest, ) -> Result { - let source = goose::sources::create_source( + let source = goose::skills::sources::create_source( req.source_type, &req.name, &req.description, @@ -3136,7 +3136,7 @@ impl GooseAcpAgent { &self, req: ListSourcesRequest, ) -> Result { - let sources = goose::sources::list_sources(req.source_type, req.project_dir.as_deref())?; + let sources = goose::skills::sources::list_sources(req.source_type, req.project_dir.as_deref())?; Ok(ListSourcesResponse { sources }) } @@ -3145,7 +3145,7 @@ impl GooseAcpAgent { &self, req: UpdateSourceRequest, ) -> Result { - let source = goose::sources::update_source( + let source = goose::skills::sources::update_source( req.source_type, &req.name, &req.description, @@ -3161,7 +3161,7 @@ impl GooseAcpAgent { &self, req: DeleteSourceRequest, ) -> Result { - goose::sources::delete_source( + goose::skills::sources::delete_source( req.source_type, &req.name, req.global, @@ -3175,7 +3175,7 @@ impl GooseAcpAgent { &self, req: ExportSourceRequest, ) -> Result { - let (json, filename) = goose::sources::export_source( + let (json, filename) = goose::skills::sources::export_source( req.source_type, &req.name, req.global, @@ -3190,7 +3190,7 @@ impl GooseAcpAgent { req: ImportSourcesRequest, ) -> Result { let sources = - goose::sources::import_sources(&req.data, req.global, req.project_dir.as_deref())?; + goose::skills::sources::import_sources(&req.data, req.global, req.project_dir.as_deref())?; Ok(ImportSourcesResponse { sources }) } diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 1d18e2b40fac..81c2291df259 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -426,9 +426,7 @@ pub async fn get_slash_commands( } let working_dir = query.working_dir.map(std::path::PathBuf::from); - for source in - goose::agents::platform_extensions::skills::list_installed_skills(working_dir.as_deref()) - { + for source in goose::skills::list_installed_skills(working_dir.as_deref()) { commands.push(SlashCommand { command: source.name, help: source.description, diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs index b49713854e2d..f695e3f94a3a 100644 --- a/crates/goose/src/agents/execute_commands.rs +++ b/crates/goose/src/agents/execute_commands.rs @@ -140,8 +140,8 @@ impl Agent { } async fn handle_skills_command(&self, session_id: &str) -> Result> { - use super::platform_extensions::skills::list_installed_skills; use super::platform_extensions::SourceKind; + use crate::skills::list_installed_skills; let working_dir = self .config diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 1b41a743182b..a221907e84ba 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -1,5 +1,4 @@ mod agent; -pub(crate) mod builtin_skills; pub mod container; pub mod execute_commands; pub mod extension; diff --git a/crates/goose/src/agents/platform_extensions/mod.rs b/crates/goose/src/agents/platform_extensions/mod.rs index 2166d3f3f715..45da451fd732 100644 --- a/crates/goose/src/agents/platform_extensions/mod.rs +++ b/crates/goose/src/agents/platform_extensions/mod.rs @@ -6,7 +6,6 @@ pub mod code_execution; pub mod developer; pub mod ext_manager; pub mod orchestrator; -pub mod skills; pub mod summarize; pub mod summon; pub mod todo; @@ -248,15 +247,15 @@ pub static PLATFORM_EXTENSIONS: Lazy ); map.insert( - skills::EXTENSION_NAME, + crate::skills::EXTENSION_NAME, PlatformExtensionDef { - name: skills::EXTENSION_NAME, + name: crate::skills::EXTENSION_NAME, display_name: "Skills", description: "Discover and provide skill instructions from filesystem and builtins", default_enabled: true, unprefixed_tools: true, hidden: false, - client_factory: |ctx| Box::new(skills::SkillsClient::new(ctx).unwrap()), + client_factory: |ctx| Box::new(crate::skills::SkillsClient::new(ctx).unwrap()), }, ); diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index def1d186d038..019ce2b2395d 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -37,8 +37,8 @@ pub mod scheduler_trait; pub mod security; pub mod session; pub mod session_context; +pub mod skills; pub mod slash_commands; -pub mod sources; pub mod subprocess; pub mod token_counter; pub mod tool_inspection; diff --git a/crates/goose/src/agents/builtin_skills/mod.rs b/crates/goose/src/skills/builtin.rs similarity index 70% rename from crates/goose/src/agents/builtin_skills/mod.rs rename to crates/goose/src/skills/builtin.rs index 039e7680448d..daaec140e29f 100644 --- a/crates/goose/src/agents/builtin_skills/mod.rs +++ b/crates/goose/src/skills/builtin.rs @@ -1,7 +1,6 @@ use include_dir::{include_dir, Dir}; -static BUILTIN_SKILLS_DIR: Dir = - include_dir!("$CARGO_MANIFEST_DIR/src/agents/builtin_skills/skills"); +static BUILTIN_SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/skills/builtins"); pub fn get_all() -> Vec<&'static str> { BUILTIN_SKILLS_DIR diff --git a/crates/goose/src/agents/builtin_skills/skills/goose_doc_guide.md b/crates/goose/src/skills/builtins/goose_doc_guide.md similarity index 100% rename from crates/goose/src/agents/builtin_skills/skills/goose_doc_guide.md rename to crates/goose/src/skills/builtins/goose_doc_guide.md diff --git a/crates/goose/src/agents/platform_extensions/skills.rs b/crates/goose/src/skills/client.rs similarity index 67% rename from crates/goose/src/agents/platform_extensions/skills.rs rename to crates/goose/src/skills/client.rs index c209cfea53be..349a8aac5fc6 100644 --- a/crates/goose/src/agents/platform_extensions/skills.rs +++ b/crates/goose/src/skills/client.rs @@ -1,201 +1,19 @@ -use super::{parse_frontmatter, Source, SourceKind}; -use crate::agents::builtin_skills; +use super::discover_skills; use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait}; -use crate::agents::tool_execution::ToolCallContext; -use crate::config::paths::Paths; +use crate::agents::platform_extensions::{Source, SourceKind}; +use crate::agents::ToolCallContext; use async_trait::async_trait; use rmcp::model::{ CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, ServerCapabilities, ServerNotification, Tool, }; -use serde::Deserialize; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -use tracing::warn; pub static EXTENSION_NAME: &str = "skills"; -#[derive(Debug, Deserialize)] -struct SkillMetadata { - name: String, - description: String, -} - -fn parse_skill_content(content: &str, path: PathBuf) -> Option { - 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( - dir: &Path, - visited_dirs: &mut HashSet, - 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) -> Vec { - 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 { - 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 = [ - 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 { - 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 { info: InitializeResult, working_dir: PathBuf, @@ -211,7 +29,7 @@ impl SkillsClient { let mut instructions = String::new(); if context.session.is_some() { - let sources = discover_skills(&working_dir); + let sources = discover_skills(Some(&working_dir)); let mut skills: Vec<&Source> = sources .iter() .filter(|s| s.kind == SourceKind::Skill || s.kind == SourceKind::BuiltinSkill) @@ -300,9 +118,8 @@ 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) { let mut output = format!( "# Loaded Skill: {} ({})\n\n{}\n", @@ -331,7 +148,6 @@ impl McpClientTrait for SkillsClient { 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('/') { let relative_path = raw_relative_path.replace('\\', "/"); if let Some(skill) = skills.iter().find(|s| { @@ -403,7 +219,6 @@ impl McpClientTrait for SkillsClient { } } - // No match — suggest similar skills let suggestions: Vec<&str> = skills .iter() .filter(|s| { diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs new file mode 100644 index 000000000000..6d18a59591d6 --- /dev/null +++ b/crates/goose/src/skills/mod.rs @@ -0,0 +1,234 @@ +//! Everything related to skills: filesystem discovery (`SKILL.md` walking + +//! built-ins), runtime MCP client (`client` submodule), and user-facing CRUD +//! over ACP (`sources` submodule). + +mod builtin; +pub mod client; +pub mod sources; + +pub use client::{SkillsClient, EXTENSION_NAME}; + +use crate::agents::platform_extensions::{parse_frontmatter, Source, SourceKind}; +use crate::config::paths::Paths; +use serde::Deserialize; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use tracing::warn; + +/// Shared YAML frontmatter shape for `SKILL.md` files. `name` is optional at +/// the parser level so callers can decide whether to require it (runtime +/// discovery does; source CRUD uses the directory name instead). +#[derive(Debug, Deserialize)] +pub struct SkillFrontmatter { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: String, +} + +/// Canonical writable location for global user skills: `~/.agents/skills`. +pub fn global_skills_dir() -> Option { + dirs::home_dir().map(|h| h.join(".agents").join("skills")) +} + +/// Canonical writable location for project-scoped skills: +/// `/.goose/skills`. +pub fn project_skills_dir(project_dir: &Path) -> PathBuf { + project_dir.join(".goose").join("skills") +} + +/// 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: PathBuf) -> Option { + 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 = metadata.name.filter(|n| !n.is_empty())?; + + if name.contains('/') { + warn!("Skill name '{}' contains '/', skipping", name); + return None; + } + + Some(Source { + 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( + dir: &Path, + visited_dirs: &mut HashSet, + 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) -> Vec { + 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) { + 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 +} + +pub fn discover_skills(working_dir: Option<&Path>) -> Vec { + discover_skills_with_scope(working_dir) + .into_iter() + .map(|(source, _)| source) + .collect() +} + +/// Discover skills and pair each with whether the directory it was found in is +/// a global (home-rooted) location. Built-in skills are reported with +/// `global = true`. +pub fn discover_skills_with_scope(working_dir: Option<&Path>) -> Vec<(Source, bool)> { + let mut sources: Vec<(Source, bool)> = 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, &mut seen) { + sources.push((source, is_global)); + } + } + + for content in builtin::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 + }, + true, + )); + } + } + } + + sources +} + +pub fn list_installed_skills(working_dir: Option<&Path>) -> Vec { + let fallback; + let wd = match working_dir { + Some(p) => Some(p), + None => { + fallback = std::env::current_dir().ok(); + fallback.as_deref() + } + }; + discover_skills(wd) +} diff --git a/crates/goose/src/sources.rs b/crates/goose/src/skills/sources.rs similarity index 83% rename from crates/goose/src/sources.rs rename to crates/goose/src/skills/sources.rs index 35d0b67805a4..c79e84b9c7ef 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/skills/sources.rs @@ -1,48 +1,30 @@ //! 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 `/.goose/skills` -//! for project-specific sources. - -use crate::agents::platform_extensions::parse_frontmatter; +//! methods. Writes go to the canonical per-scope directory — `~/.agents/skills` +//! for global sources and `/.goose/skills` for project-specific ones. +//! `list_sources` reads from every location the agent loads skills from so the +//! UI sees the same set of skills the runtime uses. + +use super::{ + discover_skills_with_scope, global_skills_dir, project_skills_dir, SkillFrontmatter, +}; +use crate::agents::platform_extensions::{parse_frontmatter, Source, SourceKind}; use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use sacp::Error; -use serde::Deserialize; use std::path::{Path, PathBuf}; -#[derive(Deserialize)] -struct SkillFront { - #[serde(default)] - description: String, -} - -const GLOBAL_SKILLS_SUBPATH: &[&str] = &[".agents", "skills"]; -const PROJECT_SKILLS_SUBPATH: &[&str] = &[".goose", "skills"]; - -fn home_dir() -> Result { - dirs::home_dir() +fn skills_dir_global_or_err() -> Result { + global_skills_dir() .ok_or_else(|| Error::internal_error().data("Could not determine home directory")) } -fn skills_dir_global() -> Result { - 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 { +fn skills_dir_project_or_err(project_dir: &str) -> Result { 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) + Ok(project_skills_dir(Path::new(project_dir))) } fn source_base_dir( @@ -53,12 +35,12 @@ fn source_base_dir( match source_type { SourceType::Skill => { if global { - skills_dir_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(pd) + skills_dir_project_or_err(pd) } } } @@ -109,7 +91,7 @@ fn parse_skill_frontmatter(raw: &str) -> (String, String) { if !raw.trim_start().starts_with("---") { return (String::new(), raw.to_string()); } - match parse_frontmatter::(raw) { + match parse_frontmatter::(raw) { Ok(Some((meta, body))) => (meta.description, body), _ => (String::new(), raw.to_string()), } @@ -133,6 +115,25 @@ fn source_entry( } } +fn source_type_for(kind: SourceKind) -> Option { + match kind { + SourceKind::Skill => Some(SourceType::Skill), + _ => None, + } +} + +fn to_source_entry(source: &Source, global: bool) -> Option { + let source_type = source_type_for(source.kind)?; + Some(source_entry( + source_type, + &source.name, + &source.description, + &source.content, + &source.path, + global, + )) +} + pub fn create_source( source_type: SourceType, name: &str, @@ -219,66 +220,30 @@ pub fn list_sources( source_type: Option, project_dir: Option<&str>, ) -> Result, Error> { - let kinds: Vec = match source_type { - Some(k) => vec![k], - 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 type_filter = source_type; + + let working_dir = project_dir + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(PathBuf::from); + + let mut sources: Vec = discover_skills_with_scope(working_dir.as_deref()) + .iter() + .filter_map(|(s, global)| { + let entry = to_source_entry(s, *global)?; + if let Some(t) = type_filter { + if entry.source_type != t { + return None; } - let dir = skills_dir_global()?; - sources.extend(read_skill_dir(&dir, true)?); } - } - } + Some(entry) + }) + .collect(); + sources.sort_by(|a, b| a.name.cmp(&b.name)); Ok(sources) } -fn read_skill_dir(dir: &Path, global: bool) -> Result, Error> { - if !dir.exists() { - return Ok(Vec::new()); - } - 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, From 47b8d880471a4558de481af50deebbb765adf0b6 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Apr 2026 13:01:51 -0400 Subject: [PATCH 02/20] other way around --- crates/goose-acp/src/server.rs | 12 ++++++------ crates/goose/src/lib.rs | 1 + crates/goose/src/skills/mod.rs | 7 +++---- crates/goose/src/{skills => }/sources.rs | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) rename crates/goose/src/{skills => }/sources.rs (99%) diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 9e67dc144417..09d483a2c56a 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -3120,7 +3120,7 @@ impl GooseAcpAgent { &self, req: CreateSourceRequest, ) -> Result { - let source = goose::skills::sources::create_source( + let source = goose::sources::create_source( req.source_type, &req.name, &req.description, @@ -3136,7 +3136,7 @@ impl GooseAcpAgent { &self, req: ListSourcesRequest, ) -> Result { - let sources = goose::skills::sources::list_sources(req.source_type, req.project_dir.as_deref())?; + let sources = goose::sources::list_sources(req.source_type, req.project_dir.as_deref())?; Ok(ListSourcesResponse { sources }) } @@ -3145,7 +3145,7 @@ impl GooseAcpAgent { &self, req: UpdateSourceRequest, ) -> Result { - let source = goose::skills::sources::update_source( + let source = goose::sources::update_source( req.source_type, &req.name, &req.description, @@ -3161,7 +3161,7 @@ impl GooseAcpAgent { &self, req: DeleteSourceRequest, ) -> Result { - goose::skills::sources::delete_source( + goose::sources::delete_source( req.source_type, &req.name, req.global, @@ -3175,7 +3175,7 @@ impl GooseAcpAgent { &self, req: ExportSourceRequest, ) -> Result { - let (json, filename) = goose::skills::sources::export_source( + let (json, filename) = goose::sources::export_source( req.source_type, &req.name, req.global, @@ -3190,7 +3190,7 @@ impl GooseAcpAgent { req: ImportSourcesRequest, ) -> Result { let sources = - goose::skills::sources::import_sources(&req.data, req.global, req.project_dir.as_deref())?; + goose::sources::import_sources(&req.data, req.global, req.project_dir.as_deref())?; Ok(ImportSourcesResponse { sources }) } diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 019ce2b2395d..c74ccbd6fe82 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -39,6 +39,7 @@ pub mod session; pub mod session_context; pub mod skills; pub mod slash_commands; +pub mod sources; pub mod subprocess; pub mod token_counter; pub mod tool_inspection; diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index 6d18a59591d6..65f0caf03741 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -1,10 +1,9 @@ -//! Everything related to skills: filesystem discovery (`SKILL.md` walking + -//! built-ins), runtime MCP client (`client` submodule), and user-facing CRUD -//! over ACP (`sources` submodule). +//! 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 mod sources; pub use client::{SkillsClient, EXTENSION_NAME}; diff --git a/crates/goose/src/skills/sources.rs b/crates/goose/src/sources.rs similarity index 99% rename from crates/goose/src/skills/sources.rs rename to crates/goose/src/sources.rs index c79e84b9c7ef..b4840d91f14c 100644 --- a/crates/goose/src/skills/sources.rs +++ b/crates/goose/src/sources.rs @@ -4,10 +4,10 @@ //! `list_sources` reads from every location the agent loads skills from so the //! UI sees the same set of skills the runtime uses. -use super::{ +use crate::agents::platform_extensions::{parse_frontmatter, Source, SourceKind}; +use crate::skills::{ discover_skills_with_scope, global_skills_dir, project_skills_dir, SkillFrontmatter, }; -use crate::agents::platform_extensions::{parse_frontmatter, Source, SourceKind}; use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use sacp::Error; From 17b3c014bd59874658ecf49d094997e292692cca Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Apr 2026 13:05:11 -0400 Subject: [PATCH 03/20] back to the original --- crates/goose/src/sources.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index b4840d91f14c..e9f55b0d5c23 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -1,8 +1,7 @@ //! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom -//! methods. Writes go to the canonical per-scope directory — `~/.agents/skills` -//! for global sources and `/.goose/skills` for project-specific ones. -//! `list_sources` reads from every location the agent loads skills from so the -//! UI sees the same set of skills the runtime uses. +//! methods. A source is a user-editable entity stored under a per-scope root +//! directory — `~/.agents/skills` for global sources and `/.goose/skills` +//! for project-specific sources. use crate::agents::platform_extensions::{parse_frontmatter, Source, SourceKind}; use crate::skills::{ From 87eec39364a9e05065ff39c975ddb17dd570c5b7 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Apr 2026 15:17:19 -0400 Subject: [PATCH 04/20] Consolidate existing Source type --- crates/goose-sdk/src/custom_requests.rs | 47 +++++++-- crates/goose/src/agents/execute_commands.rs | 6 +- .../src/agents/platform_extensions/mod.rs | 41 -------- .../src/agents/platform_extensions/summon.rs | 96 ++++++++++--------- crates/goose/src/skills/client.rs | 40 ++++---- crates/goose/src/skills/mod.rs | 58 ++++++----- crates/goose/src/sources.rs | 54 ++++------- 7 files changed, 163 insertions(+), 179 deletions(-) diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 2906b7aad126..94f80d8453d9 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -297,15 +297,33 @@ pub struct ProviderConfigKey { } /// 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")] pub enum SourceType { #[default] Skill, -} - -/// A source — a user-editable entity backed by an on-disk directory. Sources -/// may be either `global` (shared across all projects) or project-specific. + BuiltinSkill, + Recipe, + Subrecipe, + Agent, +} + +impl std::fmt::Display for SourceType { + 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 — a user-editable entity 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)] #[serde(rename_all = "camelCase")] pub struct SourceEntry { @@ -314,11 +332,28 @@ pub struct SourceEntry { pub name: String, pub description: 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, /// True when the source lives in the user's global sources directory; false /// when it lives inside a specific project. 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, +} + +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 (global or project-scoped). diff --git a/crates/goose/src/agents/execute_commands.rs b/crates/goose/src/agents/execute_commands.rs index f695e3f94a3a..3a488c70a1fb 100644 --- a/crates/goose/src/agents/execute_commands.rs +++ b/crates/goose/src/agents/execute_commands.rs @@ -140,8 +140,8 @@ impl Agent { } async fn handle_skills_command(&self, session_id: &str) -> Result> { - use super::platform_extensions::SourceKind; use crate::skills::list_installed_skills; + use goose_sdk::custom_requests::SourceType; let working_dir = self .config @@ -153,7 +153,7 @@ impl Agent { let sources = list_installed_skills(working_dir.as_deref()); let skills: Vec<_> = sources .iter() - .filter(|s| matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill)) + .filter(|s| matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill)) .collect(); let mut output = String::new(); @@ -165,7 +165,7 @@ impl Agent { } else { output.push_str(&format!("**Installed skills ({}):**\n\n", skills.len())); for skill in &skills { - let kind_label = if skill.kind == SourceKind::BuiltinSkill { + let kind_label = if skill.source_type == SourceType::BuiltinSkill { " *(builtin)*" } else { "" diff --git a/crates/goose/src/agents/platform_extensions/mod.rs b/crates/goose/src/agents/platform_extensions/mod.rs index 45da451fd732..bc507a2a2b95 100644 --- a/crates/goose/src/agents/platform_extensions/mod.rs +++ b/crates/goose/src/agents/platform_extensions/mod.rs @@ -12,53 +12,12 @@ pub mod todo; pub mod tom; use std::collections::HashMap; -use std::path::PathBuf; use crate::agents::mcp_client::McpClientTrait; use crate::session::Session; 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, -} - -#[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 Deserialize<'de>>( content: &str, ) -> Result, serde_yaml::Error> { diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 8badb85793bf..4967d65f3a95 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -1,4 +1,4 @@ -use super::{parse_frontmatter, Source, SourceKind}; +use super::parse_frontmatter; use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait}; use crate::agents::subagent_handler::{run_subagent_task, OnMessageCallback, SubagentRunParams}; @@ -15,6 +15,7 @@ use crate::session::extension_data::EnabledExtensionsState; use crate::session::SessionType; use anyhow::Result; use async_trait::async_trait; +use goose_sdk::custom_requests::{SourceEntry, SourceType}; use rmcp::model::{ CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, Meta, ServerCapabilities, ServerNotification, Tool, @@ -33,11 +34,11 @@ use tracing::{info, warn}; pub static EXTENSION_NAME: &str = "summon"; -fn kind_plural(kind: SourceKind) -> &'static str { +fn kind_plural(kind: SourceType) -> &'static str { match kind { - SourceKind::Subrecipe => "Subrecipes", - SourceKind::Recipe => "Recipes", - SourceKind::Agent => "Agents", + SourceType::Subrecipe => "Subrecipes", + SourceType::Recipe => "Recipes", + SourceType::Agent => "Agents", _ => "Other", } } @@ -95,7 +96,7 @@ struct AgentMetadata { model: Option, } -fn parse_agent_content(content: &str, path: &Path) -> Option { +fn parse_agent_content(content: &str, path: &Path) -> Option { let (metadata, body): (AgentMetadata, String) = match parse_frontmatter(content) { Ok(Some(parsed)) => parsed, Ok(None) => return None, @@ -119,20 +120,21 @@ fn parse_agent_content(content: &str, path: &Path) -> Option { format!("Agent{}", model_info) }); - Some(Source { + Some(SourceEntry { + source_type: SourceType::Agent, name: metadata.name, - kind: SourceKind::Agent, description, - path: path.to_path_buf(), content: body, + directory: path.to_string_lossy().into_owned(), + global: false, supporting_files: Vec::new(), }) } fn scan_recipes_from_dir( dir: &Path, - kind: SourceKind, - sources: &mut Vec, + kind: SourceType, + sources: &mut Vec, seen: &mut std::collections::HashSet, ) { let entries = match std::fs::read_dir(dir) { @@ -164,12 +166,13 @@ fn scan_recipes_from_dir( match Recipe::from_file_path(&path) { Ok(recipe) => { seen.insert(name.clone()); - sources.push(Source { + sources.push(SourceEntry { + source_type: kind, name, - kind, description: recipe.description.clone(), - path: path.clone(), content: recipe.instructions.clone().unwrap_or_default(), + directory: path.to_string_lossy().into_owned(), + global: false, supporting_files: Vec::new(), }); } @@ -182,7 +185,7 @@ fn scan_recipes_from_dir( fn scan_agents_from_dir( dir: &Path, - sources: &mut Vec, + sources: &mut Vec, seen: &mut std::collections::HashSet, ) { let entries = match std::fs::read_dir(dir) { @@ -218,8 +221,8 @@ fn scan_agents_from_dir( } } -fn discover_filesystem_sources(working_dir: &Path) -> Vec { - let mut sources: Vec = Vec::new(); +fn discover_filesystem_sources(working_dir: &Path) -> Vec { + let mut sources: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let home = dirs::home_dir(); @@ -264,7 +267,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec { .collect(); 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 { @@ -272,7 +275,7 @@ fn discover_filesystem_sources(working_dir: &Path) -> Vec { } 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 { @@ -313,7 +316,7 @@ fn is_session_id(s: &str) -> bool { pub struct SummonClient { info: InitializeResult, context: PlatformExtensionContext, - source_cache: Mutex)>>, + source_cache: Mutex)>>, background_tasks: Mutex>, completed_tasks: Mutex>, notification_subscribers: Arc>>>, @@ -475,11 +478,11 @@ impl SummonClient { .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) } - async fn get_sources(&self, session_id: &str, working_dir: &Path) -> Vec { + async fn get_sources(&self, session_id: &str, working_dir: &Path) -> Vec { let fs_sources = self.get_filesystem_sources(working_dir).await; let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - let mut sources: Vec = Vec::new(); + let mut sources: Vec = Vec::new(); self.add_subrecipes(session_id, &mut sources, &mut seen) .await; @@ -491,11 +494,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 } - async fn get_filesystem_sources(&self, working_dir: &Path) -> Vec { + async fn get_filesystem_sources(&self, working_dir: &Path) -> Vec { let mut cache = self.source_cache.lock().await; if let Some((cached_at, cached_dir, sources)) = cache.as_ref() { if cached_dir == working_dir && cached_at.elapsed() < Duration::from_secs(60) { @@ -512,11 +515,11 @@ impl SummonClient { session_id: &str, name: &str, working_dir: &Path, - ) -> Result, String> { + ) -> Result, String> { let sources = self.get_sources(session_id, working_dir).await; 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; } return Ok(Some(source)); @@ -555,14 +558,14 @@ impl SummonClient { } } - fn discover_filesystem_sources(&self, working_dir: &Path) -> Vec { + fn discover_filesystem_sources(&self, working_dir: &Path) -> Vec { discover_filesystem_sources(working_dir) } async fn add_subrecipes( &self, session_id: &str, - sources: &mut Vec, + sources: &mut Vec, seen: &mut std::collections::HashSet, ) { let session = match self @@ -588,12 +591,13 @@ impl SummonClient { let description = self.build_subrecipe_description(sr).await; - sources.push(Source { + sources.push(SourceEntry { + source_type: SourceType::Subrecipe, name: sr.name.clone(), - kind: SourceKind::Subrecipe, description, - path: PathBuf::from(&sr.path), content: String::new(), + directory: sr.path.clone(), + global: false, supporting_files: Vec::new(), }); } @@ -839,8 +843,8 @@ impl SummonClient { } } - for kind in [SourceKind::Subrecipe, SourceKind::Recipe, SourceKind::Agent] { - let kind_sources: Vec<_> = sources.iter().filter(|s| s.kind == kind).collect(); + for kind in [SourceType::Subrecipe, SourceType::Recipe, SourceType::Agent] { + let kind_sources: Vec<_> = sources.iter().filter(|s| s.source_type == kind).collect(); if !kind_sources.is_empty() { output.push_str(&format!("\n{}:\n", kind_plural(kind))); for source in kind_sources { @@ -873,7 +877,7 @@ impl SummonClient { let output = format!( "# 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)]) @@ -1078,16 +1082,16 @@ impl SummonClient { .await? .ok_or_else(|| format!("Source '{}' not found", source_name))?; - let mut recipe = match source.kind { - SourceKind::Recipe | SourceKind::Subrecipe => { + let mut recipe = match source.source_type { + SourceType::Recipe | SourceType::Subrecipe => { self.build_recipe_from_source(&source, params, session_id) .await? } - SourceKind::Agent => self.build_recipe_from_agent(&source, params)?, + SourceType::Agent => self.build_recipe_from_agent(&source, params)?, _ => { return Err(format!( "Source '{}' has kind '{}' which cannot be delegated from summon", - source_name, source.kind + source_name, source.source_type )) } }; @@ -1106,7 +1110,7 @@ impl SummonClient { async fn build_recipe_from_source( &self, - source: &Source, + source: &SourceEntry, params: &DelegateParams, session_id: &str, ) -> Result { @@ -1117,7 +1121,7 @@ impl SummonClient { .await .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()); if let Some(sub_recipes) = sub_recipes { @@ -1154,7 +1158,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))?; let param_values: Vec<(String, String)> = params @@ -1184,13 +1188,13 @@ impl SummonClient { fn build_recipe_from_agent( &self, - source: &Source, + source: &SourceEntry, params: &DelegateParams, ) -> Result { - 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()); } 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))? }; @@ -1745,14 +1749,14 @@ You review code."#; let recipe = sources .iter() - .find(|s| s.name == "deploy" && s.kind == SourceKind::Recipe) + .find(|s| s.name == "deploy" && s.source_type == SourceType::Recipe) .unwrap(); assert_eq!(recipe.description, "Deploy to production"); assert_eq!(recipe.content, "Run deploy steps"); let agent = sources .iter() - .find(|s| s.name == "reviewer" && s.kind == SourceKind::Agent) + .find(|s| s.name == "reviewer" && s.source_type == SourceType::Agent) .unwrap(); assert_eq!(agent.description, "Code reviewer"); assert!(agent.content.contains("You review code")); diff --git a/crates/goose/src/skills/client.rs b/crates/goose/src/skills/client.rs index 349a8aac5fc6..8d1625eeb423 100644 --- a/crates/goose/src/skills/client.rs +++ b/crates/goose/src/skills/client.rs @@ -1,14 +1,14 @@ use super::discover_skills; use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait}; -use crate::agents::platform_extensions::{Source, SourceKind}; use crate::agents::ToolCallContext; use async_trait::async_trait; +use goose_sdk::custom_requests::{SourceEntry, SourceType}; use rmcp::model::{ CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, ServerCapabilities, ServerNotification, Tool, }; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -30,11 +30,14 @@ impl SkillsClient { let mut instructions = String::new(); if context.session.is_some() { let sources = discover_skills(Some(&working_dir)); - let mut skills: Vec<&Source> = sources + let mut skills: Vec<&SourceEntry> = sources .iter() - .filter(|s| s.kind == SourceKind::Skill || s.kind == SourceKind::BuiltinSkill) + .filter(|s| { + s.source_type == SourceType::Skill + || s.source_type == SourceType::BuiltinSkill + }) .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() { instructions.push_str( @@ -124,17 +127,18 @@ impl McpClientTrait for SkillsClient { let mut output = format!( "# Loaded Skill: {} ({})\n\n{}\n", skill.name, - skill.kind, + skill.source_type, skill.to_load_text() ); if !skill.supporting_files.is_empty() { + let skill_dir = Path::new(&skill.directory); output.push_str(&format!( "\n## Supporting Files\n\nSkill directory: {}\n\n", - skill.path.display() + skill.directory )); 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('\\', "/"); output.push_str(&format!( "- {} → load_skill(name: \"{}/{}\")\n", @@ -152,22 +156,25 @@ impl McpClientTrait for SkillsClient { let relative_path = raw_relative_path.replace('\\', "/"); if let Some(skill) = skills.iter().find(|s| { 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 - .path - .canonicalize() - .unwrap_or_else(|_| skill.path.clone()); + let skill_dir = PathBuf::from(&skill.directory); + let canonical_skill_dir = + skill_dir.canonicalize().unwrap_or_else(|_| skill_dir.clone()); 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; }; if rel.to_string_lossy().replace('\\', "/") != relative_path { continue; } - return Ok(match file_path.canonicalize() { + return Ok(match file_path_buf.canonicalize() { Ok(canonical) if canonical.starts_with(&canonical_skill_dir) => { match std::fs::read_to_string(&canonical) { Ok(content) => { @@ -197,7 +204,8 @@ impl McpClientTrait for SkillsClient { .supporting_files .iter() .filter_map(|f| { - f.strip_prefix(&skill.path) + Path::new(f) + .strip_prefix(&skill_dir) .ok() .map(|r| r.to_string_lossy().replace('\\', "/")) }) diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index 65f0caf03741..96cc8bc7d30c 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -7,8 +7,9 @@ pub mod client; pub use client::{SkillsClient, EXTENSION_NAME}; -use crate::agents::platform_extensions::{parse_frontmatter, Source, SourceKind}; +use crate::agents::platform_extensions::parse_frontmatter; use crate::config::paths::Paths; +use goose_sdk::custom_requests::{SourceEntry, SourceType}; use serde::Deserialize; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -61,7 +62,7 @@ pub fn all_skill_dirs(working_dir: Option<&Path>) -> Vec<(PathBuf, bool)> { dirs } -fn parse_skill_content(content: &str, path: PathBuf) -> Option { +fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option { let (metadata, body): (SkillFrontmatter, String) = match parse_frontmatter(content) { Ok(Some(parsed)) => parsed, Ok(None) => return None, @@ -78,12 +79,13 @@ fn parse_skill_content(content: &str, path: PathBuf) -> Option { return None; } - Some(Source { + Some(SourceEntry { + source_type: SourceType::Skill, name, - kind: SourceKind::Skill, description: metadata.description, - path, content: body, + directory: path.to_string_lossy().into_owned(), + global, supporting_files: Vec::new(), }) } @@ -130,7 +132,11 @@ fn walk_files_recursively( } } -fn scan_skills_from_dir(dir: &Path, seen: &mut HashSet) -> Vec { +fn scan_skills_from_dir( + dir: &Path, + global: bool, + seen: &mut HashSet, +) -> Vec { let mut skill_files = Vec::new(); let mut visited_dirs = HashSet::new(); @@ -158,7 +164,7 @@ fn scan_skills_from_dir(dir: &Path, seen: &mut HashSet) -> Vec { } }; - if let Some(mut source) = parse_skill_content(&content, skill_dir.to_path_buf()) { + 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(); @@ -168,7 +174,7 @@ fn scan_skills_from_dir(dir: &Path, seen: &mut HashSet) -> Vec { &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()); + files.push(path.to_string_lossy().into_owned()); } }, ); @@ -182,37 +188,27 @@ fn scan_skills_from_dir(dir: &Path, seen: &mut HashSet) -> Vec { sources } -pub fn discover_skills(working_dir: Option<&Path>) -> Vec { - discover_skills_with_scope(working_dir) - .into_iter() - .map(|(source, _)| source) - .collect() -} - -/// Discover skills and pair each with whether the directory it was found in is -/// a global (home-rooted) location. Built-in skills are reported with -/// `global = true`. -pub fn discover_skills_with_scope(working_dir: Option<&Path>) -> Vec<(Source, bool)> { - let mut sources: Vec<(Source, bool)> = Vec::new(); +/// 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 { + let mut sources: Vec = 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, &mut seen) { - sources.push((source, is_global)); + 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()) { + if let Some(source) = parse_skill_content(content, &PathBuf::new(), true) { if !seen.contains(&source.name) { seen.insert(source.name.clone()); - sources.push(( - Source { - kind: SourceKind::BuiltinSkill, - ..source - }, - true, - )); + sources.push(SourceEntry { + source_type: SourceType::BuiltinSkill, + ..source + }); } } } @@ -220,7 +216,7 @@ pub fn discover_skills_with_scope(working_dir: Option<&Path>) -> Vec<(Source, bo sources } -pub fn list_installed_skills(working_dir: Option<&Path>) -> Vec { +pub fn list_installed_skills(working_dir: Option<&Path>) -> Vec { let fallback; let wd = match working_dir { Some(p) => Some(p), diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index e9f55b0d5c23..643c5e657565 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -3,10 +3,8 @@ //! directory — `~/.agents/skills` for global sources and `/.goose/skills` //! for project-specific sources. -use crate::agents::platform_extensions::{parse_frontmatter, Source, SourceKind}; -use crate::skills::{ - discover_skills_with_scope, global_skills_dir, project_skills_dir, SkillFrontmatter, -}; +use crate::agents::platform_extensions::parse_frontmatter; +use crate::skills::{discover_skills, global_skills_dir, project_skills_dir, SkillFrontmatter}; use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use sacp::Error; @@ -42,6 +40,8 @@ fn source_base_dir( skills_dir_project_or_err(pd) } } + other => Err(Error::invalid_params() + .data(format!("Source type '{}' is not user-editable", other))), } } @@ -111,28 +111,10 @@ fn source_entry( content: content.to_string(), directory: dir.to_string_lossy().to_string(), global, + supporting_files: Vec::new(), } } -fn source_type_for(kind: SourceKind) -> Option { - match kind { - SourceKind::Skill => Some(SourceType::Skill), - _ => None, - } -} - -fn to_source_entry(source: &Source, global: bool) -> Option { - let source_type = source_type_for(source.kind)?; - Some(source_entry( - source_type, - &source.name, - &source.description, - &source.content, - &source.path, - global, - )) -} - pub fn create_source( source_type: SourceType, name: &str, @@ -219,24 +201,20 @@ pub fn list_sources( source_type: Option, project_dir: Option<&str>, ) -> Result, Error> { - let type_filter = source_type; - let working_dir = project_dir .map(str::trim) .filter(|p| !p.is_empty()) .map(PathBuf::from); - let mut sources: Vec = discover_skills_with_scope(working_dir.as_deref()) - .iter() - .filter_map(|(s, global)| { - let entry = to_source_entry(s, *global)?; - if let Some(t) = type_filter { - if entry.source_type != t { - return None; - } - } - Some(entry) - }) + // Today only SourceType::Skill flows through sources CRUD; built-in skills + // and summon-owned source types (recipes, agents, subrecipes) are excluded. + if matches!(source_type, Some(t) if t != SourceType::Skill) { + return Ok(Vec::new()); + } + + let mut sources: Vec = discover_skills(working_dir.as_deref()) + .into_iter() + .filter(|s| s.source_type == SourceType::Skill) .collect(); sources.sort_by(|a, b| a.name.cmp(&b.name)); @@ -263,6 +241,10 @@ pub fn export_source( let type_slug = match source_type { SourceType::Skill => "skill", + other => { + return Err(Error::invalid_params() + .data(format!("Source type '{}' cannot be exported", other))) + } }; let export = serde_json::json!({ "version": 1, From c757c432b2f53084eaf334ca7b0be1d9c7175d53 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 21 Apr 2026 15:24:34 -0400 Subject: [PATCH 05/20] move parse_frontmatter --- .../src/agents/platform_extensions/mod.rs | 16 ----------- .../src/agents/platform_extensions/summon.rs | 2 +- crates/goose/src/skills/mod.rs | 8 ++---- crates/goose/src/sources.rs | 28 +++++++++++++++---- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/crates/goose/src/agents/platform_extensions/mod.rs b/crates/goose/src/agents/platform_extensions/mod.rs index bc507a2a2b95..c68d19ee5f89 100644 --- a/crates/goose/src/agents/platform_extensions/mod.rs +++ b/crates/goose/src/agents/platform_extensions/mod.rs @@ -16,22 +16,6 @@ use std::collections::HashMap; use crate::agents::mcp_client::McpClientTrait; use crate::session::Session; use once_cell::sync::Lazy; -use serde::Deserialize; - -pub fn parse_frontmatter Deserialize<'de>>( - content: &str, -) -> Result, serde_yaml::Error> { - let parts: Vec<&str> = content.split("---").collect(); - if parts.len() < 3 { - return Ok(None); - } - - let yaml_content = parts[1].trim(); - let metadata: T = serde_yaml::from_str(yaml_content)?; - - let body = parts[2..].join("---").trim().to_string(); - Ok(Some((metadata, body))) -} pub use ext_manager::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE; diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 4967d65f3a95..bc2fca125bb5 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -1,4 +1,3 @@ -use super::parse_frontmatter; use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait}; use crate::agents::subagent_handler::{run_subagent_task, OnMessageCallback, SubagentRunParams}; @@ -13,6 +12,7 @@ use crate::recipe::local_recipes::load_local_recipe_file; use crate::recipe::{Recipe, Settings, RECIPE_FILE_EXTENSIONS}; use crate::session::extension_data::EnabledExtensionsState; use crate::session::SessionType; +use crate::sources::parse_frontmatter; use anyhow::Result; use async_trait::async_trait; use goose_sdk::custom_requests::{SourceEntry, SourceType}; diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index 96cc8bc7d30c..f45e4c42bd96 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -7,8 +7,8 @@ pub mod client; pub use client::{SkillsClient, EXTENSION_NAME}; -use crate::agents::platform_extensions::parse_frontmatter; use crate::config::paths::Paths; +use crate::sources::parse_frontmatter; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use serde::Deserialize; use std::collections::HashSet; @@ -132,11 +132,7 @@ fn walk_files_recursively( } } -fn scan_skills_from_dir( - dir: &Path, - global: bool, - seen: &mut HashSet, -) -> Vec { +fn scan_skills_from_dir(dir: &Path, global: bool, seen: &mut HashSet) -> Vec { let mut skill_files = Vec::new(); let mut visited_dirs = HashSet::new(); diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index 643c5e657565..2fe10227be01 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -3,13 +3,28 @@ //! directory — `~/.agents/skills` for global sources and `/.goose/skills` //! for project-specific sources. -use crate::agents::platform_extensions::parse_frontmatter; use crate::skills::{discover_skills, global_skills_dir, project_skills_dir, SkillFrontmatter}; use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use sacp::Error; +use serde::Deserialize; use std::path::{Path, PathBuf}; +pub fn parse_frontmatter Deserialize<'de>>( + content: &str, +) -> Result, serde_yaml::Error> { + let parts: Vec<&str> = content.split("---").collect(); + if parts.len() < 3 { + return Ok(None); + } + + let yaml_content = parts[1].trim(); + let metadata: T = serde_yaml::from_str(yaml_content)?; + + let body = parts[2..].join("---").trim().to_string(); + Ok(Some((metadata, body))) +} + fn skills_dir_global_or_err() -> Result { global_skills_dir() .ok_or_else(|| Error::internal_error().data("Could not determine home directory")) @@ -40,8 +55,10 @@ fn source_base_dir( skills_dir_project_or_err(pd) } } - other => Err(Error::invalid_params() - .data(format!("Source type '{}' is not user-editable", other))), + other => { + Err(Error::invalid_params() + .data(format!("Source type '{}' is not user-editable", other))) + } } } @@ -242,8 +259,9 @@ pub fn export_source( let type_slug = match source_type { SourceType::Skill => "skill", other => { - return Err(Error::invalid_params() - .data(format!("Source type '{}' cannot be exported", other))) + return Err( + Error::invalid_params().data(format!("Source type '{}' cannot be exported", other)) + ) } }; let export = serde_json::json!({ From e18f113dff2c7c8726a8eec42336090ab97f2c55 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 22 Apr 2026 10:12:38 -0400 Subject: [PATCH 06/20] paths are identifiers --- crates/goose-acp/src/server.rs | 6 +- crates/goose-sdk/src/custom_requests.rs | 6 +- crates/goose/src/skills/client.rs | 13 +- crates/goose/src/skills/mod.rs | 119 +++++++++- crates/goose/src/sources.rs | 284 ++++++++++++------------ 5 files changed, 264 insertions(+), 164 deletions(-) diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 09d483a2c56a..55bc62583870 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -3147,7 +3147,7 @@ impl GooseAcpAgent { ) -> Result { let source = goose::sources::update_source( req.source_type, - &req.name, + &req.path, &req.description, &req.content, req.global, @@ -3163,7 +3163,7 @@ impl GooseAcpAgent { ) -> Result { goose::sources::delete_source( req.source_type, - &req.name, + &req.path, req.global, req.project_dir.as_deref(), )?; @@ -3177,7 +3177,7 @@ impl GooseAcpAgent { ) -> Result { let (json, filename) = goose::sources::export_source( req.source_type, - &req.name, + &req.path, req.global, req.project_dir.as_deref(), )?; diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 94f80d8453d9..6a6aee81feee 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -403,7 +403,7 @@ pub struct ListSourcesResponse { pub struct UpdateSourceRequest { #[serde(rename = "type")] pub source_type: SourceType, - pub name: String, + pub path: String, pub description: String, pub content: String, pub global: bool, @@ -424,7 +424,7 @@ pub struct UpdateSourceResponse { pub struct DeleteSourceRequest { #[serde(rename = "type")] 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, @@ -437,7 +437,7 @@ pub struct DeleteSourceRequest { pub struct ExportSourceRequest { #[serde(rename = "type")] 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, diff --git a/crates/goose/src/skills/client.rs b/crates/goose/src/skills/client.rs index 8d1625eeb423..c7f82af310a6 100644 --- a/crates/goose/src/skills/client.rs +++ b/crates/goose/src/skills/client.rs @@ -33,8 +33,7 @@ impl SkillsClient { let mut skills: Vec<&SourceEntry> = sources .iter() .filter(|s| { - s.source_type == SourceType::Skill - || s.source_type == SourceType::BuiltinSkill + s.source_type == SourceType::Skill || s.source_type == SourceType::BuiltinSkill }) .collect(); skills.sort_by(|a, b| (&a.name, &a.directory).cmp(&(&b.name, &b.directory))); @@ -156,14 +155,12 @@ impl McpClientTrait for SkillsClient { let relative_path = raw_relative_path.replace('\\', "/"); if let Some(skill) = skills.iter().find(|s| { s.name == parent_skill_name - && matches!( - s.source_type, - SourceType::Skill | SourceType::BuiltinSkill - ) + && matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill) }) { let skill_dir = PathBuf::from(&skill.directory); - let canonical_skill_dir = - skill_dir.canonicalize().unwrap_or_else(|_| skill_dir.clone()); + let canonical_skill_dir = skill_dir + .canonicalize() + .unwrap_or_else(|_| skill_dir.clone()); for file_path in &skill.supporting_files { let file_path_buf = Path::new(file_path); diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index f45e4c42bd96..3f4c2efad2bb 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -10,14 +10,12 @@ 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; -/// Shared YAML frontmatter shape for `SKILL.md` files. `name` is optional at -/// the parser level so callers can decide whether to require it (runtime -/// discovery does; source CRUD uses the directory name instead). #[derive(Debug, Deserialize)] pub struct SkillFrontmatter { #[serde(default)] @@ -37,6 +35,110 @@ pub fn project_skills_dir(project_dir: &Path) -> PathBuf { project_dir.join(".goose").join("skills") } +pub(crate) fn skills_dir_global_or_err() -> Result { + 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 { + 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 { + 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(()) +} + +pub(crate) fn resolve_skill_dir( + path: &str, + global: bool, + project_dir: Option<&str>, +) -> Result { + if path.is_empty() { + return Err(Error::invalid_params().data("Source path must not be empty")); + } + let dir = skill_base_dir(global, project_dir)?.join(path); + if !dir.exists() { + return Err(Error::invalid_params().data(format!("Source \"{}\" not found", path))); + } + Ok(dir) +} + +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::(&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::(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. @@ -72,7 +174,16 @@ fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option 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); diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index 2fe10227be01..db32411b386e 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -1,9 +1,9 @@ //! 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 `/.goose/skills` -//! for project-specific sources. -use crate::skills::{discover_skills, global_skills_dir, project_skills_dir, SkillFrontmatter}; +use crate::skills::{ + build_skill_md, discover_skills, infer_skill_name, parse_skill_frontmatter, resolve_skill_dir, + skill_base_dir, validate_skill_name, +}; use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use sacp::Error; @@ -25,94 +25,16 @@ pub fn parse_frontmatter Deserialize<'de>>( Ok(Some((metadata, body))) } -fn skills_dir_global_or_err() -> Result { - global_skills_dir() - .ok_or_else(|| Error::internal_error().data("Could not determine home directory")) -} - -fn skills_dir_project_or_err(project_dir: &str) -> Result { - 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))) -} - -fn source_base_dir( - source_type: SourceType, - global: bool, - project_dir: Option<&str>, -) -> Result { - match source_type { - SourceType::Skill => { - 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) - } - } - other => { - Err(Error::invalid_params() - .data(format!("Source type '{}' is not user-editable", other))) - } - } -} - -/// 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 { +fn require_skill_type(source_type: SourceType) -> Result<(), Error> { + if source_type != SourceType::Skill { return Err(Error::invalid_params().data(format!( - "Invalid source name \"{}\". Names must not end with a hyphen.", - name + "Source type '{}' is not supported. Only 'skill' is currently supported.", + source_type ))); } 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::(raw) { - Ok(Some((meta, body))) => (meta.description, body), - _ => (String::new(), raw.to_string()), - } -} - fn source_entry( source_type: SourceType, name: &str, @@ -140,8 +62,9 @@ pub fn create_source( global: bool, project_dir: Option<&str>, ) -> Result { - validate_source_name(name)?; - let dir = source_base_dir(source_type, global, project_dir)?.join(name); + require_skill_type(source_type)?; + validate_skill_name(name)?; + let dir = skill_base_dir(global, project_dir)?.join(name); if dir.exists() { return Err( @@ -169,27 +92,24 @@ pub fn create_source( pub fn update_source( source_type: SourceType, - name: &str, + path: &str, description: &str, content: &str, global: bool, project_dir: Option<&str>, ) -> Result { - 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))); - } + require_skill_type(source_type)?; + let dir = resolve_skill_dir(path, global, project_dir)?; + let name = infer_skill_name(&dir); let file_path = 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) .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; Ok(source_entry( source_type, - name, + &name, description, content, &dir, @@ -199,16 +119,12 @@ pub fn update_source( pub fn delete_source( source_type: SourceType, - name: &str, + path: &str, 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))); - } + require_skill_type(source_type)?; + let dir = resolve_skill_dir(path, global, project_dir)?; fs::remove_dir_all(&dir) .map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?; Ok(()) @@ -218,17 +134,15 @@ pub fn list_sources( source_type: Option, project_dir: Option<&str>, ) -> Result, Error> { + if let Some(t) = source_type { + require_skill_type(t)?; + } + let working_dir = project_dir .map(str::trim) .filter(|p| !p.is_empty()) .map(PathBuf::from); - // Today only SourceType::Skill flows through sources CRUD; built-in skills - // and summon-owned source types (recipes, agents, subrecipes) are excluded. - if matches!(source_type, Some(t) if t != SourceType::Skill) { - return Ok(Vec::new()); - } - let mut sources: Vec = discover_skills(working_dir.as_deref()) .into_iter() .filter(|s| s.source_type == SourceType::Skill) @@ -240,40 +154,30 @@ pub fn list_sources( pub fn export_source( source_type: SourceType, - name: &str, + path: &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))); - } + require_skill_type(source_type)?; + let dir = resolve_skill_dir(path, global, project_dir)?; let md = dir.join("SKILL.md"); let raw = fs::read_to_string(&md) .map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?; let (description, content) = parse_skill_frontmatter(&raw); - let type_slug = match source_type { - SourceType::Skill => "skill", - other => { - return Err( - Error::invalid_params().data(format!("Source type '{}' cannot be exported", other)) - ) - } - }; + let name = infer_skill_name(&dir); + let export = serde_json::json!({ "version": 1, - "type": type_slug, + "type": "skill", "name": name, "description": description, "content": content, }); let json = serde_json::to_string_pretty(&export) .map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?; - let filename = format!("{}.{}.json", name, type_slug); + let filename = format!("{}.skill.json", name); Ok((json, filename)) } @@ -295,15 +199,17 @@ pub fn import_sources( ); } - // Default to `skill` to preserve compatibility with pre-sources skill exports. - let source_type = match value + match value .get("type") .and_then(|v| v.as_str()) .unwrap_or("skill") { - "skill" => SourceType::Skill, + "skill" => {} 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 + ))); } }; @@ -325,7 +231,6 @@ pub fn import_sources( 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 .get("content") .or_else(|| value.get("instructions")) @@ -333,9 +238,9 @@ pub fn import_sources( .unwrap_or("") .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(); if base.join(&final_name).exists() { final_name = format!("{}-imported", name); @@ -356,7 +261,7 @@ pub fn import_sources( .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; Ok(vec![source_entry( - source_type, + SourceType::Skill, &final_name, &description, &content, @@ -371,15 +276,17 @@ mod tests { use tempfile::TempDir; #[test] - fn kebab_case_validation() { - assert!(validate_source_name("my-skill").is_ok()); - assert!(validate_source_name("abc123").is_ok()); - assert!(validate_source_name("").is_err()); - assert!(validate_source_name("-leading").is_err()); - assert!(validate_source_name("trailing-").is_err()); - assert!(validate_source_name("double--hyphen").is_err()); - assert!(validate_source_name("CAPS").is_err()); - assert!(validate_source_name("../escape").is_err()); + fn skill_name_validation() { + assert!(validate_skill_name("my-skill").is_ok()); + assert!(validate_skill_name("abc123").is_ok()); + assert!(validate_skill_name("double--hyphen").is_ok()); + assert!(validate_skill_name("").is_err()); + assert!(validate_skill_name("-leading").is_err()); + assert!(validate_skill_name("trailing-").is_err()); + assert!(validate_skill_name("CAPS").is_err()); + assert!(validate_skill_name("../escape").is_err()); + assert!(validate_skill_name(&"a".repeat(64)).is_ok()); + assert!(validate_skill_name(&"a".repeat(65)).is_err()); } #[test] @@ -398,7 +305,8 @@ mod tests { .unwrap(); assert_eq!(created.name, "my-skill"); 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(); assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global)); @@ -413,9 +321,10 @@ mod tests { ) .unwrap(); 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(); - assert!(!PathBuf::from(&created.directory).exists()); + assert!(!dir.exists()); } #[test] @@ -487,4 +396,87 @@ mod tests { let imported = import_sources(&payload, false, Some(project)).unwrap(); assert_eq!(imported[0].name, "busy-imported"); } + + #[test] + fn update_rejects_nonexistent_source() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + let err = update_source( + SourceType::Skill, + "no-such-skill", + "d", + "c", + false, + Some(project), + ) + .unwrap_err(); + assert!(format!("{:?}", err).contains("not found")); + } + + #[test] + fn delete_rejects_nonexistent_source() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + let err = + delete_source(SourceType::Skill, "no-such-skill", false, Some(project)).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", "d", "c", false, Some(project)).unwrap_err(); + assert!(format!("{:?}", err).contains("not supported")); + + let err = delete_source(SourceType::Subrecipe, "x", false, Some(project)).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", false, Some(project)).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 updated = update_source( + SourceType::Skill, + "my-dir", + "new description", + "new body", + false, + Some(project), + ) + .unwrap(); + // Name is derived from the frontmatter written by create_source + assert_eq!(updated.name, "my-dir"); + } } From bc6808560a90edaf14d9d68ba6987ad0dd553862 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 22 Apr 2026 13:41:33 -0400 Subject: [PATCH 07/20] merge and schema --- crates/goose-cli/src/session/completion.rs | 2 +- crates/goose-cli/src/session/mod.rs | 2 +- crates/goose-sdk/src/custom_requests.rs | 7 +- crates/goose/acp-schema.json | 34 +++++-- .../src/agents/platform_extensions/summon.rs | 3 + crates/goose/src/skills/mod.rs | 47 +++++++++- crates/goose/src/sources.rs | 94 ++++++++++++++++--- ui/sdk/src/generated/types.gen.ts | 24 +++-- ui/sdk/src/generated/zod.gen.ts | 22 +++-- 9 files changed, 194 insertions(+), 41 deletions(-) diff --git a/crates/goose-cli/src/session/completion.rs b/crates/goose-cli/src/session/completion.rs index 9b766f36c916..f9c3d2bf6795 100644 --- a/crates/goose-cli/src/session/completion.rs +++ b/crates/goose-cli/src/session/completion.rs @@ -123,7 +123,7 @@ impl GooseCompleter { /// Complete skill names for the /skills command fn complete_skill_names(&self, line: &str) -> Result<(usize, Vec)> { - 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 skills = list_installed_skills(Some(&cwd)); diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 493c54481343..4deb36ae3d14 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -907,7 +907,7 @@ impl CliSession { async fn handle_list_skills(&mut self) -> Result<()> { 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 skills = list_installed_skills(Some(&cwd)); diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 6a6dc1222369..6925f8bd5a89 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -279,8 +279,8 @@ impl std::fmt::Display for SourceType { } } -/// A source — a user-editable entity backed by an on-disk path. Sources may -/// be either `global` (shared across all projects) or project-specific. +/// 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)] #[serde(rename_all = "camelCase")] pub struct SourceEntry { @@ -295,6 +295,9 @@ pub struct SourceEntry { /// True when the source lives in the user's global sources directory; false /// when it lives inside a specific project. pub global: bool, + /// Whether this source can be modified through Goose's source CRUD APIs. + #[serde(default)] + pub editable: 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")] diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index cb6d121998c5..d705f0c58a73 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -757,7 +757,11 @@ "SourceType": { "type": "string", "enum": [ - "skill" + "skill", + "builtinSkill", + "recipe", + "subrecipe", + "agent" ], "description": "The type of source entity." }, @@ -791,11 +795,23 @@ }, "directory": { "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": { "type": "boolean", "description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project." + }, + "editable": { + "type": "boolean", + "description": "Whether this source can be modified through Goose's source CRUD APIs.", + "default": false + }, + "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": [ @@ -806,7 +822,7 @@ "directory", "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": { "type": "object", @@ -854,7 +870,7 @@ "type": { "$ref": "#/$defs/SourceType" }, - "name": { + "path": { "type": "string" }, "description": { @@ -875,7 +891,7 @@ }, "required": [ "type", - "name", + "path", "description", "content", "global" @@ -903,7 +919,7 @@ "type": { "$ref": "#/$defs/SourceType" }, - "name": { + "path": { "type": "string" }, "global": { @@ -918,7 +934,7 @@ }, "required": [ "type", - "name", + "path", "global" ], "description": "Delete a source and its on-disk directory.", @@ -931,7 +947,7 @@ "type": { "$ref": "#/$defs/SourceType" }, - "name": { + "path": { "type": "string" }, "global": { @@ -946,7 +962,7 @@ }, "required": [ "type", - "name", + "path", "global" ], "description": "Export a source as a portable JSON payload.", diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index bc2fca125bb5..38dae12fefee 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -127,6 +127,7 @@ fn parse_agent_content(content: &str, path: &Path) -> Option { content: body, directory: path.to_string_lossy().into_owned(), global: false, + editable: false, supporting_files: Vec::new(), }) } @@ -173,6 +174,7 @@ fn scan_recipes_from_dir( content: recipe.instructions.clone().unwrap_or_default(), directory: path.to_string_lossy().into_owned(), global: false, + editable: false, supporting_files: Vec::new(), }); } @@ -598,6 +600,7 @@ impl SummonClient { content: String::new(), directory: sr.path.clone(), global: false, + editable: false, supporting_files: Vec::new(), }); } diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index 3f4c2efad2bb..5fbb97841163 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -96,11 +96,34 @@ pub(crate) fn resolve_skill_dir( if path.is_empty() { return Err(Error::invalid_params().data("Source path must not be empty")); } - let dir = skill_base_dir(global, project_dir)?.join(path); - if !dir.exists() { + + let base_dir = skill_base_dir(global, project_dir)?; + let joined_dir = base_dir.join(path); + let canonical_dir = joined_dir + .canonicalize() + .map_err(|_| Error::invalid_params().data(format!("Source \"{}\" not found", path)))?; + let canonical_base_dir = base_dir.canonicalize().unwrap_or_else(|_| base_dir.clone()); + + if !canonical_dir.starts_with(&canonical_base_dir) { + return Err(Error::invalid_params().data(format!("Source \"{}\" not found", path))); + } + + if !canonical_dir.is_dir() || !canonical_dir.join("SKILL.md").is_file() { return Err(Error::invalid_params().data(format!("Source \"{}\" not found", path))); } - Ok(dir) + + Ok(canonical_dir) +} + +pub(crate) fn is_editable_skill_dir(path: &Path, working_dir: Option<&Path>) -> bool { + let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + + editable_skill_dirs(working_dir) + .into_iter() + .any(|(dir, _)| { + let editable_dir = dir.canonicalize().unwrap_or(dir); + canonical_path.starts_with(editable_dir) + }) } pub(crate) fn infer_skill_name(dir: &Path) -> String { @@ -139,6 +162,23 @@ pub(crate) fn parse_skill_frontmatter(raw: &str) -> (String, 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 editable_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)); + } + + if let Some(h) = dirs::home_dir() { + dirs.push((h.join(".agents").join("skills"), true)); + } + + dirs +} + /// 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. @@ -197,6 +237,7 @@ fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option SourceEntry { + source.editable = editable; + source +} + pub fn parse_frontmatter Deserialize<'de>>( content: &str, ) -> Result, serde_yaml::Error> { @@ -42,16 +47,21 @@ fn source_entry( content: &str, dir: &Path, global: bool, + editable: bool, ) -> SourceEntry { - SourceEntry { - source_type, - name: name.to_string(), - description: description.to_string(), - content: content.to_string(), - directory: dir.to_string_lossy().to_string(), - global, - supporting_files: Vec::new(), - } + with_editable( + SourceEntry { + source_type, + name: name.to_string(), + description: description.to_string(), + content: content.to_string(), + directory: dir.to_string_lossy().to_string(), + global, + editable: false, + supporting_files: Vec::new(), + }, + editable, + ) } pub fn create_source( @@ -87,6 +97,7 @@ pub fn create_source( content, &dir, global, + true, )) } @@ -114,6 +125,7 @@ pub fn update_source( content, &dir, global, + true, )) } @@ -146,6 +158,10 @@ pub fn list_sources( let mut sources: Vec = discover_skills(working_dir.as_deref()) .into_iter() .filter(|s| s.source_type == SourceType::Skill) + .map(|s| { + let editable = is_editable_skill_dir(Path::new(&s.directory), working_dir.as_deref()); + with_editable(s, editable) + }) .collect(); sources.sort_by(|a, b| a.name.cmp(&b.name)); @@ -267,6 +283,7 @@ pub fn import_sources( &content, &dir, global, + true, )]) } @@ -479,4 +496,59 @@ mod tests { // 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 err = update_source( + SourceType::Skill, + "../escaped", + "new description", + "new content", + false, + Some(project.to_str().unwrap()), + ) + .unwrap_err(); + assert!(format!("{:?}", err).contains("not found")); + } + + #[test] + fn list_sources_marks_only_editable_paths_editable() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path(); + + let goose_skill = project.join(".goose").join("skills").join("goose-skill"); + std::fs::create_dir_all(&goose_skill).unwrap(); + std::fs::write( + goose_skill.join("SKILL.md"), + "---\nname: goose-skill\ndescription: Goose skill\n---\ncontent", + ) + .unwrap(); + + let claude_skill = project.join(".claude").join("skills").join("claude-skill"); + std::fs::create_dir_all(&claude_skill).unwrap(); + std::fs::write( + claude_skill.join("SKILL.md"), + "---\nname: claude-skill\ndescription: Claude skill\n---\ncontent", + ) + .unwrap(); + + let listed = + list_sources(Some(SourceType::Skill), Some(project.to_str().unwrap())).unwrap(); + + let goose_skill = listed.iter().find(|s| s.name == "goose-skill").unwrap(); + assert!(goose_skill.editable); + + let claude_skill = listed.iter().find(|s| s.name == "claude-skill").unwrap(); + assert!(!claude_skill.editable); + } } diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 14800f9850e0..63aed51e1f1b 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -391,15 +391,15 @@ export type CreateSourceRequest = { /** * The type of source entity. */ -export type SourceType = 'skill'; +export type SourceType = 'skill' | 'builtinSkill' | 'recipe' | 'subrecipe' | 'agent'; export type CreateSourceResponse = { source: SourceEntry; }; /** - * A source — a user-editable entity backed by an on-disk directory. Sources - * may be either `global` (shared across all projects) or project-specific. + * A source discovered by Goose and backed by an on-disk path. Sources may be + * either `global` (shared across all projects) or project-specific. */ export type SourceEntry = { type: SourceType; @@ -407,7 +407,8 @@ export type SourceEntry = { description: 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; /** @@ -415,6 +416,15 @@ export type SourceEntry = { * when it lives inside a specific project. */ global: boolean; + /** + * Whether this source can be modified through Goose's source CRUD APIs. + */ + editable?: boolean; + /** + * Paths (absolute) of additional files that live alongside the source. + * Only skills currently populate this; empty for other source types. + */ + supportingFiles?: Array; }; /** @@ -435,7 +445,7 @@ export type ListSourcesResponse = { */ export type UpdateSourceRequest = { type: SourceType; - name: string; + path: string; description: string; content: string; global: boolean; @@ -451,7 +461,7 @@ export type UpdateSourceResponse = { */ export type DeleteSourceRequest = { type: SourceType; - name: string; + path: string; global: boolean; projectDir?: string | null; }; @@ -461,7 +471,7 @@ export type DeleteSourceRequest = { */ export type ExportSourceRequest = { type: SourceType; - name: string; + path: string; global: boolean; projectDir?: string | null; }; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 3a6be88b13c4..f6ab5b5a6d1b 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -321,7 +321,13 @@ export const zUnarchiveSessionRequest = z.object({ /** * 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). @@ -339,8 +345,8 @@ export const zCreateSourceRequest = z.object({ }); /** - * A source — a user-editable entity backed by an on-disk directory. Sources - * may be either `global` (shared across all projects) or project-specific. + * A source discovered by Goose and backed by an on-disk path. Sources may be + * either `global` (shared across all projects) or project-specific. */ export const zSourceEntry = z.object({ type: zSourceType, @@ -348,7 +354,9 @@ export const zSourceEntry = z.object({ description: z.string(), content: z.string(), directory: z.string(), - global: z.boolean() + global: z.boolean(), + editable: z.boolean().optional().default(false), + supportingFiles: z.array(z.string()).optional() }); export const zCreateSourceResponse = z.object({ @@ -379,7 +387,7 @@ export const zListSourcesResponse = z.object({ */ export const zUpdateSourceRequest = z.object({ type: zSourceType, - name: z.string(), + path: z.string(), description: z.string(), content: z.string(), global: z.boolean(), @@ -398,7 +406,7 @@ export const zUpdateSourceResponse = z.object({ */ export const zDeleteSourceRequest = z.object({ type: zSourceType, - name: z.string(), + path: z.string(), global: z.boolean(), projectDir: z.union([ z.string(), @@ -411,7 +419,7 @@ export const zDeleteSourceRequest = z.object({ */ export const zExportSourceRequest = z.object({ type: zSourceType, - name: z.string(), + path: z.string(), global: z.boolean(), projectDir: z.union([ z.string(), From 76f9ed492518faa3a7295a351ce81a738e189392 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 22 Apr 2026 14:19:16 -0400 Subject: [PATCH 08/20] update callers to use path --- crates/goose-sdk/src/custom_requests.rs | 7 +- crates/goose/acp-schema.json | 394 +++------- ui/goose2/AGENTS.md | 2 +- ui/goose2/src/features/skills/api/skills.ts | 12 +- .../features/skills/ui/CreateSkillDialog.tsx | 9 +- .../src/features/skills/ui/SkillsView.tsx | 8 +- .../ui/__tests__/CreateSkillDialog.test.tsx | 5 +- ui/goose2/tests/e2e/fixtures/tauri-mock.ts | 16 +- ui/sdk/src/generated/types.gen.ts | 671 ++++++++++-------- ui/sdk/src/generated/zod.gen.ts | 566 +++++++-------- 10 files changed, 738 insertions(+), 952 deletions(-) diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 6925f8bd5a89..f22447424c23 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -338,8 +338,11 @@ pub struct CreateSourceResponse { pub source: SourceEntry, } -/// List sources. If `type` is omitted, sources of all known types are returned. -/// Both global and project-scoped sources are included when `project_dir` is set. +/// List discovered sources. +/// +/// 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)] #[request(method = "_goose/sources/list", response = ListSourcesResponse)] #[serde(rename_all = "camelCase")] diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index d705f0c58a73..083e0742a3b4 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -13,9 +13,7 @@ "default": null } }, - "required": [ - "sessionId" - ], + "required": ["sessionId"], "description": "Add an extension to an active session.", "x-side": "agent", "x-method": "_goose/extensions/add" @@ -35,10 +33,7 @@ "type": "string" } }, - "required": [ - "sessionId", - "name" - ], + "required": ["sessionId", "name"], "description": "Remove an extension from an active session.", "x-side": "agent", "x-method": "_goose/extensions/remove" @@ -50,9 +45,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ], + "required": ["sessionId"], "description": "List all tools available in a session.", "x-side": "agent", "x-method": "_goose/tools" @@ -66,9 +59,7 @@ "description": "Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`." } }, - "required": [ - "tools" - ], + "required": ["tools"], "description": "Tools response.", "x-side": "agent", "x-method": "_goose/tools" @@ -86,11 +77,7 @@ "type": "string" } }, - "required": [ - "sessionId", - "uri", - "extensionName" - ], + "required": ["sessionId", "uri", "extensionName"], "description": "Read a resource from an extension.", "x-side": "agent", "x-method": "_goose/resource/read" @@ -117,10 +104,7 @@ "type": "string" } }, - "required": [ - "sessionId", - "workingDir" - ], + "required": ["sessionId", "workingDir"], "description": "Update the working directory for a session.", "x-side": "agent", "x-method": "_goose/working_dir/update" @@ -132,9 +116,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ], + "required": ["sessionId"], "description": "Delete a session.", "x-side": "agent", "x-method": "session/delete" @@ -160,10 +142,7 @@ } } }, - "required": [ - "extensions", - "warnings" - ], + "required": ["extensions", "warnings"], "description": "List configured extensions and any warnings.", "x-side": "agent", "x-method": "_goose/config/extensions" @@ -175,9 +154,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ], + "required": ["sessionId"], "x-side": "agent", "x-method": "_goose/session/extensions" }, @@ -189,9 +166,7 @@ "items": {} } }, - "required": [ - "extensions" - ], + "required": ["extensions"], "x-side": "agent", "x-method": "_goose/session/extensions" }, @@ -221,9 +196,7 @@ } } }, - "required": [ - "entries" - ], + "required": ["entries"], "description": "Provider list response.", "x-side": "agent", "x-method": "_goose/providers/list" @@ -285,24 +258,15 @@ "description": "The list of available models." }, "lastUpdatedAt": { - "type": [ - "string", - "null" - ], + "type": ["string", "null"], "description": "When this entry was last successfully refreshed (ISO 8601)." }, "lastRefreshAttemptAt": { - "type": [ - "string", - "null" - ], + "type": ["string", "null"], "description": "When a refresh was most recently attempted (ISO 8601)." }, "lastRefreshError": { - "type": [ - "string", - "null" - ], + "type": ["string", "null"], "description": "The last refresh failure message, if any." }, "stale": { @@ -310,10 +274,7 @@ "description": "Whether we believe this data may be outdated." }, "modelSelectionHint": { - "type": [ - "string", - "null" - ], + "type": ["string", "null"], "description": "Guidance message shown when this provider manages its own model selection externally." } }, @@ -346,10 +307,7 @@ "type": "boolean" }, "default": { - "type": [ - "string", - "null" - ], + "type": ["string", "null"], "default": null }, "oauthFlow": { @@ -365,11 +323,7 @@ "default": false } }, - "required": [ - "name", - "required", - "secret" - ] + "required": ["name", "required", "secret"] }, "ProviderInventoryModelDto": { "type": "object", @@ -383,26 +337,17 @@ "description": "Human-readable display name." }, "family": { - "type": [ - "string", - "null" - ], + "type": ["string", "null"], "description": "Model family for grouping in UI." }, "contextLimit": { - "type": [ - "integer", - "null" - ], + "type": ["integer", "null"], "format": "uint", "minimum": 0, "description": "Context window size in tokens." }, "reasoning": { - "type": [ - "boolean", - "null" - ], + "type": ["boolean", "null"], "description": "Whether the model supports reasoning/extended thinking." }, "recommended": { @@ -411,10 +356,7 @@ "default": false } }, - "required": [ - "id", - "name" - ], + "required": ["id", "name"], "description": "A single model in provider inventory." }, "RefreshProviderInventoryRequest": { @@ -452,9 +394,7 @@ "default": [] } }, - "required": [ - "started" - ], + "required": ["started"], "description": "Refresh acknowledgement.", "x-side": "agent", "x-method": "_goose/providers/inventory/refresh" @@ -469,10 +409,7 @@ "$ref": "#/$defs/RefreshProviderInventorySkipReasonDto" } }, - "required": [ - "providerId", - "reason" - ] + "required": ["providerId", "reason"] }, "RefreshProviderInventorySkipReasonDto": { "type": "string", @@ -490,9 +427,7 @@ "type": "string" } }, - "required": [ - "key" - ], + "required": ["key"], "description": "Read a single non-secret config value.", "x-side": "agent", "x-method": "_goose/config/read" @@ -516,10 +451,7 @@ }, "value": {} }, - "required": [ - "key", - "value" - ], + "required": ["key", "value"], "description": "Upsert a single non-secret config value.", "x-side": "agent", "x-method": "_goose/config/upsert" @@ -531,9 +463,7 @@ "type": "string" } }, - "required": [ - "key" - ], + "required": ["key"], "description": "Remove a single non-secret config value.", "x-side": "agent", "x-method": "_goose/config/remove" @@ -545,9 +475,7 @@ "type": "string" } }, - "required": [ - "key" - ], + "required": ["key"], "description": "Check whether a secret exists. Never returns the actual value.", "x-side": "agent", "x-method": "_goose/secret/check" @@ -559,9 +487,7 @@ "type": "boolean" } }, - "required": [ - "exists" - ], + "required": ["exists"], "description": "Secret check response.", "x-side": "agent", "x-method": "_goose/secret/check" @@ -574,10 +500,7 @@ }, "value": {} }, - "required": [ - "key", - "value" - ], + "required": ["key", "value"], "description": "Set a secret value (write-only).", "x-side": "agent", "x-method": "_goose/secret/upsert" @@ -589,9 +512,7 @@ "type": "string" } }, - "required": [ - "key" - ], + "required": ["key"], "description": "Remove a secret.", "x-side": "agent", "x-method": "_goose/secret/remove" @@ -603,9 +524,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ], + "required": ["sessionId"], "description": "Export a session as a JSON string.", "x-side": "agent", "x-method": "_goose/session/export" @@ -617,9 +536,7 @@ "type": "string" } }, - "required": [ - "data" - ], + "required": ["data"], "description": "Export session response — raw JSON of the goose session with `conversation`.", "x-side": "agent", "x-method": "_goose/session/export" @@ -631,9 +548,7 @@ "type": "string" } }, - "required": [ - "data" - ], + "required": ["data"], "description": "Import a session from a JSON string.", "x-side": "agent", "x-method": "_goose/session/import" @@ -645,26 +560,17 @@ "type": "string" }, "title": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "updatedAt": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "messageCount": { "type": "integer", "minimum": 0 } }, - "required": [ - "sessionId", - "messageCount" - ], + "required": ["sessionId", "messageCount"], "description": "Import session response — metadata about the newly created session.", "x-side": "agent", "x-method": "_goose/session/import" @@ -676,15 +582,10 @@ "type": "string" }, "projectId": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } }, - "required": [ - "sessionId" - ], + "required": ["sessionId"], "description": "Update the project association for a session.", "x-side": "agent", "x-method": "_goose/session/update_project" @@ -696,9 +597,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ], + "required": ["sessionId"], "description": "Archive a session (soft delete).", "x-side": "agent", "x-method": "_goose/session/archive" @@ -710,9 +609,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ], + "required": ["sessionId"], "description": "Unarchive a previously archived session.", "x-side": "agent", "x-method": "_goose/session/unarchive" @@ -736,33 +633,18 @@ "type": "boolean" }, "projectDir": { - "type": [ - "string", - "null" - ], + "type": ["string", "null"], "description": "Absolute path to the project root. Required when `global` is false." } }, - "required": [ - "type", - "name", - "description", - "content", - "global" - ], + "required": ["type", "name", "description", "content", "global"], "description": "Create a new source (global or project-scoped).", "x-side": "agent", "x-method": "_goose/sources/create" }, "SourceType": { "type": "string", - "enum": [ - "skill", - "builtinSkill", - "recipe", - "subrecipe", - "agent" - ], + "enum": ["skill", "builtinSkill", "recipe", "subrecipe", "agent"], "description": "The type of source entity." }, "CreateSourceResponse": { @@ -772,9 +654,7 @@ "$ref": "#/$defs/SourceEntry" } }, - "required": [ - "source" - ], + "required": ["source"], "x-side": "agent", "x-method": "_goose/sources/create" }, @@ -838,13 +718,10 @@ ] }, "projectDir": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } }, - "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-method": "_goose/sources/list" }, @@ -858,9 +735,7 @@ } } }, - "required": [ - "sources" - ], + "required": ["sources"], "x-side": "agent", "x-method": "_goose/sources/list" }, @@ -883,19 +758,10 @@ "type": "boolean" }, "projectDir": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } }, - "required": [ - "type", - "path", - "description", - "content", - "global" - ], + "required": ["type", "path", "description", "content", "global"], "description": "Update an existing source's description and content.", "x-side": "agent", "x-method": "_goose/sources/update" @@ -907,9 +773,7 @@ "$ref": "#/$defs/SourceEntry" } }, - "required": [ - "source" - ], + "required": ["source"], "x-side": "agent", "x-method": "_goose/sources/update" }, @@ -926,17 +790,10 @@ "type": "boolean" }, "projectDir": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } }, - "required": [ - "type", - "path", - "global" - ], + "required": ["type", "path", "global"], "description": "Delete a source and its on-disk directory.", "x-side": "agent", "x-method": "_goose/sources/delete" @@ -954,17 +811,10 @@ "type": "boolean" }, "projectDir": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } }, - "required": [ - "type", - "path", - "global" - ], + "required": ["type", "path", "global"], "description": "Export a source as a portable JSON payload.", "x-side": "agent", "x-method": "_goose/sources/export" @@ -979,10 +829,7 @@ "type": "string" } }, - "required": [ - "json", - "filename" - ], + "required": ["json", "filename"], "x-side": "agent", "x-method": "_goose/sources/export" }, @@ -996,16 +843,10 @@ "type": "boolean" }, "projectDir": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } }, - "required": [ - "data", - "global" - ], + "required": ["data", "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.", "x-side": "agent", "x-method": "_goose/sources/import" @@ -1020,9 +861,7 @@ } } }, - "required": [ - "sources" - ], + "required": ["sources"], "x-side": "agent", "x-method": "_goose/sources/import" }, @@ -1042,11 +881,7 @@ "description": "Provider to use: \"openai\", \"groq\", \"elevenlabs\", or \"local\"" } }, - "required": [ - "audio", - "mimeType", - "provider" - ], + "required": ["audio", "mimeType", "provider"], "description": "Transcribe audio via a dictation provider.", "x-side": "agent", "x-method": "_goose/dictation/transcribe" @@ -1058,9 +893,7 @@ "type": "string" } }, - "required": [ - "text" - ], + "required": ["text"], "description": "Transcription result.", "x-side": "agent", "x-method": "_goose/dictation/transcribe" @@ -1081,9 +914,7 @@ } } }, - "required": [ - "providers" - ], + "required": ["providers"], "description": "Dictation config response — map of provider name to status.", "x-side": "agent", "x-method": "_goose/dictation/config" @@ -1095,10 +926,7 @@ "type": "boolean" }, "host": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "description": { "type": "string" @@ -1107,34 +935,19 @@ "type": "boolean" }, "settingsPath": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "configKey": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "modelConfigKey": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "defaultModel": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "selectedModel": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "availableModels": { "type": "array", @@ -1144,11 +957,7 @@ "default": [] } }, - "required": [ - "configured", - "description", - "usesProviderConfig" - ], + "required": ["configured", "description", "usesProviderConfig"], "description": "Per-provider configuration status." }, "DictationModelOption": { @@ -1164,11 +973,7 @@ "type": "string" } }, - "required": [ - "id", - "label", - "description" - ] + "required": ["id", "label", "description"] }, "DictationModelsListRequest": { "type": "object", @@ -1186,9 +991,7 @@ } } }, - "required": [ - "models" - ], + "required": ["models"], "x-side": "agent", "x-method": "_goose/dictation/models/list" }, @@ -1231,9 +1034,7 @@ "type": "string" } }, - "required": [ - "modelId" - ], + "required": ["modelId"], "description": "Kick off a background download of a local Whisper model.", "x-side": "agent", "x-method": "_goose/dictation/models/download" @@ -1245,9 +1046,7 @@ "type": "string" } }, - "required": [ - "modelId" - ], + "required": ["modelId"], "description": "Poll the progress of an in-flight download.", "x-side": "agent", "x-method": "_goose/dictation/models/download/progress" @@ -1290,18 +1089,10 @@ "description": "serde lowercase of DownloadStatus: \"downloading\" | \"completed\" | \"failed\" | \"cancelled\"" }, "error": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } }, - "required": [ - "bytesDownloaded", - "totalBytes", - "progressPercent", - "status" - ] + "required": ["bytesDownloaded", "totalBytes", "progressPercent", "status"] }, "DictationModelCancelRequest": { "type": "object", @@ -1310,9 +1101,7 @@ "type": "string" } }, - "required": [ - "modelId" - ], + "required": ["modelId"], "description": "Cancel an in-flight download.", "x-side": "agent", "x-method": "_goose/dictation/models/cancel" @@ -1324,9 +1113,7 @@ "type": "string" } }, - "required": [ - "modelId" - ], + "required": ["modelId"], "description": "Delete a downloaded local Whisper model from disk.", "x-side": "agent", "x-method": "_goose/dictation/models/delete" @@ -1341,10 +1128,7 @@ "type": "string" } }, - "required": [ - "provider", - "modelId" - ], + "required": ["provider", "modelId"], "description": "Persist the user's model selection for a given provider.", "x-side": "agent", "x-method": "_goose/dictation/model/select" @@ -1680,18 +1464,12 @@ }, { "description": "Untyped params", - "type": [ - "object", - "null" - ] + "type": ["object", "null"] } ] } }, - "required": [ - "id", - "method" - ], + "required": ["id", "method"], "type": "object", "x-docs-ignore": true }, @@ -1874,9 +1652,7 @@ ] } }, - "required": [ - "id" - ], + "required": ["id"], "title": "Success", "type": "object" }, @@ -1893,19 +1669,13 @@ }, "data": {} }, - "required": [ - "code", - "message" - ] + "required": ["code", "message"] }, "id": { "type": "string" } }, - "required": [ - "id", - "error" - ], + "required": ["id", "error"], "title": "Error", "type": "object" } diff --git a/ui/goose2/AGENTS.md b/ui/goose2/AGENTS.md index 3423b367e2dc..5079d6d682ea 100644 --- a/ui/goose2/AGENTS.md +++ b/ui/goose2/AGENTS.md @@ -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: -1. **Define the request/response in `crates/goose-sdk/src/custom_requests.rs`.** Use the `JsonRpcRequest` / `JsonRpcResponse` derives and the `#[request(method = "_goose//", 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//", 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, and update/delete/export address an existing skill by `path` (its on-disk directory), not by display name. 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. 4. **Regenerate the SDK.** The TS methods on `GooseClient` are generated into `ui/sdk/src/generated/`. Do not hand-edit generated files. diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index 5f32147c1ad3..3f8679f49ea6 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -48,24 +48,24 @@ export async function listSkills(): Promise { return sources.map(toSkillInfo); } -export async function deleteSkill(name: string): Promise { +export async function deleteSkill(path: string): Promise { const client = await getClient(); await client.extMethod("_goose/sources/delete", { type: "skill", - name, + path, global: true, }); } export async function updateSkill( - name: string, + path: string, description: string, instructions: string, ): Promise { const client = await getClient(); const raw = await client.extMethod("_goose/sources/update", { type: "skill", - name, + path, description, content: instructions, global: true, @@ -74,12 +74,12 @@ export async function updateSkill( } export async function exportSkill( - name: string, + path: string, ): Promise<{ json: string; filename: string }> { const client = await getClient(); const raw = await client.extMethod("_goose/sources/export", { type: "skill", - name, + path, global: true, }); return { json: raw.json as string, filename: raw.filename as string }; diff --git a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx index fd81b91c0eac..c0972ac82d06 100644 --- a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx +++ b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx @@ -20,7 +20,12 @@ interface CreateSkillDialogProps { isOpen: boolean; onClose: () => void; onCreated?: () => void; - editingSkill?: { name: string; description: string; instructions: string }; + editingSkill?: { + name: string; + description: string; + instructions: string; + path: string; + }; } export function CreateSkillDialog({ @@ -82,7 +87,7 @@ export function CreateSkillDialog({ setError(null); try { if (isEditing) { - await updateSkill(name, description.trim(), instructions); + await updateSkill(editingSkill.path, description.trim(), instructions); } else { await createSkill(name, description.trim(), instructions); } diff --git a/ui/goose2/src/features/skills/ui/SkillsView.tsx b/ui/goose2/src/features/skills/ui/SkillsView.tsx index 1a9b354bfd65..e2f1445c77f0 100644 --- a/ui/goose2/src/features/skills/ui/SkillsView.tsx +++ b/ui/goose2/src/features/skills/ui/SkillsView.tsx @@ -98,7 +98,8 @@ export function SkillsView() { const [search, setSearch] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [editingSkill, setEditingSkill] = useState< - { name: string; description: string; instructions: string } | undefined + | { name: string; description: string; instructions: string; path: string } + | undefined >(undefined); const [skills, setSkills] = useState([]); const [loading, setLoading] = useState(true); @@ -129,7 +130,7 @@ export function SkillsView() { const handleConfirmDeleteSkill = async () => { if (!deletingSkill) return; try { - await deleteSkill(deletingSkill.name); + await deleteSkill(deletingSkill.path); await loadSkills(); } catch { // best-effort @@ -142,6 +143,7 @@ export function SkillsView() { name: skill.name, description: skill.description, instructions: skill.instructions, + path: skill.path, }); setDialogOpen(true); }; @@ -164,7 +166,7 @@ export function SkillsView() { const handleExport = async (skill: SkillInfo) => { try { - const result = await exportSkill(skill.name); + const result = await exportSkill(skill.path); const blob = new Blob([result.json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/ui/goose2/src/features/skills/ui/__tests__/CreateSkillDialog.test.tsx b/ui/goose2/src/features/skills/ui/__tests__/CreateSkillDialog.test.tsx index 2444a18fa202..13aaeca17a90 100644 --- a/ui/goose2/src/features/skills/ui/__tests__/CreateSkillDialog.test.tsx +++ b/ui/goose2/src/features/skills/ui/__tests__/CreateSkillDialog.test.tsx @@ -53,6 +53,7 @@ describe("CreateSkillDialog", () => { name: "my-skill", description: "desc", instructions: "instr", + path: "/mock/.agents/skills/my-skill", }} />, ); @@ -120,6 +121,7 @@ describe("CreateSkillDialog", () => { name: "code-review", description: "Reviews code", instructions: "Review the code carefully", + path: "/mock/.agents/skills/code-review", }; it("pre-fills fields with existing skill data", () => { @@ -193,6 +195,7 @@ describe("CreateSkillDialog", () => { name: "code-review", description: "Reviews code", instructions: "Review carefully", + path: "/mock/.agents/skills/code-review", }} />, ); @@ -207,7 +210,7 @@ describe("CreateSkillDialog", () => { await user.click(screen.getByRole("button", { name: /save changes/i })); expect(updateSkill).toHaveBeenCalledWith( - "code-review", + "/mock/.agents/skills/code-review", "Updated description", "Review carefully", ); diff --git a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts index f52f6af1afa6..f9ad20a9f831 100644 --- a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts +++ b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts @@ -197,24 +197,30 @@ export function buildInitScript(options?: { global: message.params?.global ?? true, }, }); - case "_goose/sources/update": + case "_goose/sources/update": { + const path = message.params?.path ?? "/mock/.agents/skills/updated-skill"; + const name = String(path).split("/").filter(Boolean).at(-1) ?? "updated-skill"; return jsonRpcResult(message.id, { source: { - name: message.params?.name ?? "updated-skill", + name, type: "skill", description: message.params?.description ?? "", content: message.params?.content ?? "", - directory: "/mock/.agents/skills/" + (message.params?.name ?? "updated-skill"), + directory: path, global: message.params?.global ?? true, }, }); + } case "_goose/sources/delete": 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, { json: "{}", - filename: (message.params?.name ?? "skill") + ".skill.json", + filename: name + ".skill.json", }); + } case "_goose/sources/import": return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) }); default: diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 63aed51e1f1b..bd18878ed26e 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -1,400 +1,408 @@ // This file is auto-generated by @hey-api/openapi-ts - /** * Add an extension to an active session. */ export type AddExtensionRequest = { - sessionId: string; - /** - * Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform). - */ - config?: unknown; + sessionId: string; + /** + * Extension configuration (see ExtensionConfig variants: Stdio, StreamableHttp, Builtin, Platform). + */ + config?: unknown; }; /** * Empty success response for operations that return no data. */ export type EmptyResponse = { - [key: string]: unknown; + [key: string]: unknown; }; /** * Remove an extension from an active session. */ export type RemoveExtensionRequest = { - sessionId: string; - name: string; + sessionId: string; + name: string; }; /** * List all tools available in a session. */ export type GetToolsRequest = { - sessionId: string; + sessionId: string; }; /** * Tools response. */ export type GetToolsResponse = { - /** - * Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`. - */ - tools: Array; + /** + * Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`. + */ + tools: Array; }; /** * Read a resource from an extension. */ export type ReadResourceRequest = { - sessionId: string; - uri: string; - extensionName: string; + sessionId: string; + uri: string; + extensionName: string; }; /** * Resource read response. */ export type ReadResourceResponse = { - /** - * The resource result from the extension (MCP ReadResourceResult). - */ - result?: unknown; + /** + * The resource result from the extension (MCP ReadResourceResult). + */ + result?: unknown; }; /** * Update the working directory for a session. */ export type UpdateWorkingDirRequest = { - sessionId: string; - workingDir: string; + sessionId: string; + workingDir: string; }; /** * Delete a session. */ export type DeleteSessionRequest = { - sessionId: string; + sessionId: string; }; /** * List configured extensions and any warnings. */ export type GetExtensionsRequest = { - [key: string]: unknown; + [key: string]: unknown; }; /** * List configured extensions and any warnings. */ export type GetExtensionsResponse = { - /** - * Array of ExtensionEntry objects with `enabled` flag and config details. - */ - extensions: Array; - warnings: Array; + /** + * Array of ExtensionEntry objects with `enabled` flag and config details. + */ + extensions: Array; + warnings: Array; }; export type GetSessionExtensionsRequest = { - sessionId: string; + sessionId: string; }; export type GetSessionExtensionsResponse = { - extensions: Array; + extensions: Array; }; /** * List providers with setup metadata and the current model inventory snapshot. */ export type ListProvidersRequest = { - /** - * Only return entries for these providers. Empty means all. - */ - providerIds?: Array; + /** + * Only return entries for these providers. Empty means all. + */ + providerIds?: Array; }; /** * Provider list response. */ export type ListProvidersResponse = { - entries: Array; + entries: Array; }; /** * Provider inventory entry. */ export type ProviderInventoryEntryDto = { - /** - * Provider identifier. - */ - providerId: string; - /** - * Human-readable provider name. - */ - providerName: string; - /** - * Description of the provider's capabilities. - */ - description: string; - /** - * The default/recommended model for this provider. - */ - defaultModel: string; - /** - * Whether Goose has enough configuration to use this provider. - */ - configured: boolean; - /** - * Provider classification such as `Preferred`, `Builtin`, `Declarative`, or `Custom`. - */ - providerType: string; - /** - * Required configuration keys and setup metadata. - */ - configKeys: Array; - /** - * Step-by-step setup instructions, when present. - */ - setupSteps: Array; - /** - * Whether this provider supports background inventory refresh. - */ - supportsRefresh: boolean; - /** - * Whether a refresh is currently in flight. - */ - refreshing: boolean; - /** - * The list of available models. - */ - models: Array; - /** - * When this entry was last successfully refreshed (ISO 8601). - */ - lastUpdatedAt?: string | null; - /** - * When a refresh was most recently attempted (ISO 8601). - */ - lastRefreshAttemptAt?: string | null; - /** - * The last refresh failure message, if any. - */ - lastRefreshError?: string | null; - /** - * Whether we believe this data may be outdated. - */ - stale: boolean; - /** - * Guidance message shown when this provider manages its own model selection externally. - */ - modelSelectionHint?: string | null; + /** + * Provider identifier. + */ + providerId: string; + /** + * Human-readable provider name. + */ + providerName: string; + /** + * Description of the provider's capabilities. + */ + description: string; + /** + * The default/recommended model for this provider. + */ + defaultModel: string; + /** + * Whether Goose has enough configuration to use this provider. + */ + configured: boolean; + /** + * Provider classification such as `Preferred`, `Builtin`, `Declarative`, or `Custom`. + */ + providerType: string; + /** + * Required configuration keys and setup metadata. + */ + configKeys: Array; + /** + * Step-by-step setup instructions, when present. + */ + setupSteps: Array; + /** + * Whether this provider supports background inventory refresh. + */ + supportsRefresh: boolean; + /** + * Whether a refresh is currently in flight. + */ + refreshing: boolean; + /** + * The list of available models. + */ + models: Array; + /** + * When this entry was last successfully refreshed (ISO 8601). + */ + lastUpdatedAt?: string | null; + /** + * When a refresh was most recently attempted (ISO 8601). + */ + lastRefreshAttemptAt?: string | null; + /** + * The last refresh failure message, if any. + */ + lastRefreshError?: string | null; + /** + * Whether we believe this data may be outdated. + */ + stale: boolean; + /** + * Guidance message shown when this provider manages its own model selection externally. + */ + modelSelectionHint?: string | null; }; export type ProviderConfigKey = { - name: string; - required: boolean; - secret: boolean; - default?: string | null; - oauthFlow?: boolean; - deviceCodeFlow?: boolean; - primary?: boolean; + name: string; + required: boolean; + secret: boolean; + default?: string | null; + oauthFlow?: boolean; + deviceCodeFlow?: boolean; + primary?: boolean; }; /** * A single model in provider inventory. */ export type ProviderInventoryModelDto = { - /** - * Model identifier as the provider knows it. - */ - id: string; - /** - * Human-readable display name. - */ - name: string; - /** - * Model family for grouping in UI. - */ - family?: string | null; - /** - * Context window size in tokens. - */ - contextLimit?: number | null; - /** - * Whether the model supports reasoning/extended thinking. - */ - reasoning?: boolean | null; - /** - * Whether this model should appear in the compact recommended picker. - */ - recommended?: boolean; + /** + * Model identifier as the provider knows it. + */ + id: string; + /** + * Human-readable display name. + */ + name: string; + /** + * Model family for grouping in UI. + */ + family?: string | null; + /** + * Context window size in tokens. + */ + contextLimit?: number | null; + /** + * Whether the model supports reasoning/extended thinking. + */ + reasoning?: boolean | null; + /** + * Whether this model should appear in the compact recommended picker. + */ + recommended?: boolean; }; /** * Trigger a background refresh of provider inventories. */ export type RefreshProviderInventoryRequest = { - /** - * Which providers to refresh. Empty means all known providers. - */ - providerIds?: Array; + /** + * Which providers to refresh. Empty means all known providers. + */ + providerIds?: Array; }; /** * Refresh acknowledgement. */ export type RefreshProviderInventoryResponse = { - /** - * Which providers will be refreshed. - */ - started: Array; - /** - * Which providers were skipped and why. - */ - skipped?: Array; + /** + * Which providers will be refreshed. + */ + started: Array; + /** + * Which providers were skipped and why. + */ + skipped?: Array; }; export type RefreshProviderInventorySkipDto = { - providerId: string; - reason: RefreshProviderInventorySkipReasonDto; + providerId: string; + reason: RefreshProviderInventorySkipReasonDto; }; -export type RefreshProviderInventorySkipReasonDto = 'unknown_provider' | 'not_configured' | 'does_not_support_refresh' | 'already_refreshing'; +export type RefreshProviderInventorySkipReasonDto = + | "unknown_provider" + | "not_configured" + | "does_not_support_refresh" + | "already_refreshing"; /** * Read a single non-secret config value. */ export type ReadConfigRequest = { - key: string; + key: string; }; /** * Config read response. */ export type ReadConfigResponse = { - value?: unknown; + value?: unknown; }; /** * Upsert a single non-secret config value. */ export type UpsertConfigRequest = { - key: string; - value: unknown; + key: string; + value: unknown; }; /** * Remove a single non-secret config value. */ export type RemoveConfigRequest = { - key: string; + key: string; }; /** * Check whether a secret exists. Never returns the actual value. */ export type CheckSecretRequest = { - key: string; + key: string; }; /** * Secret check response. */ export type CheckSecretResponse = { - exists: boolean; + exists: boolean; }; /** * Set a secret value (write-only). */ export type UpsertSecretRequest = { - key: string; - value: unknown; + key: string; + value: unknown; }; /** * Remove a secret. */ export type RemoveSecretRequest = { - key: string; + key: string; }; /** * Export a session as a JSON string. */ export type ExportSessionRequest = { - sessionId: string; + sessionId: string; }; /** * Export session response — raw JSON of the goose session with `conversation`. */ export type ExportSessionResponse = { - data: string; + data: string; }; /** * Import a session from a JSON string. */ export type ImportSessionRequest = { - data: string; + data: string; }; /** * Import session response — metadata about the newly created session. */ export type ImportSessionResponse = { - sessionId: string; - title?: string | null; - updatedAt?: string | null; - messageCount: number; + sessionId: string; + title?: string | null; + updatedAt?: string | null; + messageCount: number; }; /** * Update the project association for a session. */ export type UpdateSessionProjectRequest = { - sessionId: string; - projectId?: string | null; + sessionId: string; + projectId?: string | null; }; /** * Archive a session (soft delete). */ export type ArchiveSessionRequest = { - sessionId: string; + sessionId: string; }; /** * Unarchive a previously archived session. */ export type UnarchiveSessionRequest = { - sessionId: string; + sessionId: string; }; /** * Create a new source (global or project-scoped). */ export type CreateSourceRequest = { - type: SourceType; - name: string; - description: string; - content: string; - global: boolean; - /** - * Absolute path to the project root. Required when `global` is false. - */ - projectDir?: string | null; + type: SourceType; + name: string; + description: string; + content: string; + global: boolean; + /** + * Absolute path to the project root. Required when `global` is false. + */ + projectDir?: string | null; }; /** * The type of source entity. */ -export type SourceType = 'skill' | 'builtinSkill' | 'recipe' | 'subrecipe' | 'agent'; +export type SourceType = + | "skill" + | "builtinSkill" + | "recipe" + | "subrecipe" + | "agent"; export type CreateSourceResponse = { - source: SourceEntry; + source: SourceEntry; }; /** @@ -402,83 +410,86 @@ export type CreateSourceResponse = { * either `global` (shared across all projects) or project-specific. */ export type SourceEntry = { - type: SourceType; - name: string; - description: string; - content: string; - /** - * Absolute path to the source on disk. A directory for skills, a file for - * recipes and agents. - */ - directory: string; - /** - * True when the source lives in the user's global sources directory; false - * when it lives inside a specific project. - */ - global: boolean; - /** - * Whether this source can be modified through Goose's source CRUD APIs. - */ - editable?: boolean; - /** - * Paths (absolute) of additional files that live alongside the source. - * Only skills currently populate this; empty for other source types. - */ - supportingFiles?: Array; -}; - -/** - * List sources. If `type` is omitted, sources of all known types are returned. - * Both global and project-scoped sources are included when `project_dir` is set. + type: SourceType; + name: string; + description: string; + content: string; + /** + * Absolute path to the source on disk. A directory for skills, a file for + * recipes and agents. + */ + directory: string; + /** + * True when the source lives in the user's global sources directory; false + * when it lives inside a specific project. + */ + global: boolean; + /** + * Whether this source can be modified through Goose's source CRUD APIs. + */ + editable?: boolean; + /** + * Paths (absolute) of additional files that live alongside the source. + * Only skills currently populate this; empty for other source types. + */ + supportingFiles?: Array; +}; + +/** + * List discovered sources. + * + * 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 = { - type?: SourceType | null; - projectDir?: string | null; + type?: SourceType | null; + projectDir?: string | null; }; export type ListSourcesResponse = { - sources: Array; + sources: Array; }; /** * Update an existing source's description and content. */ export type UpdateSourceRequest = { - type: SourceType; - path: string; - description: string; - content: string; - global: boolean; - projectDir?: string | null; + type: SourceType; + path: string; + description: string; + content: string; + global: boolean; + projectDir?: string | null; }; export type UpdateSourceResponse = { - source: SourceEntry; + source: SourceEntry; }; /** * Delete a source and its on-disk directory. */ export type DeleteSourceRequest = { - type: SourceType; - path: string; - global: boolean; - projectDir?: string | null; + type: SourceType; + path: string; + global: boolean; + projectDir?: string | null; }; /** * Export a source as a portable JSON payload. */ export type ExportSourceRequest = { - type: SourceType; - path: string; - global: boolean; - projectDir?: string | null; + type: SourceType; + path: string; + global: boolean; + projectDir?: string | null; }; export type ExportSourceResponse = { - json: string; - filename: string; + json: string; + filename: string; }; /** @@ -487,168 +498,228 @@ export type ExportSourceResponse = { * `-imported` suffix is appended. */ export type ImportSourcesRequest = { - data: string; - global: boolean; - projectDir?: string | null; + data: string; + global: boolean; + projectDir?: string | null; }; export type ImportSourcesResponse = { - sources: Array; + sources: Array; }; /** * Transcribe audio via a dictation provider. */ export type DictationTranscribeRequest = { - /** - * Base64-encoded audio data - */ - audio: string; - /** - * MIME type (e.g. "audio/wav", "audio/webm") - */ - mimeType: string; - /** - * Provider to use: "openai", "groq", "elevenlabs", or "local" - */ - provider: string; + /** + * Base64-encoded audio data + */ + audio: string; + /** + * MIME type (e.g. "audio/wav", "audio/webm") + */ + mimeType: string; + /** + * Provider to use: "openai", "groq", "elevenlabs", or "local" + */ + provider: string; }; /** * Transcription result. */ export type DictationTranscribeResponse = { - text: string; + text: string; }; /** * Get the configuration status of all dictation providers. */ export type DictationConfigRequest = { - [key: string]: unknown; + [key: string]: unknown; }; /** * Dictation config response — map of provider name to status. */ export type DictationConfigResponse = { - providers: { - [key: string]: DictationProviderStatusEntry; - }; + providers: { + [key: string]: DictationProviderStatusEntry; + }; }; /** * Per-provider configuration status. */ export type DictationProviderStatusEntry = { - configured: boolean; - host?: string | null; - description: string; - usesProviderConfig: boolean; - settingsPath?: string | null; - configKey?: string | null; - modelConfigKey?: string | null; - defaultModel?: string | null; - selectedModel?: string | null; - availableModels?: Array; + configured: boolean; + host?: string | null; + description: string; + usesProviderConfig: boolean; + settingsPath?: string | null; + configKey?: string | null; + modelConfigKey?: string | null; + defaultModel?: string | null; + selectedModel?: string | null; + availableModels?: Array; }; export type DictationModelOption = { - id: string; - label: string; - description: string; + id: string; + label: string; + description: string; }; /** * List available local Whisper models with their download status. */ export type DictationModelsListRequest = { - [key: string]: unknown; + [key: string]: unknown; }; export type DictationModelsListResponse = { - models: Array; + models: Array; }; export type DictationLocalModelStatus = { - id: string; - label: string; - description: string; - sizeMb: number; - downloaded: boolean; - downloadInProgress: boolean; + id: string; + label: string; + description: string; + sizeMb: number; + downloaded: boolean; + downloadInProgress: boolean; }; /** * Kick off a background download of a local Whisper model. */ export type DictationModelDownloadRequest = { - modelId: string; + modelId: string; }; /** * Poll the progress of an in-flight download. */ export type DictationModelDownloadProgressRequest = { - modelId: string; + modelId: string; }; export type DictationModelDownloadProgressResponse = { - /** - * None when no download is active for this model id. - */ - progress?: DictationDownloadProgress | null; + /** + * None when no download is active for this model id. + */ + progress?: DictationDownloadProgress | null; }; export type DictationDownloadProgress = { - bytesDownloaded: number; - totalBytes: number; - progressPercent: number; - /** - * serde lowercase of DownloadStatus: "downloading" | "completed" | "failed" | "cancelled" - */ - status: string; - error?: string | null; + bytesDownloaded: number; + totalBytes: number; + progressPercent: number; + /** + * serde lowercase of DownloadStatus: "downloading" | "completed" | "failed" | "cancelled" + */ + status: string; + error?: string | null; }; /** * Cancel an in-flight download. */ export type DictationModelCancelRequest = { - modelId: string; + modelId: string; }; /** * Delete a downloaded local Whisper model from disk. */ export type DictationModelDeleteRequest = { - modelId: string; + modelId: string; }; /** * Persist the user's model selection for a given provider. */ export type DictationModelSelectRequest = { - provider: string; - modelId: string; + provider: string; + modelId: string; }; export type ExtRequest = { - id: string; - method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { + id: string; + method: string; + params?: + | AddExtensionRequest + | RemoveExtensionRequest + | GetToolsRequest + | ReadResourceRequest + | UpdateWorkingDirRequest + | DeleteSessionRequest + | GetExtensionsRequest + | GetSessionExtensionsRequest + | ListProvidersRequest + | RefreshProviderInventoryRequest + | ReadConfigRequest + | UpsertConfigRequest + | RemoveConfigRequest + | CheckSecretRequest + | UpsertSecretRequest + | RemoveSecretRequest + | ExportSessionRequest + | ImportSessionRequest + | UpdateSessionProjectRequest + | ArchiveSessionRequest + | UnarchiveSessionRequest + | CreateSourceRequest + | ListSourcesRequest + | UpdateSourceRequest + | DeleteSourceRequest + | ExportSourceRequest + | ImportSourcesRequest + | DictationTranscribeRequest + | DictationConfigRequest + | DictationModelsListRequest + | DictationModelDownloadRequest + | DictationModelDownloadProgressRequest + | DictationModelCancelRequest + | DictationModelDeleteRequest + | DictationModelSelectRequest + | { [key: string]: unknown; - } | null; -}; - -export type ExtResponse = { - id: string; - result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; -} | { - error: { + } + | null; +}; + +export type ExtResponse = + | { + id: string; + result?: + | EmptyResponse + | GetToolsResponse + | ReadResourceResponse + | GetExtensionsResponse + | GetSessionExtensionsResponse + | ListProvidersResponse + | RefreshProviderInventoryResponse + | ReadConfigResponse + | CheckSecretResponse + | ExportSessionResponse + | ImportSessionResponse + | CreateSourceResponse + | ListSourcesResponse + | UpdateSourceResponse + | ExportSourceResponse + | ImportSourcesResponse + | DictationTranscribeResponse + | DictationConfigResponse + | DictationModelsListResponse + | DictationModelDownloadProgressResponse + | unknown; + } + | { + error: { code: number; message: string; data?: unknown; + }; + id: string; }; - id: string; -}; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index f6ab5b5a6d1b..90bfffc9ab6d 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -1,13 +1,13 @@ // This file is auto-generated by @hey-api/openapi-ts -import { z } from 'zod'; +import { z } from "zod"; /** * Add an extension to an active session. */ export const zAddExtensionRequest = z.object({ - sessionId: z.string(), - config: z.unknown().optional().default(null) + sessionId: z.string(), + config: z.unknown().optional().default(null), }); /** @@ -19,53 +19,53 @@ export const zEmptyResponse = z.record(z.unknown()); * Remove an extension from an active session. */ export const zRemoveExtensionRequest = z.object({ - sessionId: z.string(), - name: z.string() + sessionId: z.string(), + name: z.string(), }); /** * List all tools available in a session. */ export const zGetToolsRequest = z.object({ - sessionId: z.string() + sessionId: z.string(), }); /** * Tools response. */ export const zGetToolsResponse = z.object({ - tools: z.array(z.unknown()) + tools: z.array(z.unknown()), }); /** * Read a resource from an extension. */ export const zReadResourceRequest = z.object({ - sessionId: z.string(), - uri: z.string(), - extensionName: z.string() + sessionId: z.string(), + uri: z.string(), + extensionName: z.string(), }); /** * Resource read response. */ export const zReadResourceResponse = z.object({ - result: z.unknown().optional().default(null) + result: z.unknown().optional().default(null), }); /** * Update the working directory for a session. */ export const zUpdateWorkingDirRequest = z.object({ - sessionId: z.string(), - workingDir: z.string() + sessionId: z.string(), + workingDir: z.string(), }); /** * Delete a session. */ export const zDeleteSessionRequest = z.object({ - sessionId: z.string() + sessionId: z.string(), }); /** @@ -77,271 +77,235 @@ export const zGetExtensionsRequest = z.record(z.unknown()); * List configured extensions and any warnings. */ export const zGetExtensionsResponse = z.object({ - extensions: z.array(z.unknown()), - warnings: z.array(z.string()) + extensions: z.array(z.unknown()), + warnings: z.array(z.string()), }); export const zGetSessionExtensionsRequest = z.object({ - sessionId: z.string() + sessionId: z.string(), }); export const zGetSessionExtensionsResponse = z.object({ - extensions: z.array(z.unknown()) + extensions: z.array(z.unknown()), }); /** * List providers with setup metadata and the current model inventory snapshot. */ export const zListProvidersRequest = z.object({ - providerIds: z.array(z.string()).optional().default([]) + providerIds: z.array(z.string()).optional().default([]), }); export const zProviderConfigKey = z.object({ - name: z.string(), - required: z.boolean(), - secret: z.boolean(), - default: z.union([ - z.string(), - z.null() - ]).optional().default(null), - oauthFlow: z.boolean().optional().default(false), - deviceCodeFlow: z.boolean().optional().default(false), - primary: z.boolean().optional().default(false) + name: z.string(), + required: z.boolean(), + secret: z.boolean(), + default: z.union([z.string(), z.null()]).optional().default(null), + oauthFlow: z.boolean().optional().default(false), + deviceCodeFlow: z.boolean().optional().default(false), + primary: z.boolean().optional().default(false), }); /** * A single model in provider inventory. */ export const zProviderInventoryModelDto = z.object({ - id: z.string(), - name: z.string(), - family: z.union([ - z.string(), - z.null() - ]).optional(), - contextLimit: z.union([ - z.number().int().gte(0), - z.null() - ]).optional(), - reasoning: z.union([ - z.boolean(), - z.null() - ]).optional(), - recommended: z.boolean().optional().default(false) + id: z.string(), + name: z.string(), + family: z.union([z.string(), z.null()]).optional(), + contextLimit: z.union([z.number().int().gte(0), z.null()]).optional(), + reasoning: z.union([z.boolean(), z.null()]).optional(), + recommended: z.boolean().optional().default(false), }); /** * Provider inventory entry. */ export const zProviderInventoryEntryDto = z.object({ - providerId: z.string(), - providerName: z.string(), - description: z.string(), - defaultModel: z.string(), - configured: z.boolean(), - providerType: z.string(), - configKeys: z.array(zProviderConfigKey), - setupSteps: z.array(z.string()), - supportsRefresh: z.boolean(), - refreshing: z.boolean(), - models: z.array(zProviderInventoryModelDto), - lastUpdatedAt: z.union([ - z.string(), - z.null() - ]).optional(), - lastRefreshAttemptAt: z.union([ - z.string(), - z.null() - ]).optional(), - lastRefreshError: z.union([ - z.string(), - z.null() - ]).optional(), - stale: z.boolean(), - modelSelectionHint: z.union([ - z.string(), - z.null() - ]).optional() + providerId: z.string(), + providerName: z.string(), + description: z.string(), + defaultModel: z.string(), + configured: z.boolean(), + providerType: z.string(), + configKeys: z.array(zProviderConfigKey), + setupSteps: z.array(z.string()), + supportsRefresh: z.boolean(), + refreshing: z.boolean(), + models: z.array(zProviderInventoryModelDto), + lastUpdatedAt: z.union([z.string(), z.null()]).optional(), + lastRefreshAttemptAt: z.union([z.string(), z.null()]).optional(), + lastRefreshError: z.union([z.string(), z.null()]).optional(), + stale: z.boolean(), + modelSelectionHint: z.union([z.string(), z.null()]).optional(), }); /** * Provider list response. */ export const zListProvidersResponse = z.object({ - entries: z.array(zProviderInventoryEntryDto) + entries: z.array(zProviderInventoryEntryDto), }); /** * Trigger a background refresh of provider inventories. */ export const zRefreshProviderInventoryRequest = z.object({ - providerIds: z.array(z.string()).optional().default([]) + providerIds: z.array(z.string()).optional().default([]), }); export const zRefreshProviderInventorySkipReasonDto = z.enum([ - 'unknown_provider', - 'not_configured', - 'does_not_support_refresh', - 'already_refreshing' + "unknown_provider", + "not_configured", + "does_not_support_refresh", + "already_refreshing", ]); export const zRefreshProviderInventorySkipDto = z.object({ - providerId: z.string(), - reason: zRefreshProviderInventorySkipReasonDto + providerId: z.string(), + reason: zRefreshProviderInventorySkipReasonDto, }); /** * Refresh acknowledgement. */ export const zRefreshProviderInventoryResponse = z.object({ - started: z.array(z.string()), - skipped: z.array(zRefreshProviderInventorySkipDto).optional().default([]) + started: z.array(z.string()), + skipped: z.array(zRefreshProviderInventorySkipDto).optional().default([]), }); /** * Read a single non-secret config value. */ export const zReadConfigRequest = z.object({ - key: z.string() + key: z.string(), }); /** * Config read response. */ export const zReadConfigResponse = z.object({ - value: z.unknown().optional().default(null) + value: z.unknown().optional().default(null), }); /** * Upsert a single non-secret config value. */ export const zUpsertConfigRequest = z.object({ - key: z.string(), - value: z.unknown() + key: z.string(), + value: z.unknown(), }); /** * Remove a single non-secret config value. */ export const zRemoveConfigRequest = z.object({ - key: z.string() + key: z.string(), }); /** * Check whether a secret exists. Never returns the actual value. */ export const zCheckSecretRequest = z.object({ - key: z.string() + key: z.string(), }); /** * Secret check response. */ export const zCheckSecretResponse = z.object({ - exists: z.boolean() + exists: z.boolean(), }); /** * Set a secret value (write-only). */ export const zUpsertSecretRequest = z.object({ - key: z.string(), - value: z.unknown() + key: z.string(), + value: z.unknown(), }); /** * Remove a secret. */ export const zRemoveSecretRequest = z.object({ - key: z.string() + key: z.string(), }); /** * Export a session as a JSON string. */ export const zExportSessionRequest = z.object({ - sessionId: z.string() + sessionId: z.string(), }); /** * Export session response — raw JSON of the goose session with `conversation`. */ export const zExportSessionResponse = z.object({ - data: z.string() + data: z.string(), }); /** * Import a session from a JSON string. */ export const zImportSessionRequest = z.object({ - data: z.string() + data: z.string(), }); /** * Import session response — metadata about the newly created session. */ export const zImportSessionResponse = z.object({ - sessionId: z.string(), - title: z.union([ - z.string(), - z.null() - ]).optional(), - updatedAt: z.union([ - z.string(), - z.null() - ]).optional(), - messageCount: z.number().int().gte(0) + sessionId: z.string(), + title: z.union([z.string(), z.null()]).optional(), + updatedAt: z.union([z.string(), z.null()]).optional(), + messageCount: z.number().int().gte(0), }); /** * Update the project association for a session. */ export const zUpdateSessionProjectRequest = z.object({ - sessionId: z.string(), - projectId: z.union([ - z.string(), - z.null() - ]).optional() + sessionId: z.string(), + projectId: z.union([z.string(), z.null()]).optional(), }); /** * Archive a session (soft delete). */ export const zArchiveSessionRequest = z.object({ - sessionId: z.string() + sessionId: z.string(), }); /** * Unarchive a previously archived session. */ export const zUnarchiveSessionRequest = z.object({ - sessionId: z.string() + sessionId: z.string(), }); /** * The type of source entity. */ export const zSourceType = z.enum([ - 'skill', - 'builtinSkill', - 'recipe', - 'subrecipe', - 'agent' + "skill", + "builtinSkill", + "recipe", + "subrecipe", + "agent", ]); /** * Create a new source (global or project-scoped). */ export const zCreateSourceRequest = z.object({ - type: zSourceType, - name: z.string(), - description: z.string(), - content: z.string(), - global: z.boolean(), - projectDir: z.union([ - z.string(), - z.null() - ]).optional() + type: zSourceType, + name: z.string(), + description: z.string(), + content: z.string(), + global: z.boolean(), + projectDir: z.union([z.string(), z.null()]).optional(), }); /** @@ -349,87 +313,75 @@ export const zCreateSourceRequest = z.object({ * either `global` (shared across all projects) or project-specific. */ export const zSourceEntry = z.object({ - type: zSourceType, - name: z.string(), - description: z.string(), - content: z.string(), - directory: z.string(), - global: z.boolean(), - editable: z.boolean().optional().default(false), - supportingFiles: z.array(z.string()).optional() + type: zSourceType, + name: z.string(), + description: z.string(), + content: z.string(), + directory: z.string(), + global: z.boolean(), + editable: z.boolean().optional().default(false), + supportingFiles: z.array(z.string()).optional(), }); export const zCreateSourceResponse = z.object({ - source: zSourceEntry + source: zSourceEntry, }); /** - * List sources. If `type` is omitted, sources of all known types are returned. - * Both global and project-scoped sources are included when `project_dir` is set. + * List discovered sources. + * + * 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({ - type: z.union([ - zSourceType, - z.null() - ]).optional(), - projectDir: z.union([ - z.string(), - z.null() - ]).optional() + type: z.union([zSourceType, z.null()]).optional(), + projectDir: z.union([z.string(), z.null()]).optional(), }); export const zListSourcesResponse = z.object({ - sources: z.array(zSourceEntry) + sources: z.array(zSourceEntry), }); /** * Update an existing source's description and content. */ export const zUpdateSourceRequest = z.object({ - type: zSourceType, - path: z.string(), - description: z.string(), - content: z.string(), - global: z.boolean(), - projectDir: z.union([ - z.string(), - z.null() - ]).optional() + type: zSourceType, + path: z.string(), + description: z.string(), + content: z.string(), + global: z.boolean(), + projectDir: z.union([z.string(), z.null()]).optional(), }); export const zUpdateSourceResponse = z.object({ - source: zSourceEntry + source: zSourceEntry, }); /** * Delete a source and its on-disk directory. */ export const zDeleteSourceRequest = z.object({ - type: zSourceType, - path: z.string(), - global: z.boolean(), - projectDir: z.union([ - z.string(), - z.null() - ]).optional() + type: zSourceType, + path: z.string(), + global: z.boolean(), + projectDir: z.union([z.string(), z.null()]).optional(), }); /** * Export a source as a portable JSON payload. */ export const zExportSourceRequest = z.object({ - type: zSourceType, - path: z.string(), - global: z.boolean(), - projectDir: z.union([ - z.string(), - z.null() - ]).optional() + type: zSourceType, + path: z.string(), + global: z.boolean(), + projectDir: z.union([z.string(), z.null()]).optional(), }); export const zExportSourceResponse = z.object({ - json: z.string(), - filename: z.string() + json: z.string(), + filename: z.string(), }); /** @@ -438,32 +390,29 @@ export const zExportSourceResponse = z.object({ * `-imported` suffix is appended. */ export const zImportSourcesRequest = z.object({ - data: z.string(), - global: z.boolean(), - projectDir: z.union([ - z.string(), - z.null() - ]).optional() + data: z.string(), + global: z.boolean(), + projectDir: z.union([z.string(), z.null()]).optional(), }); export const zImportSourcesResponse = z.object({ - sources: z.array(zSourceEntry) + sources: z.array(zSourceEntry), }); /** * Transcribe audio via a dictation provider. */ export const zDictationTranscribeRequest = z.object({ - audio: z.string(), - mimeType: z.string(), - provider: z.string() + audio: z.string(), + mimeType: z.string(), + provider: z.string(), }); /** * Transcription result. */ export const zDictationTranscribeResponse = z.object({ - text: z.string() + text: z.string(), }); /** @@ -472,50 +421,32 @@ export const zDictationTranscribeResponse = z.object({ export const zDictationConfigRequest = z.record(z.unknown()); export const zDictationModelOption = z.object({ - id: z.string(), - label: z.string(), - description: z.string() + id: z.string(), + label: z.string(), + description: z.string(), }); /** * Per-provider configuration status. */ export const zDictationProviderStatusEntry = z.object({ - configured: z.boolean(), - host: z.union([ - z.string(), - z.null() - ]).optional(), - description: z.string(), - usesProviderConfig: z.boolean(), - settingsPath: z.union([ - z.string(), - z.null() - ]).optional(), - configKey: z.union([ - z.string(), - z.null() - ]).optional(), - modelConfigKey: z.union([ - z.string(), - z.null() - ]).optional(), - defaultModel: z.union([ - z.string(), - z.null() - ]).optional(), - selectedModel: z.union([ - z.string(), - z.null() - ]).optional(), - availableModels: z.array(zDictationModelOption).optional().default([]) + configured: z.boolean(), + host: z.union([z.string(), z.null()]).optional(), + description: z.string(), + usesProviderConfig: z.boolean(), + settingsPath: z.union([z.string(), z.null()]).optional(), + configKey: z.union([z.string(), z.null()]).optional(), + modelConfigKey: z.union([z.string(), z.null()]).optional(), + defaultModel: z.union([z.string(), z.null()]).optional(), + selectedModel: z.union([z.string(), z.null()]).optional(), + availableModels: z.array(zDictationModelOption).optional().default([]), }); /** * Dictation config response — map of provider name to status. */ export const zDictationConfigResponse = z.object({ - providers: z.record(zDictationProviderStatusEntry) + providers: z.record(zDictationProviderStatusEntry), }); /** @@ -524,155 +455,150 @@ export const zDictationConfigResponse = z.object({ export const zDictationModelsListRequest = z.record(z.unknown()); export const zDictationLocalModelStatus = z.object({ - id: z.string(), - label: z.string(), - description: z.string(), - sizeMb: z.number().int().gte(0), - downloaded: z.boolean(), - downloadInProgress: z.boolean() + id: z.string(), + label: z.string(), + description: z.string(), + sizeMb: z.number().int().gte(0), + downloaded: z.boolean(), + downloadInProgress: z.boolean(), }); export const zDictationModelsListResponse = z.object({ - models: z.array(zDictationLocalModelStatus) + models: z.array(zDictationLocalModelStatus), }); /** * Kick off a background download of a local Whisper model. */ export const zDictationModelDownloadRequest = z.object({ - modelId: z.string() + modelId: z.string(), }); /** * Poll the progress of an in-flight download. */ export const zDictationModelDownloadProgressRequest = z.object({ - modelId: z.string() + modelId: z.string(), }); export const zDictationDownloadProgress = z.object({ - bytesDownloaded: z.number().int().gte(0), - totalBytes: z.number().int().gte(0), - progressPercent: z.number(), - status: z.string(), - error: z.union([ - z.string(), - z.null() - ]).optional() + bytesDownloaded: z.number().int().gte(0), + totalBytes: z.number().int().gte(0), + progressPercent: z.number(), + status: z.string(), + error: z.union([z.string(), z.null()]).optional(), }); export const zDictationModelDownloadProgressResponse = z.object({ - progress: z.union([ - zDictationDownloadProgress, - z.null() - ]).optional() + progress: z.union([zDictationDownloadProgress, z.null()]).optional(), }); /** * Cancel an in-flight download. */ export const zDictationModelCancelRequest = z.object({ - modelId: z.string() + modelId: z.string(), }); /** * Delete a downloaded local Whisper model from disk. */ export const zDictationModelDeleteRequest = z.object({ - modelId: z.string() + modelId: z.string(), }); /** * Persist the user's model selection for a given provider. */ export const zDictationModelSelectRequest = z.object({ - provider: z.string(), - modelId: z.string() + provider: z.string(), + modelId: z.string(), }); export const zExtRequest = z.object({ - id: z.string(), - method: z.string(), - params: z.union([ - z.union([ - zAddExtensionRequest, - zRemoveExtensionRequest, - zGetToolsRequest, - zReadResourceRequest, - zUpdateWorkingDirRequest, - zDeleteSessionRequest, - zGetExtensionsRequest, - zGetSessionExtensionsRequest, - zListProvidersRequest, - zRefreshProviderInventoryRequest, - zReadConfigRequest, - zUpsertConfigRequest, - zRemoveConfigRequest, - zCheckSecretRequest, - zUpsertSecretRequest, - zRemoveSecretRequest, - zExportSessionRequest, - zImportSessionRequest, - zUpdateSessionProjectRequest, - zArchiveSessionRequest, - zUnarchiveSessionRequest, - zCreateSourceRequest, - zListSourcesRequest, - zUpdateSourceRequest, - zDeleteSourceRequest, - zExportSourceRequest, - zImportSourcesRequest, - zDictationTranscribeRequest, - zDictationConfigRequest, - zDictationModelsListRequest, - zDictationModelDownloadRequest, - zDictationModelDownloadProgressRequest, - zDictationModelCancelRequest, - zDictationModelDeleteRequest, - zDictationModelSelectRequest - ]), - z.union([ - z.record(z.unknown()), - z.null() - ]) - ]).optional() + id: z.string(), + method: z.string(), + params: z + .union([ + z.union([ + zAddExtensionRequest, + zRemoveExtensionRequest, + zGetToolsRequest, + zReadResourceRequest, + zUpdateWorkingDirRequest, + zDeleteSessionRequest, + zGetExtensionsRequest, + zGetSessionExtensionsRequest, + zListProvidersRequest, + zRefreshProviderInventoryRequest, + zReadConfigRequest, + zUpsertConfigRequest, + zRemoveConfigRequest, + zCheckSecretRequest, + zUpsertSecretRequest, + zRemoveSecretRequest, + zExportSessionRequest, + zImportSessionRequest, + zUpdateSessionProjectRequest, + zArchiveSessionRequest, + zUnarchiveSessionRequest, + zCreateSourceRequest, + zListSourcesRequest, + zUpdateSourceRequest, + zDeleteSourceRequest, + zExportSourceRequest, + zImportSourcesRequest, + zDictationTranscribeRequest, + zDictationConfigRequest, + zDictationModelsListRequest, + zDictationModelDownloadRequest, + zDictationModelDownloadProgressRequest, + zDictationModelCancelRequest, + zDictationModelDeleteRequest, + zDictationModelSelectRequest, + ]), + z.union([z.record(z.unknown()), z.null()]), + ]) + .optional(), }); export const zExtResponse = z.union([ - z.object({ - id: z.string(), - result: z.union([ - z.union([ - zEmptyResponse, - zGetToolsResponse, - zReadResourceResponse, - zGetExtensionsResponse, - zGetSessionExtensionsResponse, - zListProvidersResponse, - zRefreshProviderInventoryResponse, - zReadConfigResponse, - zCheckSecretResponse, - zExportSessionResponse, - zImportSessionResponse, - zCreateSourceResponse, - zListSourcesResponse, - zUpdateSourceResponse, - zExportSourceResponse, - zImportSourcesResponse, - zDictationTranscribeResponse, - zDictationConfigResponse, - zDictationModelsListResponse, - zDictationModelDownloadProgressResponse - ]), - z.unknown() - ]).optional() + z.object({ + id: z.string(), + result: z + .union([ + z.union([ + zEmptyResponse, + zGetToolsResponse, + zReadResourceResponse, + zGetExtensionsResponse, + zGetSessionExtensionsResponse, + zListProvidersResponse, + zRefreshProviderInventoryResponse, + zReadConfigResponse, + zCheckSecretResponse, + zExportSessionResponse, + zImportSessionResponse, + zCreateSourceResponse, + zListSourcesResponse, + zUpdateSourceResponse, + zExportSourceResponse, + zImportSourcesResponse, + zDictationTranscribeResponse, + zDictationConfigResponse, + zDictationModelsListResponse, + zDictationModelDownloadProgressResponse, + ]), + z.unknown(), + ]) + .optional(), + }), + z.object({ + error: z.object({ + code: z.number().int(), + message: z.string(), + data: z.unknown().optional(), }), - z.object({ - error: z.object({ - code: z.number().int(), - message: z.string(), - data: z.unknown().optional() - }), - id: z.string() - }) + id: z.string(), + }), ]); From aba3b59129aa374642a6ef97b5611ed64ba10434 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 22 Apr 2026 15:13:51 -0400 Subject: [PATCH 09/20] global is inferred --- crates/goose-sdk/src/custom_requests.rs | 9 - crates/goose/acp-schema.json | 392 ++++++++--- crates/goose/src/acp/server.rs | 16 +- crates/goose/src/skills/mod.rs | 58 +- crates/goose/src/sources.rs | 109 +-- ui/goose2/src/features/skills/api/skills.ts | 70 +- .../src/features/skills/ui/SkillsView.tsx | 47 +- .../skills/ui/__tests__/SkillsView.test.tsx | 63 +- .../src/shared/i18n/locales/en/skills.json | 2 + .../src/shared/i18n/locales/es/skills.json | 2 + ui/goose2/tests/e2e/fixtures/tauri-mock.ts | 2 +- ui/sdk/src/generated/types.gen.ts | 652 ++++++++---------- ui/sdk/src/generated/zod.gen.ts | 544 ++++++++------- 13 files changed, 1121 insertions(+), 845 deletions(-) diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index f22447424c23..1befbd15e0ad 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -369,9 +369,6 @@ pub struct UpdateSourceRequest { pub path: String, pub description: String, pub content: String, - pub global: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub project_dir: Option, } #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] @@ -388,9 +385,6 @@ pub struct DeleteSourceRequest { #[serde(rename = "type")] pub source_type: SourceType, pub path: String, - pub global: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub project_dir: Option, } /// Export a source as a portable JSON payload. @@ -401,9 +395,6 @@ pub struct ExportSourceRequest { #[serde(rename = "type")] pub source_type: SourceType, pub path: String, - pub global: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub project_dir: Option, } #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 083e0742a3b4..376ec347a574 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -13,7 +13,9 @@ "default": null } }, - "required": ["sessionId"], + "required": [ + "sessionId" + ], "description": "Add an extension to an active session.", "x-side": "agent", "x-method": "_goose/extensions/add" @@ -33,7 +35,10 @@ "type": "string" } }, - "required": ["sessionId", "name"], + "required": [ + "sessionId", + "name" + ], "description": "Remove an extension from an active session.", "x-side": "agent", "x-method": "_goose/extensions/remove" @@ -45,7 +50,9 @@ "type": "string" } }, - "required": ["sessionId"], + "required": [ + "sessionId" + ], "description": "List all tools available in a session.", "x-side": "agent", "x-method": "_goose/tools" @@ -59,7 +66,9 @@ "description": "Array of tool info objects with `name`, `description`, `parameters`, and optional `permission`." } }, - "required": ["tools"], + "required": [ + "tools" + ], "description": "Tools response.", "x-side": "agent", "x-method": "_goose/tools" @@ -77,7 +86,11 @@ "type": "string" } }, - "required": ["sessionId", "uri", "extensionName"], + "required": [ + "sessionId", + "uri", + "extensionName" + ], "description": "Read a resource from an extension.", "x-side": "agent", "x-method": "_goose/resource/read" @@ -104,7 +117,10 @@ "type": "string" } }, - "required": ["sessionId", "workingDir"], + "required": [ + "sessionId", + "workingDir" + ], "description": "Update the working directory for a session.", "x-side": "agent", "x-method": "_goose/working_dir/update" @@ -116,7 +132,9 @@ "type": "string" } }, - "required": ["sessionId"], + "required": [ + "sessionId" + ], "description": "Delete a session.", "x-side": "agent", "x-method": "session/delete" @@ -142,7 +160,10 @@ } } }, - "required": ["extensions", "warnings"], + "required": [ + "extensions", + "warnings" + ], "description": "List configured extensions and any warnings.", "x-side": "agent", "x-method": "_goose/config/extensions" @@ -154,7 +175,9 @@ "type": "string" } }, - "required": ["sessionId"], + "required": [ + "sessionId" + ], "x-side": "agent", "x-method": "_goose/session/extensions" }, @@ -166,7 +189,9 @@ "items": {} } }, - "required": ["extensions"], + "required": [ + "extensions" + ], "x-side": "agent", "x-method": "_goose/session/extensions" }, @@ -196,7 +221,9 @@ } } }, - "required": ["entries"], + "required": [ + "entries" + ], "description": "Provider list response.", "x-side": "agent", "x-method": "_goose/providers/list" @@ -258,15 +285,24 @@ "description": "The list of available models." }, "lastUpdatedAt": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "When this entry was last successfully refreshed (ISO 8601)." }, "lastRefreshAttemptAt": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "When a refresh was most recently attempted (ISO 8601)." }, "lastRefreshError": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "The last refresh failure message, if any." }, "stale": { @@ -274,7 +310,10 @@ "description": "Whether we believe this data may be outdated." }, "modelSelectionHint": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Guidance message shown when this provider manages its own model selection externally." } }, @@ -307,7 +346,10 @@ "type": "boolean" }, "default": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null }, "oauthFlow": { @@ -323,7 +365,11 @@ "default": false } }, - "required": ["name", "required", "secret"] + "required": [ + "name", + "required", + "secret" + ] }, "ProviderInventoryModelDto": { "type": "object", @@ -337,17 +383,26 @@ "description": "Human-readable display name." }, "family": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Model family for grouping in UI." }, "contextLimit": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "uint", "minimum": 0, "description": "Context window size in tokens." }, "reasoning": { - "type": ["boolean", "null"], + "type": [ + "boolean", + "null" + ], "description": "Whether the model supports reasoning/extended thinking." }, "recommended": { @@ -356,7 +411,10 @@ "default": false } }, - "required": ["id", "name"], + "required": [ + "id", + "name" + ], "description": "A single model in provider inventory." }, "RefreshProviderInventoryRequest": { @@ -394,7 +452,9 @@ "default": [] } }, - "required": ["started"], + "required": [ + "started" + ], "description": "Refresh acknowledgement.", "x-side": "agent", "x-method": "_goose/providers/inventory/refresh" @@ -409,7 +469,10 @@ "$ref": "#/$defs/RefreshProviderInventorySkipReasonDto" } }, - "required": ["providerId", "reason"] + "required": [ + "providerId", + "reason" + ] }, "RefreshProviderInventorySkipReasonDto": { "type": "string", @@ -427,7 +490,9 @@ "type": "string" } }, - "required": ["key"], + "required": [ + "key" + ], "description": "Read a single non-secret config value.", "x-side": "agent", "x-method": "_goose/config/read" @@ -451,7 +516,10 @@ }, "value": {} }, - "required": ["key", "value"], + "required": [ + "key", + "value" + ], "description": "Upsert a single non-secret config value.", "x-side": "agent", "x-method": "_goose/config/upsert" @@ -463,7 +531,9 @@ "type": "string" } }, - "required": ["key"], + "required": [ + "key" + ], "description": "Remove a single non-secret config value.", "x-side": "agent", "x-method": "_goose/config/remove" @@ -475,7 +545,9 @@ "type": "string" } }, - "required": ["key"], + "required": [ + "key" + ], "description": "Check whether a secret exists. Never returns the actual value.", "x-side": "agent", "x-method": "_goose/secret/check" @@ -487,7 +559,9 @@ "type": "boolean" } }, - "required": ["exists"], + "required": [ + "exists" + ], "description": "Secret check response.", "x-side": "agent", "x-method": "_goose/secret/check" @@ -500,7 +574,10 @@ }, "value": {} }, - "required": ["key", "value"], + "required": [ + "key", + "value" + ], "description": "Set a secret value (write-only).", "x-side": "agent", "x-method": "_goose/secret/upsert" @@ -512,7 +589,9 @@ "type": "string" } }, - "required": ["key"], + "required": [ + "key" + ], "description": "Remove a secret.", "x-side": "agent", "x-method": "_goose/secret/remove" @@ -524,7 +603,9 @@ "type": "string" } }, - "required": ["sessionId"], + "required": [ + "sessionId" + ], "description": "Export a session as a JSON string.", "x-side": "agent", "x-method": "_goose/session/export" @@ -536,7 +617,9 @@ "type": "string" } }, - "required": ["data"], + "required": [ + "data" + ], "description": "Export session response — raw JSON of the goose session with `conversation`.", "x-side": "agent", "x-method": "_goose/session/export" @@ -548,7 +631,9 @@ "type": "string" } }, - "required": ["data"], + "required": [ + "data" + ], "description": "Import a session from a JSON string.", "x-side": "agent", "x-method": "_goose/session/import" @@ -560,17 +645,26 @@ "type": "string" }, "title": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "updatedAt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "messageCount": { "type": "integer", "minimum": 0 } }, - "required": ["sessionId", "messageCount"], + "required": [ + "sessionId", + "messageCount" + ], "description": "Import session response — metadata about the newly created session.", "x-side": "agent", "x-method": "_goose/session/import" @@ -582,10 +676,15 @@ "type": "string" }, "projectId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, - "required": ["sessionId"], + "required": [ + "sessionId" + ], "description": "Update the project association for a session.", "x-side": "agent", "x-method": "_goose/session/update_project" @@ -597,7 +696,9 @@ "type": "string" } }, - "required": ["sessionId"], + "required": [ + "sessionId" + ], "description": "Archive a session (soft delete).", "x-side": "agent", "x-method": "_goose/session/archive" @@ -609,7 +710,9 @@ "type": "string" } }, - "required": ["sessionId"], + "required": [ + "sessionId" + ], "description": "Unarchive a previously archived session.", "x-side": "agent", "x-method": "_goose/session/unarchive" @@ -633,18 +736,33 @@ "type": "boolean" }, "projectDir": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Absolute path to the project root. Required when `global` is false." } }, - "required": ["type", "name", "description", "content", "global"], + "required": [ + "type", + "name", + "description", + "content", + "global" + ], "description": "Create a new source (global or project-scoped).", "x-side": "agent", "x-method": "_goose/sources/create" }, "SourceType": { "type": "string", - "enum": ["skill", "builtinSkill", "recipe", "subrecipe", "agent"], + "enum": [ + "skill", + "builtinSkill", + "recipe", + "subrecipe", + "agent" + ], "description": "The type of source entity." }, "CreateSourceResponse": { @@ -654,7 +772,9 @@ "$ref": "#/$defs/SourceEntry" } }, - "required": ["source"], + "required": [ + "source" + ], "x-side": "agent", "x-method": "_goose/sources/create" }, @@ -718,7 +838,10 @@ ] }, "projectDir": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, "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.", @@ -735,7 +858,9 @@ } } }, - "required": ["sources"], + "required": [ + "sources" + ], "x-side": "agent", "x-method": "_goose/sources/list" }, @@ -753,15 +878,14 @@ }, "content": { "type": "string" - }, - "global": { - "type": "boolean" - }, - "projectDir": { - "type": ["string", "null"] } }, - "required": ["type", "path", "description", "content", "global"], + "required": [ + "type", + "path", + "description", + "content" + ], "description": "Update an existing source's description and content.", "x-side": "agent", "x-method": "_goose/sources/update" @@ -773,7 +897,9 @@ "$ref": "#/$defs/SourceEntry" } }, - "required": ["source"], + "required": [ + "source" + ], "x-side": "agent", "x-method": "_goose/sources/update" }, @@ -785,15 +911,12 @@ }, "path": { "type": "string" - }, - "global": { - "type": "boolean" - }, - "projectDir": { - "type": ["string", "null"] } }, - "required": ["type", "path", "global"], + "required": [ + "type", + "path" + ], "description": "Delete a source and its on-disk directory.", "x-side": "agent", "x-method": "_goose/sources/delete" @@ -806,15 +929,12 @@ }, "path": { "type": "string" - }, - "global": { - "type": "boolean" - }, - "projectDir": { - "type": ["string", "null"] } }, - "required": ["type", "path", "global"], + "required": [ + "type", + "path" + ], "description": "Export a source as a portable JSON payload.", "x-side": "agent", "x-method": "_goose/sources/export" @@ -829,7 +949,10 @@ "type": "string" } }, - "required": ["json", "filename"], + "required": [ + "json", + "filename" + ], "x-side": "agent", "x-method": "_goose/sources/export" }, @@ -843,10 +966,16 @@ "type": "boolean" }, "projectDir": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, - "required": ["data", "global"], + "required": [ + "data", + "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.", "x-side": "agent", "x-method": "_goose/sources/import" @@ -861,7 +990,9 @@ } } }, - "required": ["sources"], + "required": [ + "sources" + ], "x-side": "agent", "x-method": "_goose/sources/import" }, @@ -881,7 +1012,11 @@ "description": "Provider to use: \"openai\", \"groq\", \"elevenlabs\", or \"local\"" } }, - "required": ["audio", "mimeType", "provider"], + "required": [ + "audio", + "mimeType", + "provider" + ], "description": "Transcribe audio via a dictation provider.", "x-side": "agent", "x-method": "_goose/dictation/transcribe" @@ -893,7 +1028,9 @@ "type": "string" } }, - "required": ["text"], + "required": [ + "text" + ], "description": "Transcription result.", "x-side": "agent", "x-method": "_goose/dictation/transcribe" @@ -914,7 +1051,9 @@ } } }, - "required": ["providers"], + "required": [ + "providers" + ], "description": "Dictation config response — map of provider name to status.", "x-side": "agent", "x-method": "_goose/dictation/config" @@ -926,7 +1065,10 @@ "type": "boolean" }, "host": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "description": { "type": "string" @@ -935,19 +1077,34 @@ "type": "boolean" }, "settingsPath": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "configKey": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "modelConfigKey": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "defaultModel": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "selectedModel": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "availableModels": { "type": "array", @@ -957,7 +1114,11 @@ "default": [] } }, - "required": ["configured", "description", "usesProviderConfig"], + "required": [ + "configured", + "description", + "usesProviderConfig" + ], "description": "Per-provider configuration status." }, "DictationModelOption": { @@ -973,7 +1134,11 @@ "type": "string" } }, - "required": ["id", "label", "description"] + "required": [ + "id", + "label", + "description" + ] }, "DictationModelsListRequest": { "type": "object", @@ -991,7 +1156,9 @@ } } }, - "required": ["models"], + "required": [ + "models" + ], "x-side": "agent", "x-method": "_goose/dictation/models/list" }, @@ -1034,7 +1201,9 @@ "type": "string" } }, - "required": ["modelId"], + "required": [ + "modelId" + ], "description": "Kick off a background download of a local Whisper model.", "x-side": "agent", "x-method": "_goose/dictation/models/download" @@ -1046,7 +1215,9 @@ "type": "string" } }, - "required": ["modelId"], + "required": [ + "modelId" + ], "description": "Poll the progress of an in-flight download.", "x-side": "agent", "x-method": "_goose/dictation/models/download/progress" @@ -1089,10 +1260,18 @@ "description": "serde lowercase of DownloadStatus: \"downloading\" | \"completed\" | \"failed\" | \"cancelled\"" }, "error": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, - "required": ["bytesDownloaded", "totalBytes", "progressPercent", "status"] + "required": [ + "bytesDownloaded", + "totalBytes", + "progressPercent", + "status" + ] }, "DictationModelCancelRequest": { "type": "object", @@ -1101,7 +1280,9 @@ "type": "string" } }, - "required": ["modelId"], + "required": [ + "modelId" + ], "description": "Cancel an in-flight download.", "x-side": "agent", "x-method": "_goose/dictation/models/cancel" @@ -1113,7 +1294,9 @@ "type": "string" } }, - "required": ["modelId"], + "required": [ + "modelId" + ], "description": "Delete a downloaded local Whisper model from disk.", "x-side": "agent", "x-method": "_goose/dictation/models/delete" @@ -1128,7 +1311,10 @@ "type": "string" } }, - "required": ["provider", "modelId"], + "required": [ + "provider", + "modelId" + ], "description": "Persist the user's model selection for a given provider.", "x-side": "agent", "x-method": "_goose/dictation/model/select" @@ -1464,12 +1650,18 @@ }, { "description": "Untyped params", - "type": ["object", "null"] + "type": [ + "object", + "null" + ] } ] } }, - "required": ["id", "method"], + "required": [ + "id", + "method" + ], "type": "object", "x-docs-ignore": true }, @@ -1652,7 +1844,9 @@ ] } }, - "required": ["id"], + "required": [ + "id" + ], "title": "Success", "type": "object" }, @@ -1669,13 +1863,19 @@ }, "data": {} }, - "required": ["code", "message"] + "required": [ + "code", + "message" + ] }, "id": { "type": "string" } }, - "required": ["id", "error"], + "required": [ + "id", + "error" + ], "title": "Error", "type": "object" } diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 564a57e1c739..7c28f0159633 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -3220,8 +3220,6 @@ impl GooseAcpAgent { &req.path, &req.description, &req.content, - req.global, - req.project_dir.as_deref(), )?; Ok(UpdateSourceResponse { source }) } @@ -3231,12 +3229,7 @@ impl GooseAcpAgent { &self, req: DeleteSourceRequest, ) -> Result { - crate::sources::delete_source( - req.source_type, - &req.path, - req.global, - req.project_dir.as_deref(), - )?; + crate::sources::delete_source(req.source_type, &req.path)?; Ok(EmptyResponse {}) } @@ -3245,12 +3238,7 @@ impl GooseAcpAgent { &self, req: ExportSourceRequest, ) -> Result { - let (json, filename) = crate::sources::export_source( - req.source_type, - &req.path, - req.global, - req.project_dir.as_deref(), - )?; + let (json, filename) = crate::sources::export_source(req.source_type, &req.path)?; Ok(ExportSourceResponse { json, filename }) } diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index 5fbb97841163..2b67e7d3e54f 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -88,42 +88,56 @@ pub(crate) fn validate_skill_name(name: &str) -> Result<(), Error> { Ok(()) } -pub(crate) fn resolve_skill_dir( - path: &str, - global: bool, - project_dir: Option<&str>, -) -> Result { +fn canonicalize_or_original(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn inferred_editable_skill_root(path: &Path) -> Option { + let canonical_path = canonicalize_or_original(path); + + if let Some(global_root) = global_skills_dir() { + let canonical_global_root = canonicalize_or_original(&global_root); + if canonical_path.starts_with(&canonical_global_root) { + return Some(canonical_global_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") + && parent.file_name().and_then(|name| name.to_str()) == Some(".goose"); + is_project_skills_root.then(|| ancestor.to_path_buf()) + }) +} + +pub(crate) fn resolve_skill_dir(path: &str) -> Result { if path.is_empty() { return Err(Error::invalid_params().data("Source path must not be empty")); } - let base_dir = skill_base_dir(global, project_dir)?; - let joined_dir = base_dir.join(path); - let canonical_dir = joined_dir + let canonical_dir = Path::new(path) .canonicalize() .map_err(|_| Error::invalid_params().data(format!("Source \"{}\" not found", path)))?; - let canonical_base_dir = base_dir.canonicalize().unwrap_or_else(|_| base_dir.clone()); - - if !canonical_dir.starts_with(&canonical_base_dir) { - return Err(Error::invalid_params().data(format!("Source \"{}\" not found", path))); - } - if !canonical_dir.is_dir() || !canonical_dir.join("SKILL.md").is_file() { + if inferred_editable_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 is_editable_skill_dir(path: &Path, working_dir: Option<&Path>) -> bool { - let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); +pub(crate) fn is_editable_skill_dir(path: &Path) -> bool { + inferred_editable_skill_root(path).is_some() +} - editable_skill_dirs(working_dir) - .into_iter() - .any(|(dir, _)| { - let editable_dir = dir.canonicalize().unwrap_or(dir); - canonical_path.starts_with(editable_dir) - }) +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 { diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index 2b8678668629..70365b5aee57 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -1,7 +1,7 @@ //! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom use crate::skills::{ - build_skill_md, discover_skills, infer_skill_name, is_editable_skill_dir, + build_skill_md, discover_skills, infer_skill_name, is_editable_skill_dir, is_global_skill_dir, parse_skill_frontmatter, resolve_skill_dir, skill_base_dir, validate_skill_name, }; use fs_err as fs; @@ -106,11 +106,9 @@ pub fn update_source( path: &str, description: &str, content: &str, - global: bool, - project_dir: Option<&str>, ) -> Result { require_skill_type(source_type)?; - let dir = resolve_skill_dir(path, global, project_dir)?; + let dir = resolve_skill_dir(path)?; let name = infer_skill_name(&dir); let file_path = dir.join("SKILL.md"); @@ -124,19 +122,14 @@ pub fn update_source( description, content, &dir, - global, + is_global_skill_dir(&dir), true, )) } -pub fn delete_source( - source_type: SourceType, - path: &str, - global: bool, - project_dir: Option<&str>, -) -> Result<(), Error> { +pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> { require_skill_type(source_type)?; - let dir = resolve_skill_dir(path, global, project_dir)?; + let dir = resolve_skill_dir(path)?; fs::remove_dir_all(&dir) .map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?; Ok(()) @@ -159,7 +152,7 @@ pub fn list_sources( .into_iter() .filter(|s| s.source_type == SourceType::Skill) .map(|s| { - let editable = is_editable_skill_dir(Path::new(&s.directory), working_dir.as_deref()); + let editable = is_editable_skill_dir(Path::new(&s.directory)); with_editable(s, editable) }) .collect(); @@ -168,14 +161,9 @@ pub fn list_sources( Ok(sources) } -pub fn export_source( - source_type: SourceType, - path: &str, - global: bool, - project_dir: Option<&str>, -) -> Result<(String, String), Error> { +pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, String), Error> { require_skill_type(source_type)?; - let dir = resolve_skill_dir(path, global, project_dir)?; + let dir = resolve_skill_dir(path)?; let md = dir.join("SKILL.md"); let raw = fs::read_to_string(&md) @@ -330,17 +318,15 @@ mod tests { let updated = update_source( SourceType::Skill, - "my-skill", + created.directory.as_str(), "now does a different thing", "step three", - false, - Some(project), ) .unwrap(); 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!(!dir.exists()); } @@ -379,13 +365,9 @@ mod tests { ) .unwrap(); - let (json, filename) = export_source( - SourceType::Skill, - "portable", - false, - Some(project_a.to_str().unwrap()), - ) - .unwrap(); + let portable_dir = project_a.join(".goose").join("skills").join("portable"); + let (json, filename) = + export_source(SourceType::Skill, portable_dir.to_str().unwrap()).unwrap(); assert_eq!(filename, "portable.skill.json"); let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap(); @@ -417,25 +399,25 @@ mod tests { #[test] fn update_rejects_nonexistent_source() { let tmp = TempDir::new().unwrap(); - let project = tmp.path().to_str().unwrap(); - let err = update_source( - SourceType::Skill, - "no-such-skill", - "d", - "c", - false, - Some(project), - ) - .unwrap_err(); + let missing_dir = tmp + .path() + .join(".goose") + .join("skills") + .join("no-such-skill"); + let err = + update_source(SourceType::Skill, missing_dir.to_str().unwrap(), "d", "c").unwrap_err(); assert!(format!("{:?}", err).contains("not found")); } #[test] fn delete_rejects_nonexistent_source() { let tmp = TempDir::new().unwrap(); - let project = tmp.path().to_str().unwrap(); - let err = - delete_source(SourceType::Skill, "no-such-skill", false, Some(project)).unwrap_err(); + 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")); } @@ -455,17 +437,16 @@ mod tests { .unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); - let err = - update_source(SourceType::Recipe, "x", "d", "c", false, Some(project)).unwrap_err(); + let err = update_source(SourceType::Recipe, "x", "d", "c").unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); - let err = delete_source(SourceType::Subrecipe, "x", false, Some(project)).unwrap_err(); + 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", false, Some(project)).unwrap_err(); + let err = export_source(SourceType::Recipe, "x").unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); } @@ -484,13 +465,12 @@ mod tests { ) .unwrap(); + let skill_dir = tmp.path().join(".goose").join("skills").join("my-dir"); let updated = update_source( SourceType::Skill, - "my-dir", + skill_dir.to_str().unwrap(), "new description", "new body", - false, - Some(project), ) .unwrap(); // Name is derived from the frontmatter written by create_source @@ -509,13 +489,12 @@ mod tests { ) .unwrap(); + let attempted_escape = project.join(".goose").join("escaped"); let err = update_source( SourceType::Skill, - "../escaped", + attempted_escape.to_str().unwrap(), "new description", "new content", - false, - Some(project.to_str().unwrap()), ) .unwrap_err(); assert!(format!("{:?}", err).contains("not found")); @@ -542,13 +521,35 @@ mod tests { ) .unwrap(); + let global_root = tmp.path().join("global-root"); + std::fs::create_dir_all(global_root.join("config")).unwrap(); + std::fs::create_dir_all(global_root.join("data")).unwrap(); + std::fs::create_dir_all(global_root.join("state")).unwrap(); + std::env::set_var("GOOSE_PATH_ROOT", &global_root); + + let home = tmp.path().join("home"); + let global_skill = home.join(".agents").join("skills").join("global-skill"); + std::fs::create_dir_all(&global_skill).unwrap(); + std::fs::write( + global_skill.join("SKILL.md"), + "---\nname: global-skill\ndescription: Global skill\n---\ncontent", + ) + .unwrap(); + std::env::set_var("HOME", &home); + let listed = list_sources(Some(SourceType::Skill), Some(project.to_str().unwrap())).unwrap(); let goose_skill = listed.iter().find(|s| s.name == "goose-skill").unwrap(); assert!(goose_skill.editable); + let global_skill = listed.iter().find(|s| s.name == "global-skill").unwrap(); + assert!(global_skill.editable); + let claude_skill = listed.iter().find(|s| s.name == "claude-skill").unwrap(); assert!(!claude_skill.editable); + + std::env::remove_var("GOOSE_PATH_ROOT"); + std::env::remove_var("HOME"); } } diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index 3f8679f49ea6..21f879471cfc 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -1,28 +1,38 @@ +import type { SourceEntry } from "@aaif/goose-sdk"; import { getClient } from "@/shared/api/acpConnection"; +const SKILL_SOURCE_TYPE = "skill" as const; + export interface SkillInfo { name: string; description: string; instructions: string; path: string; + editable: boolean; + fileLocation: string; } -// Shape returned by _goose/sources/*. Narrowed to skill-type sources here. -interface SourceEntry { - type: "skill"; - name: string; - description: string; - content: string; - directory: string; - global: boolean; +type SkillSourceEntry = SourceEntry & { type: typeof SKILL_SOURCE_TYPE }; + +function isSkillSource(source: SourceEntry): source is SkillSourceEntry { + return source.type === SKILL_SOURCE_TYPE; } -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 { name: source.name, description: source.description, instructions: source.content, path: source.directory, + editable: source.editable ?? false, + fileLocation: getSkillFileLocation(source.directory), }; } @@ -32,8 +42,8 @@ export async function createSkill( instructions: string, ): Promise { const client = await getClient(); - await client.extMethod("_goose/sources/create", { - type: "skill", + await client.goose.GooseSourcesCreate({ + type: SKILL_SOURCE_TYPE, name, description, content: instructions, @@ -43,17 +53,17 @@ export async function createSkill( export async function listSkills(): Promise { const client = await getClient(); - const raw = await client.extMethod("_goose/sources/list", { type: "skill" }); - const sources = (raw.sources ?? []) as SourceEntry[]; - return sources.map(toSkillInfo); + const response = await client.goose.GooseSourcesList({ + type: SKILL_SOURCE_TYPE, + }); + return response.sources.filter(isSkillSource).map(toSkillInfo); } export async function deleteSkill(path: string): Promise { const client = await getClient(); - await client.extMethod("_goose/sources/delete", { - type: "skill", + await client.goose.GooseSourcesDelete({ + type: SKILL_SOURCE_TYPE, path, - global: true, }); } @@ -63,26 +73,29 @@ export async function updateSkill( instructions: string, ): Promise { const client = await getClient(); - const raw = await client.extMethod("_goose/sources/update", { - type: "skill", + const response = await client.goose.GooseSourcesUpdate({ + type: SKILL_SOURCE_TYPE, path, description, 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( path: string, ): Promise<{ json: string; filename: string }> { const client = await getClient(); - const raw = await client.extMethod("_goose/sources/export", { - type: "skill", + const response = await client.goose.GooseSourcesExport({ + type: SKILL_SOURCE_TYPE, 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( @@ -92,12 +105,13 @@ export async function importSkills( if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) { throw new Error("File must have a .skill.json or .json extension"); } + const data = new TextDecoder().decode(new Uint8Array(fileBytes)); const client = await getClient(); - const raw = await client.extMethod("_goose/sources/import", { + const response = await client.goose.GooseSourcesImport({ data, global: true, }); - const sources = (raw.sources ?? []) as SourceEntry[]; - return sources.map(toSkillInfo); + + return response.sources.filter(isSkillSource).map(toSkillInfo); } diff --git a/ui/goose2/src/features/skills/ui/SkillsView.tsx b/ui/goose2/src/features/skills/ui/SkillsView.tsx index e2f1445c77f0..5b301fbdb9e3 100644 --- a/ui/goose2/src/features/skills/ui/SkillsView.tsx +++ b/ui/goose2/src/features/skills/ui/SkillsView.tsx @@ -9,6 +9,7 @@ import { Copy, Download, Upload, + Lock, } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { SearchBar } from "@/shared/ui/SearchBar"; @@ -55,6 +56,10 @@ function SkillCardMenu({ }) { const { t } = useTranslation(["skills", "common"]); + if (!skill.editable) { + return null; + } + return ( @@ -98,7 +103,12 @@ export function SkillsView() { const [search, setSearch] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [editingSkill, setEditingSkill] = useState< - | { name: string; description: string; instructions: string; path: string } + | { + name: string; + description: string; + instructions: string; + path: string; + } | undefined >(undefined); const [skills, setSkills] = useState([]); @@ -112,7 +122,6 @@ export function SkillsView() { const result = await listSkills(); setSkills(result); } catch { - // skills directory may not exist yet setSkills([]); } finally { setLoading(false); @@ -139,6 +148,10 @@ export function SkillsView() { }; const handleEdit = (skill: SkillInfo) => { + if (!skill.editable) { + return; + } + setEditingSkill({ name: skill.name, description: skill.description, @@ -197,7 +210,6 @@ export function SkillsView() { console.error("Failed to import skill:", err); } - // Reset the input so the same file can be re-selected if (importInputRef.current) { importInputRef.current.value = ""; } @@ -244,7 +256,6 @@ export function SkillsView() {
- {/* Header */}

@@ -283,14 +294,12 @@ export function SkillsView() {

- {/* Search */} - {/* Skills list */} {!loading && filtered.length > 0 && (
{filtered.map((skill) => ( @@ -298,13 +307,29 @@ export function SkillsView() { key={skill.name} className="flex items-start justify-between gap-3 rounded-lg border border-border px-4 py-3" > -
-

{skill.name}

+
+
+

{skill.name}

+ {!skill.editable && ( + + + {t("view.readOnlyBadge")} + + )} +
{skill.description && (

{skill.description}

)} + {!skill.editable && ( +
+

{t("view.readOnlyDescription")}

+

+ {skill.fileLocation} +

+
+ )}
))} - {/* New Skill card */} - onEdit(skill)}> - - {t("common:actions.edit")} - + {skill.editable && ( + onEdit(skill)}> + + {t("common:actions.edit")} + + )} onDuplicate(skill)}> {t("common:actions.duplicate")} @@ -86,13 +84,15 @@ function SkillCardMenu({ {t("common:actions.export")} - onDelete(skill)} - > - - {t("common:actions.delete")} - + {skill.editable && ( + onDelete(skill)} + > + + {t("common:actions.delete")} + + )} ); diff --git a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx index 6752d1371128..95c58b0540c4 100644 --- a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx +++ b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx @@ -211,6 +211,7 @@ describe("SkillsView", () => { fileLocation: "/Users/test/.claude/skills/claude-skill/SKILL.md", }, ]); + const user = userEvent.setup(); render(); @@ -224,8 +225,19 @@ describe("SkillsView", () => { expect( screen.getByText("/Users/test/.claude/skills/claude-skill/SKILL.md"), ).toBeInTheDocument(); + + await user.click(screen.getByLabelText("Options for claude-skill")); + expect( + screen.queryByRole("menuitem", { name: /edit/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /duplicate/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /export/i }), + ).toBeInTheDocument(); expect( - screen.queryByLabelText("Options for claude-skill"), + screen.queryByRole("menuitem", { name: /delete/i }), ).not.toBeInTheDocument(); }); From f8e6464f06bf7d60ebfc75d5a53eb14661c8411d Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 23 Apr 2026 09:32:53 -0400 Subject: [PATCH 13/20] clean up --- .../ui/__tests__/CreateSkillDialog.test.tsx | 21 ---------------- .../skills/ui/__tests__/SkillsView.test.tsx | 24 ------------------- 2 files changed, 45 deletions(-) diff --git a/ui/goose2/src/features/skills/ui/__tests__/CreateSkillDialog.test.tsx b/ui/goose2/src/features/skills/ui/__tests__/CreateSkillDialog.test.tsx index 189f6349592a..acdd3b711234 100644 --- a/ui/goose2/src/features/skills/ui/__tests__/CreateSkillDialog.test.tsx +++ b/ui/goose2/src/features/skills/ui/__tests__/CreateSkillDialog.test.tsx @@ -66,18 +66,6 @@ describe("CreateSkillDialog", () => { // ── Name validation ──────────────────────────────────────────────── describe("name validation", () => { - it("allows valid skill names", async () => { - const user = userEvent.setup(); - render(); - const nameInput = screen.getByPlaceholderText("my-skill-name"); - - await user.type(nameInput, "my-skill"); - expect(nameInput).toHaveValue("my-skill"); - expect( - screen.queryByText(/cannot start or end with a hyphen/i), - ).not.toBeInTheDocument(); - }); - it("allows consecutive hyphens to match backend validation", async () => { const user = userEvent.setup(); render(); @@ -107,15 +95,6 @@ describe("CreateSkillDialog", () => { expect(nameInput).toHaveValue("my-skill"); }); - it("allows typing hyphens", async () => { - const user = userEvent.setup(); - render(); - 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 () => { const user = userEvent.setup(); render(); diff --git a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx index 95c58b0540c4..20fc6d656b8c 100644 --- a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx +++ b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx @@ -240,29 +240,5 @@ describe("SkillsView", () => { screen.queryByRole("menuitem", { name: /delete/i }), ).not.toBeInTheDocument(); }); - - it("shows ~/.agents/skills entries as editable when returned editable by the API", async () => { - listSkills.mockResolvedValue([ - { - name: "managed-skill", - description: "Managed by Goose", - instructions: "Use this skill...", - path: "/Users/test/.agents/skills/managed-skill", - editable: true, - fileLocation: "/Users/test/.agents/skills/managed-skill/SKILL.md", - }, - ]); - const user = userEvent.setup(); - - render(); - - expect(await screen.findByText("managed-skill")).toBeInTheDocument(); - expect(screen.queryByText("Read-only")).not.toBeInTheDocument(); - - await user.click(screen.getByLabelText("Options for managed-skill")); - expect( - screen.getByRole("menuitem", { name: /edit/i }), - ).toBeInTheDocument(); - }); }); }); From aee5c41acf43ea1d9388316663bfae04fe46265c Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 23 Apr 2026 09:38:41 -0400 Subject: [PATCH 14/20] type gen --- crates/goose/acp-schema.json | 10 +++++----- ui/sdk/src/generated/types.gen.ts | 12 ++++++------ ui/sdk/src/generated/zod.gen.ts | 12 ++++++------ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 376ec347a574..39f01559a46d 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -750,7 +750,7 @@ "content", "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-method": "_goose/sources/create" }, @@ -886,7 +886,7 @@ "description", "content" ], - "description": "Update an existing source's description and content.", + "description": "Update an existing source's description and content by absolute path.", "x-side": "agent", "x-method": "_goose/sources/update" }, @@ -917,7 +917,7 @@ "type", "path" ], - "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-method": "_goose/sources/delete" }, @@ -935,7 +935,7 @@ "type", "path" ], - "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-method": "_goose/sources/export" }, @@ -976,7 +976,7 @@ "data", "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-method": "_goose/sources/import" }, diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 717c4aba8a89..9e5c9f795b6c 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -374,7 +374,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 = { type: SourceType; @@ -444,7 +444,7 @@ export type ListSourcesResponse = { }; /** - * Update an existing source's description and content. + * Update an existing source's description and content by absolute path. */ export type UpdateSourceRequest = { type: SourceType; @@ -458,7 +458,7 @@ 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 = { type: SourceType; @@ -466,7 +466,7 @@ export type DeleteSourceRequest = { }; /** - * Export a source as a portable JSON payload. + * Export a source at an absolute path as a portable JSON payload. */ export type ExportSourceRequest = { type: SourceType; @@ -480,8 +480,8 @@ export type ExportSourceResponse = { /** * 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 - * `-imported` suffix is appended. + * The imported source is written into the explicit target scope; on name + * collisions a `-imported` suffix is appended. */ export type ImportSourcesRequest = { data: string; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index ed14c12c5ff7..3937531847df 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -330,7 +330,7 @@ export const zSourceType = z.enum([ ]); /** - * 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({ type: zSourceType, @@ -386,7 +386,7 @@ export const zListSourcesResponse = z.object({ }); /** - * Update an existing source's description and content. + * Update an existing source's description and content by absolute path. */ export const zUpdateSourceRequest = z.object({ type: zSourceType, @@ -400,7 +400,7 @@ 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({ type: zSourceType, @@ -408,7 +408,7 @@ export const zDeleteSourceRequest = z.object({ }); /** - * 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({ type: zSourceType, @@ -422,8 +422,8 @@ export const zExportSourceResponse = z.object({ /** * 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 - * `-imported` suffix is appended. + * The imported source is written into the explicit target scope; on name + * collisions a `-imported` suffix is appended. */ export const zImportSourcesRequest = z.object({ data: z.string(), From 6c43c51fd9bde65818a92760a46c156d83d7d401 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 23 Apr 2026 09:43:43 -0400 Subject: [PATCH 15/20] show loading --- ui/goose2/src/features/skills/ui/SkillsView.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/goose2/src/features/skills/ui/SkillsView.tsx b/ui/goose2/src/features/skills/ui/SkillsView.tsx index 8d015ae23df4..354b046a4f6a 100644 --- a/ui/goose2/src/features/skills/ui/SkillsView.tsx +++ b/ui/goose2/src/features/skills/ui/SkillsView.tsx @@ -118,6 +118,7 @@ export function SkillsView() { const importInputRef = useRef(null); const loadSkills = useCallback(async () => { + setLoading(true); try { const result = await listSkills(); setSkills(result); @@ -300,6 +301,12 @@ export function SkillsView() { placeholder={t("view.searchPlaceholder")} /> + {loading && ( +
+ {t("common:labels.loading")} +
+ )} + {!loading && filtered.length > 0 && (
{filtered.map((skill) => ( From ad065902a369feaff83e65c993d66d412c565190 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 23 Apr 2026 10:48:04 -0400 Subject: [PATCH 16/20] edit name --- crates/goose-sdk/src/custom_requests.rs | 3 +- crates/goose/acp-schema.json | 6 +- crates/goose/src/acp/server.rs | 1 + crates/goose/src/sources.rs | 89 ++++++++++++++++--- ui/goose2/src/features/skills/api/skills.ts | 2 + .../features/skills/ui/CreateSkillDialog.tsx | 48 +++++++--- .../src/features/skills/ui/SkillsView.tsx | 31 ++----- .../ui/__tests__/CreateSkillDialog.test.tsx | 72 ++++++++++++++- .../skills/ui/__tests__/SkillsView.test.tsx | 28 +++--- .../src/shared/i18n/locales/en/skills.json | 2 + .../src/shared/i18n/locales/es/skills.json | 2 + ui/goose2/tests/e2e/fixtures/tauri-mock.ts | 13 ++- ui/sdk/src/generated/types.gen.ts | 3 +- ui/sdk/src/generated/zod.gen.ts | 3 +- 14 files changed, 231 insertions(+), 72 deletions(-) diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index e7c3a520b6b7..86060598adb7 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -359,7 +359,7 @@ pub struct ListSourcesResponse { pub sources: Vec, } -/// Update an existing source's description and content by absolute path. +/// Update an existing source's name, description, and content by absolute path. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/sources/update", response = UpdateSourceResponse)] #[serde(rename_all = "camelCase")] @@ -367,6 +367,7 @@ pub struct UpdateSourceRequest { #[serde(rename = "type")] pub source_type: SourceType, pub path: String, + pub name: String, pub description: String, pub content: String, } diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 39f01559a46d..d16baf96bfc3 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -873,6 +873,9 @@ "path": { "type": "string" }, + "name": { + "type": "string" + }, "description": { "type": "string" }, @@ -883,10 +886,11 @@ "required": [ "type", "path", + "name", "description", "content" ], - "description": "Update an existing source's description and content by absolute path.", + "description": "Update an existing source's name, description, and content by absolute path.", "x-side": "agent", "x-method": "_goose/sources/update" }, diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 7c28f0159633..bc59f7481984 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -3218,6 +3218,7 @@ impl GooseAcpAgent { let source = crate::sources::update_source( req.source_type, &req.path, + &req.name, &req.description, &req.content, )?; diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index 656920b314f0..98b18b5a454b 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -105,26 +105,53 @@ pub fn create_source( pub fn update_source( source_type: SourceType, path: &str, + name: &str, description: &str, content: &str, ) -> Result { require_skill_type(source_type)?; - let dir = resolve_skill_dir(path)?; - let name = infer_skill_name(&dir); + validate_skill_name(name)?; - let file_path = dir.join("SKILL.md"); - let md = build_skill_md(&name, description, content); + let dir = resolve_discoverable_skill_dir(path)?; + let current_dir_name = dir + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| Error::internal_error().data("Failed to resolve source directory name"))?; + + let target_dir = if name == current_dir_name { + dir.clone() + } else { + let base_dir = dir.parent().ok_or_else(|| { + Error::internal_error().data("Failed to resolve source base directory") + })?; + let target_dir = base_dir.join(name); + + if target_dir.exists() { + return Err( + Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) + ); + } + + fs::rename(&dir, &target_dir).map_err(|e| { + Error::internal_error().data(format!("Failed to rename source directory: {e}")) + })?; + + target_dir + }; + + let file_path = target_dir.join("SKILL.md"); + let md = build_skill_md(name, description, content); fs::write(&file_path, md) .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; Ok(source_entry( source_type, - &name, + name, description, content, - &dir, - is_global_skill_dir(&dir), - true, + &target_dir, + is_global_skill_dir(&target_dir), + is_editable_skill_dir(&target_dir), )) } @@ -320,6 +347,7 @@ mod tests { let updated = update_source( SourceType::Skill, created.directory.as_str(), + "my-skill", "now does a different thing", "step three", ) @@ -404,6 +432,37 @@ mod tests { 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"); + assert!(!updated.editable); + + let raw = std::fs::read_to_string(claude_skill_dir.join("SKILL.md")).unwrap(); + assert!(raw.contains("description: 'updated description'")); + assert!(raw.contains("updated body")); + } + #[test] fn import_collision_appends_suffix() { let tmp = TempDir::new().unwrap(); @@ -431,8 +490,14 @@ mod tests { .join(".goose") .join("skills") .join("no-such-skill"); - let err = - update_source(SourceType::Skill, missing_dir.to_str().unwrap(), "d", "c").unwrap_err(); + let err = update_source( + SourceType::Skill, + missing_dir.to_str().unwrap(), + "no-such-skill", + "d", + "c", + ) + .unwrap_err(); assert!(format!("{:?}", err).contains("not found")); } @@ -464,7 +529,7 @@ mod tests { .unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); - let err = update_source(SourceType::Recipe, "x", "d", "c").unwrap_err(); + 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(); @@ -496,6 +561,7 @@ mod tests { let updated = update_source( SourceType::Skill, skill_dir.to_str().unwrap(), + "my-dir", "new description", "new body", ) @@ -520,6 +586,7 @@ mod tests { let err = update_source( SourceType::Skill, attempted_escape.to_str().unwrap(), + "escaped", "new description", "new content", ) diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index 21f879471cfc..36c3ac1764ef 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -69,6 +69,7 @@ export async function deleteSkill(path: string): Promise { export async function updateSkill( path: string, + name: string, description: string, instructions: string, ): Promise { @@ -76,6 +77,7 @@ export async function updateSkill( const response = await client.goose.GooseSourcesUpdate({ type: SKILL_SOURCE_TYPE, path, + name, description, content: instructions, }); diff --git a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx index bfb1cdcdc3b0..0497ac61f15a 100644 --- a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx +++ b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Label } from "@/shared/ui/label"; @@ -31,6 +30,28 @@ function isValidSkillName(name: string): boolean { ); } +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 { isOpen: boolean; onClose: () => void; @@ -40,6 +61,7 @@ interface CreateSkillDialogProps { description: string; instructions: string; path: string; + fileLocation: string; }; } @@ -77,13 +99,7 @@ export function CreateSkillDialog({ const canSave = nameValid && description.trim().length > 0 && !saving; const handleNameChange = (raw: string) => { - if (isEditing) return; - const formatted = raw - .toLowerCase() - .replace(/[^a-z0-9-]/g, "-") - .replace(/^-/, "") - .slice(0, MAX_SKILL_NAME_LENGTH); - setName(formatted); + setName(formatSkillName(raw)); setError(null); }; @@ -102,7 +118,12 @@ export function CreateSkillDialog({ setError(null); try { if (isEditing) { - await updateSkill(editingSkill.path, description.trim(), instructions); + await updateSkill( + editingSkill.path, + name, + description.trim(), + instructions, + ); } else { await createSkill(name, description.trim(), instructions); } @@ -141,8 +162,6 @@ export function CreateSkillDialog({ value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder={t("dialog.namePlaceholder")} - readOnly={isEditing} - className={cn(isEditing && "opacity-60 cursor-not-allowed")} /> {name.length > 0 && !nameValid && (

@@ -167,6 +186,13 @@ export function CreateSkillDialog({ />

+ {isEditing && editingSkill && ( +

+ {t("dialog.pathOnDisk")}:{" "} + {getRenamedSkillFileLocation(editingSkill.fileLocation, name)} +

+ )} + {/* Instructions */}