From b1235e7c01223e22feb66ef12ddfad3c7aa902ab Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Mon, 20 Apr 2026 23:09:21 -0400 Subject: [PATCH] Manage skills as sources over ACP (#8675) Co-authored-by: Lifei Zhou --- Cargo.lock | 1 + crates/goose-acp/acp-meta.json | 30 ++ crates/goose-acp/acp-schema.json | 387 ++++++++++++++ crates/goose-acp/src/server.rs | 79 +++ crates/goose-sdk/src/custom_requests.rs | 138 +++++ crates/goose/Cargo.toml | 1 + crates/goose/src/lib.rs | 1 + crates/goose/src/sources.rs | 526 ++++++++++++++++++++ ui/goose2/src-tauri/src/commands/acp.rs | 7 + ui/goose2/src-tauri/src/commands/mod.rs | 1 - ui/goose2/src-tauri/src/commands/skills.rs | 319 ------------ ui/goose2/src-tauri/src/lib.rs | 6 - ui/goose2/src/features/skills/api/skills.ts | 73 ++- ui/goose2/tests/e2e/fixtures/tauri-mock.ts | 77 ++- ui/sdk/src/generated/client.gen.ts | 55 ++ ui/sdk/src/generated/index.ts | 32 +- ui/sdk/src/generated/types.gen.ts | 117 ++++- ui/sdk/src/generated/zod.gen.ts | 135 +++++ 18 files changed, 1622 insertions(+), 363 deletions(-) create mode 100644 crates/goose/src/sources.rs delete mode 100644 ui/goose2/src-tauri/src/commands/skills.rs diff --git a/Cargo.lock b/Cargo.lock index e38f0b0f0a..c799a881e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4358,6 +4358,7 @@ dependencies = [ "fs-err", "fs2", "futures", + "goose-sdk", "goose-test-support", "http 1.4.0", "ignore", diff --git a/crates/goose-acp/acp-meta.json b/crates/goose-acp/acp-meta.json index 413707f6a6..8a2e7fe4ff 100644 --- a/crates/goose-acp/acp-meta.json +++ b/crates/goose-acp/acp-meta.json @@ -110,6 +110,36 @@ "requestType": "UnarchiveSessionRequest", "responseType": "EmptyResponse" }, + { + "method": "_goose/sources/create", + "requestType": "CreateSourceRequest", + "responseType": "CreateSourceResponse" + }, + { + "method": "_goose/sources/list", + "requestType": "ListSourcesRequest", + "responseType": "ListSourcesResponse" + }, + { + "method": "_goose/sources/update", + "requestType": "UpdateSourceRequest", + "responseType": "UpdateSourceResponse" + }, + { + "method": "_goose/sources/delete", + "requestType": "DeleteSourceRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/sources/export", + "requestType": "ExportSourceRequest", + "responseType": "ExportSourceResponse" + }, + { + "method": "_goose/sources/import", + "requestType": "ImportSourcesRequest", + "responseType": "ImportSourcesResponse" + }, { "method": "_goose/dictation/transcribe", "requestType": "DictationTranscribeRequest", diff --git a/crates/goose-acp/acp-schema.json b/crates/goose-acp/acp-schema.json index 4ae0b23633..8fe12e0019 100644 --- a/crates/goose-acp/acp-schema.json +++ b/crates/goose-acp/acp-schema.json @@ -795,6 +795,299 @@ "x-side": "agent", "x-method": "_goose/session/unarchive" }, + "CreateSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "content": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ], + "description": "Absolute path to the project root. Required when `global` is false." + } + }, + "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" + ], + "description": "The type of source entity." + }, + "CreateSourceResponse": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/SourceEntry" + } + }, + "required": [ + "source" + ], + "x-side": "agent", + "x-method": "_goose/sources/create" + }, + "SourceEntry": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "content": { + "type": "string" + }, + "directory": { + "type": "string", + "description": "Absolute path to the source's directory on disk." + }, + "global": { + "type": "boolean", + "description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project." + } + }, + "required": [ + "type", + "name", + "description", + "content", + "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." + }, + "ListSourcesRequest": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/$defs/SourceType" + }, + { + "type": "null" + } + ] + }, + "projectDir": { + "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.", + "x-side": "agent", + "x-method": "_goose/sources/list" + }, + "ListSourcesResponse": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/SourceEntry" + } + } + }, + "required": [ + "sources" + ], + "x-side": "agent", + "x-method": "_goose/sources/list" + }, + "UpdateSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "content": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "name", + "description", + "content", + "global" + ], + "description": "Update an existing source's description and content.", + "x-side": "agent", + "x-method": "_goose/sources/update" + }, + "UpdateSourceResponse": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/SourceEntry" + } + }, + "required": [ + "source" + ], + "x-side": "agent", + "x-method": "_goose/sources/update" + }, + "DeleteSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "name", + "global" + ], + "description": "Delete a source and its on-disk directory.", + "x-side": "agent", + "x-method": "_goose/sources/delete" + }, + "ExportSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "name", + "global" + ], + "description": "Export a source as a portable JSON payload.", + "x-side": "agent", + "x-method": "_goose/sources/export" + }, + "ExportSourceResponse": { + "type": "object", + "properties": { + "json": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "json", + "filename" + ], + "x-side": "agent", + "x-method": "_goose/sources/export" + }, + "ImportSourcesRequest": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "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" + }, + "ImportSourcesResponse": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/SourceEntry" + } + } + }, + "required": [ + "sources" + ], + "x-side": "agent", + "x-method": "_goose/sources/import" + }, "DictationTranscribeRequest": { "type": "object", "properties": { @@ -1328,6 +1621,60 @@ "description": "Params for _goose/session/unarchive", "title": "UnarchiveSessionRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateSourceRequest" + } + ], + "description": "Params for _goose/sources/create", + "title": "CreateSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSourcesRequest" + } + ], + "description": "Params for _goose/sources/list", + "title": "ListSourcesRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateSourceRequest" + } + ], + "description": "Params for _goose/sources/update", + "title": "UpdateSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DeleteSourceRequest" + } + ], + "description": "Params for _goose/sources/delete", + "title": "DeleteSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExportSourceRequest" + } + ], + "description": "Params for _goose/sources/export", + "title": "ExportSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImportSourcesRequest" + } + ], + "description": "Params for _goose/sources/import", + "title": "ImportSourcesRequest" + }, { "allOf": [ { @@ -1534,6 +1881,46 @@ ], "title": "ImportSessionResponse" }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateSourceResponse" + } + ], + "title": "CreateSourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSourcesResponse" + } + ], + "title": "ListSourcesResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateSourceResponse" + } + ], + "title": "UpdateSourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExportSourceResponse" + } + ], + "title": "ExportSourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImportSourcesResponse" + } + ], + "title": "ImportSourcesResponse" + }, { "allOf": [ { diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 9a4dc8ca48..09d483a2c5 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -3115,6 +3115,85 @@ impl GooseAcpAgent { Ok(EmptyResponse {}) } + #[custom_method(CreateSourceRequest)] + async fn on_create_source( + &self, + req: CreateSourceRequest, + ) -> Result { + let source = goose::sources::create_source( + req.source_type, + &req.name, + &req.description, + &req.content, + req.global, + req.project_dir.as_deref(), + )?; + Ok(CreateSourceResponse { source }) + } + + #[custom_method(ListSourcesRequest)] + async fn on_list_sources( + &self, + req: ListSourcesRequest, + ) -> Result { + let sources = goose::sources::list_sources(req.source_type, req.project_dir.as_deref())?; + Ok(ListSourcesResponse { sources }) + } + + #[custom_method(UpdateSourceRequest)] + async fn on_update_source( + &self, + req: UpdateSourceRequest, + ) -> Result { + let source = goose::sources::update_source( + req.source_type, + &req.name, + &req.description, + &req.content, + req.global, + req.project_dir.as_deref(), + )?; + Ok(UpdateSourceResponse { source }) + } + + #[custom_method(DeleteSourceRequest)] + async fn on_delete_source( + &self, + req: DeleteSourceRequest, + ) -> Result { + goose::sources::delete_source( + req.source_type, + &req.name, + req.global, + req.project_dir.as_deref(), + )?; + Ok(EmptyResponse {}) + } + + #[custom_method(ExportSourceRequest)] + async fn on_export_source( + &self, + req: ExportSourceRequest, + ) -> Result { + let (json, filename) = goose::sources::export_source( + req.source_type, + &req.name, + req.global, + req.project_dir.as_deref(), + )?; + Ok(ExportSourceResponse { json, filename }) + } + + #[custom_method(ImportSourcesRequest)] + async fn on_import_sources( + &self, + req: ImportSourcesRequest, + ) -> Result { + let sources = + goose::sources::import_sources(&req.data, req.global, req.project_dir.as_deref())?; + Ok(ImportSourcesResponse { sources }) + } + #[custom_method(DictationTranscribeRequest)] async fn on_dictation_transcribe( &self, diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 5337182110..2906b7aad1 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -296,6 +296,144 @@ pub struct ProviderConfigKey { pub primary: bool, } +/// The type of source entity. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, 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. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SourceEntry { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub description: String, + pub content: String, + /// Absolute path to the source's directory on disk. + 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, +} + +/// Create a new source (global or project-scoped). +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/create", response = CreateSourceResponse)] +#[serde(rename_all = "camelCase")] +pub struct CreateSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub description: String, + pub content: String, + pub global: bool, + /// Absolute path to the project root. Required when `global` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +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. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/list", response = ListSourcesResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListSourcesRequest { + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub source_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListSourcesResponse { + pub sources: Vec, +} + +/// Update an existing source's description and content. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/update", response = UpdateSourceResponse)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: 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)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSourceResponse { + pub source: SourceEntry, +} + +/// Delete a source and its on-disk directory. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/delete", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub global: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +/// Export a source as a portable JSON payload. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/export", response = ExportSourceResponse)] +#[serde(rename_all = "camelCase")] +pub struct ExportSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub global: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ExportSourceResponse { + pub json: String, + pub filename: String, +} + +/// 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. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/import", response = ImportSourcesResponse)] +#[serde(rename_all = "camelCase")] +pub struct ImportSourcesRequest { + pub data: String, + pub global: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ImportSourcesResponse { + pub sources: Vec, +} + /// Transcribe audio via a dictation provider. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/dictation/transcribe", response = DictationTranscribeResponse)] diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 0960319d76..d99551c0c7 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -113,6 +113,7 @@ strum = { workspace = true } once_cell = { workspace = true } etcetera = { workspace = true } fs-err = "3" +goose-sdk = { path = "../goose-sdk" } rand = { workspace = true } utoipa = { workspace = true, features = ["chrono"] } tokio-cron-scheduler = "0.14.0" diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index cd60b4de8e..def1d186d0 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -38,6 +38,7 @@ pub mod security; pub mod session; pub mod session_context; pub mod slash_commands; +pub mod sources; pub mod subprocess; pub mod token_counter; pub mod tool_inspection; diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs new file mode 100644 index 0000000000..35d0b67805 --- /dev/null +++ b/crates/goose/src/sources.rs @@ -0,0 +1,526 @@ +//! 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; +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() + .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 { + if project_dir.trim().is_empty() { + return Err( + Error::invalid_params().data("projectDir must not be empty when global is false") + ); + } + let mut dir = PathBuf::from(project_dir); + for part in PROJECT_SKILLS_SUBPATH { + dir = dir.join(part); + } + Ok(dir) +} + +fn source_base_dir( + source_type: SourceType, + global: bool, + project_dir: Option<&str>, +) -> Result { + match source_type { + SourceType::Skill => { + if global { + skills_dir_global() + } else { + let pd = project_dir.ok_or_else(|| { + Error::invalid_params().data("projectDir is required when global is false") + })?; + skills_dir_project(pd) + } + } + } +} + +/// Kebab-case validation: `^[a-z0-9]+(-[a-z0-9]+)*$`. Prevents path traversal +/// via names like `../../.ssh/authorized_keys`. +fn validate_source_name(name: &str) -> Result<(), Error> { + if name.is_empty() { + return Err(Error::invalid_params().data("Source name must not be empty")); + } + let mut expect_alnum = true; + for ch in name.chars() { + if ch.is_ascii_lowercase() || ch.is_ascii_digit() { + expect_alnum = false; + } else if ch == '-' && !expect_alnum { + expect_alnum = true; + } else { + return Err(Error::invalid_params().data(format!( + "Invalid source name \"{}\". Names must be kebab-case (lowercase letters, digits, and hyphens; \ + must not start or end with a hyphen or contain consecutive hyphens).", + name + ))); + } + } + if expect_alnum { + return Err(Error::invalid_params().data(format!( + "Invalid source name \"{}\". Names must not end with a hyphen.", + name + ))); + } + 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, + description: &str, + content: &str, + dir: &Path, + global: bool, +) -> SourceEntry { + SourceEntry { + source_type, + name: name.to_string(), + description: description.to_string(), + content: content.to_string(), + directory: dir.to_string_lossy().to_string(), + global, + } +} + +pub fn create_source( + source_type: SourceType, + name: &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!("A source named \"{}\" already exists", name)) + ); + } + + fs::create_dir_all(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to create source directory: {e}")) + })?; + let file_path = dir.join("SKILL.md"); + let md = build_skill_md(name, description, content); + fs::write(&file_path, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + + Ok(source_entry( + source_type, + name, + description, + content, + &dir, + global, + )) +} + +pub fn update_source( + source_type: SourceType, + name: &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))); + } + + let file_path = dir.join("SKILL.md"); + let md = build_skill_md(name, description, content); + fs::write(&file_path, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + + Ok(source_entry( + source_type, + name, + description, + content, + &dir, + global, + )) +} + +pub fn delete_source( + source_type: SourceType, + name: &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))); + } + fs::remove_dir_all(&dir) + .map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?; + Ok(()) +} + +pub fn list_sources( + source_type: Option, + project_dir: Option<&str>, +) -> Result, Error> { + 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 dir = skills_dir_global()?; + sources.extend(read_skill_dir(&dir, true)?); + } + } + } + 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, + global: bool, + project_dir: Option<&str>, +) -> Result<(String, String), Error> { + validate_source_name(name)?; + let dir = source_base_dir(source_type, global, project_dir)?.join(name); + + if !dir.exists() { + return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); + } + + let md = dir.join("SKILL.md"); + let 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", + }; + let export = serde_json::json!({ + "version": 1, + "type": type_slug, + "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); + Ok((json, filename)) +} + +pub fn import_sources( + data: &str, + global: bool, + project_dir: Option<&str>, +) -> Result, Error> { + let value: serde_json::Value = serde_json::from_str(data) + .map_err(|e| Error::invalid_params().data(format!("Invalid JSON: {e}")))?; + + let version = value + .get("version") + .and_then(|v| v.as_u64()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"version\" field"))?; + if version != 1 { + return Err( + Error::invalid_params().data(format!("Unsupported source export version: {}", version)) + ); + } + + // Default to `skill` to preserve compatibility with pre-sources skill exports. + let source_type = match value + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("skill") + { + "skill" => SourceType::Skill, + other => { + return Err(Error::invalid_params().data(format!("Unsupported source type: {}", other))); + } + }; + + let name = value + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"name\" field"))? + .to_string(); + if name.is_empty() { + return Err(Error::invalid_params().data("Source name must not be empty")); + } + + let description = value + .get("description") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"description\" field"))? + .to_string(); + if description.is_empty() { + return Err(Error::invalid_params().data("Source description must not be empty")); + } + + // Accept both the new `content` key and the legacy skills `instructions` key. + let content = value + .get("content") + .or_else(|| value.get("instructions")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + validate_source_name(&name)?; + + let base = source_base_dir(source_type, global, project_dir)?; + let mut final_name = name.clone(); + if base.join(&final_name).exists() { + final_name = format!("{}-imported", name); + let mut counter = 2u32; + while base.join(&final_name).exists() { + final_name = format!("{}-imported-{}", name, counter); + counter += 1; + } + } + + let dir = base.join(&final_name); + fs::create_dir_all(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to create source directory: {e}")) + })?; + let file_path = dir.join("SKILL.md"); + let md = build_skill_md(&final_name, &description, &content); + fs::write(&file_path, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + + Ok(vec![source_entry( + source_type, + &final_name, + &description, + &content, + &dir, + global, + )]) +} + +#[cfg(test)] +mod tests { + use super::*; + 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()); + } + + #[test] + fn create_list_update_delete_project_skill() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + let created = create_source( + SourceType::Skill, + "my-skill", + "does the thing", + "step one\nstep two", + false, + Some(project), + ) + .unwrap(); + assert_eq!(created.name, "my-skill"); + assert!(!created.global); + assert!(PathBuf::from(&created.directory).join("SKILL.md").exists()); + + let listed = list_sources(Some(SourceType::Skill), Some(project)).unwrap(); + assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global)); + + let updated = update_source( + SourceType::Skill, + "my-skill", + "now does a different thing", + "step three", + false, + Some(project), + ) + .unwrap(); + assert_eq!(updated.description, "now does a different thing"); + + delete_source(SourceType::Skill, "my-skill", false, Some(project)).unwrap(); + assert!(!PathBuf::from(&created.directory).exists()); + } + + #[test] + fn create_rejects_duplicate_name() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap(); + let err = + create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap_err(); + assert!(format!("{:?}", err).contains("already exists")); + } + + #[test] + fn project_scope_requires_project_dir() { + let err = create_source(SourceType::Skill, "x", "d", "c", false, None).unwrap_err(); + assert!(format!("{:?}", err).contains("projectDir")); + } + + #[test] + fn export_then_import_roundtrip() { + let tmp = TempDir::new().unwrap(); + let project_a = tmp.path().join("a"); + let project_b = tmp.path().join("b"); + std::fs::create_dir_all(&project_a).unwrap(); + std::fs::create_dir_all(&project_b).unwrap(); + + create_source( + SourceType::Skill, + "portable", + "describes itself", + "body goes here", + false, + Some(project_a.to_str().unwrap()), + ) + .unwrap(); + + let (json, filename) = export_source( + SourceType::Skill, + "portable", + false, + Some(project_a.to_str().unwrap()), + ) + .unwrap(); + assert_eq!(filename, "portable.skill.json"); + + let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap(); + assert_eq!(imported.len(), 1); + assert_eq!(imported[0].name, "portable"); + assert_eq!(imported[0].description, "describes itself"); + assert_eq!(imported[0].content, "body goes here"); + } + + #[test] + fn import_collision_appends_suffix() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + create_source(SourceType::Skill, "busy", "d", "c", false, Some(project)).unwrap(); + + let payload = serde_json::json!({ + "version": 1, + "type": "skill", + "name": "busy", + "description": "d", + "content": "c", + }) + .to_string(); + let imported = import_sources(&payload, false, Some(project)).unwrap(); + assert_eq!(imported[0].name, "busy-imported"); + } +} diff --git a/ui/goose2/src-tauri/src/commands/acp.rs b/ui/goose2/src-tauri/src/commands/acp.rs index 9d97d0c6d6..2906da85b6 100644 --- a/ui/goose2/src-tauri/src/commands/acp.rs +++ b/ui/goose2/src-tauri/src/commands/acp.rs @@ -1,7 +1,14 @@ +use std::env; + use crate::services::acp::GooseServeProcess; #[tauri::command] pub async fn get_goose_serve_url(app_handle: tauri::AppHandle) -> Result { + if let Ok(url) = env::var("GOOSE_SERVE_URL") { + if !url.is_empty() { + return Ok(url); + } + } let process = GooseServeProcess::get(app_handle).await?; Ok(process.ws_url()) } diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index 7401328c96..8acd3c56ab 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -9,5 +9,4 @@ pub mod git_changes; pub mod model_setup; pub mod path_resolver; pub mod projects; -pub mod skills; pub mod system; diff --git a/ui/goose2/src-tauri/src/commands/skills.rs b/ui/goose2/src-tauri/src/commands/skills.rs deleted file mode 100644 index 4d975da453..0000000000 --- a/ui/goose2/src-tauri/src/commands/skills.rs +++ /dev/null @@ -1,319 +0,0 @@ -use std::fs; -use std::path::PathBuf; - -fn skills_dir() -> Result { - let home = dirs::home_dir().ok_or("Could not determine home directory")?; - Ok(home.join(".agents").join("skills")) -} - -/// Validates that a skill name is kebab-case only: `^[a-z0-9]+(-[a-z0-9]+)*$`. -/// This prevents path traversal attacks (e.g. `../../.ssh/authorized_keys`). -fn validate_skill_name(name: &str) -> Result<(), String> { - if name.is_empty() { - return Err("Skill name must not be empty".to_string()); - } - let mut expect_alnum = true; // true = next char must be [a-z0-9], false = can also be '-' - 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; // char after '-' must be [a-z0-9] - } else { - return Err(format!( - "Invalid skill 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 { - // name ended with '-' - return Err(format!( - "Invalid skill name \"{}\". Names must not end with a hyphen.", - name - )); - } - Ok(()) -} - -fn build_skill_md(name: &str, description: &str, instructions: &str) -> String { - // Escape embedded single quotes by doubling them, then wrap in single quotes - // to prevent YAML injection in the description field. - let safe_desc = description.replace('\'', "''"); - let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc); - if !instructions.is_empty() { - md.push('\n'); - md.push_str(instructions); - md.push('\n'); - } - md -} - -#[tauri::command] -pub fn create_skill(name: String, description: String, instructions: String) -> Result<(), String> { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - - if dir.exists() { - return Err(format!("A skill named \"{}\" already exists", name)); - } - - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?; - - let skill_path = dir.join("SKILL.md"); - let content = build_skill_md(&name, &description, &instructions); - - fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?; - - Ok(()) -} - -#[tauri::command] -pub fn list_skills() -> Result, String> { - let dir = skills_dir()?; - - if !dir.exists() { - return Ok(vec![]); - } - - let mut skills = Vec::new(); - let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read skills dir: {}", e))?; - - 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, instructions) = parse_frontmatter(&raw); - - skills.push(SkillInfo { - name, - description, - instructions, - path: skill_md.to_string_lossy().to_string(), - }); - } - - skills.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(skills) -} - -#[tauri::command] -pub fn delete_skill(name: String) -> Result<(), String> { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - if !dir.exists() { - return Err(format!("Skill \"{}\" not found", name)); - } - fs::remove_dir_all(&dir).map_err(|e| format!("Failed to delete skill: {}", e))?; - Ok(()) -} - -fn parse_frontmatter(raw: &str) -> (String, String) { - let trimmed = raw.trim(); - if !trimmed.starts_with("---") { - return (String::new(), raw.to_string()); - } - - if let Some(end) = trimmed[3..].find("\n---") { - let front = &trimmed[3..3 + end].trim(); - let body = trimmed[3 + end + 4..].trim().to_string(); - - let mut description = String::new(); - for line in front.lines() { - let line = line.trim(); - if let Some(rest) = line.strip_prefix("description:") { - let val = rest.trim(); - // Strip surrounding quotes (single or double) - let unquoted = val - .trim_start_matches(['\'', '"']) - .trim_end_matches(['\'', '"']); - description = if val.starts_with('\'') { - // Un-escape doubled single quotes - unquoted.replace("''", "'") - } else { - // Legacy double-quote format - unquoted.replace("\\\"", "\"") - } - .to_string(); - } - } - - (description, body) - } else { - (String::new(), raw.to_string()) - } -} - -#[derive(serde::Serialize, Clone)] -pub struct SkillInfo { - pub name: String, - pub description: String, - pub instructions: String, - pub path: String, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillExportV1 { - version: u32, - name: String, - description: String, - #[serde(skip_serializing_if = "String::is_empty")] - instructions: String, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ExportSkillResult { - json: String, - filename: String, -} - -#[tauri::command] -pub fn update_skill( - name: String, - description: String, - instructions: String, -) -> Result { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - - if !dir.exists() { - return Err(format!("Skill \"{}\" not found", name)); - } - - let skill_path = dir.join("SKILL.md"); - let content = build_skill_md(&name, &description, &instructions); - - fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?; - - Ok(SkillInfo { - name: name.clone(), - description, - instructions, - path: skill_path.to_string_lossy().to_string(), - }) -} - -#[tauri::command] -pub fn export_skill(name: String) -> Result { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - - if !dir.exists() { - return Err(format!("Skill \"{}\" not found", name)); - } - - let skill_md = dir.join("SKILL.md"); - let raw = - fs::read_to_string(&skill_md).map_err(|e| format!("Failed to read SKILL.md: {}", e))?; - let (description, instructions) = parse_frontmatter(&raw); - - let export = SkillExportV1 { - version: 1, - name: name.clone(), - description, - instructions, - }; - - let json = serde_json::to_string_pretty(&export) - .map_err(|e| format!("Failed to serialize skill: {}", e))?; - - let filename = format!("{}.skill.json", name); - - Ok(ExportSkillResult { json, filename }) -} - -#[tauri::command] -pub fn import_skills(file_bytes: Vec, file_name: String) -> Result, String> { - // Validate file extension - if !file_name.ends_with(".skill.json") && !file_name.ends_with(".json") { - return Err("File must have a .skill.json or .json extension".to_string()); - } - - // Parse bytes as UTF-8 - let text = - String::from_utf8(file_bytes).map_err(|e| format!("File is not valid UTF-8: {}", e))?; - - // Parse as JSON - let value: serde_json::Value = - serde_json::from_str(&text).map_err(|e| format!("Invalid JSON: {}", e))?; - - // Validate version - let version = value - .get("version") - .and_then(|v| v.as_u64()) - .ok_or("Missing or invalid \"version\" field")?; - if version != 1 { - return Err(format!("Unsupported skill export version: {}", version)); - } - - // Extract fields - let name = value - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing or invalid \"name\" field")? - .to_string(); - if name.is_empty() { - return Err("Skill name must not be empty".to_string()); - } - - let description = value - .get("description") - .and_then(|v| v.as_str()) - .ok_or("Missing or invalid \"description\" field")? - .to_string(); - if description.is_empty() { - return Err("Skill description must not be empty".to_string()); - } - - let instructions = value - .get("instructions") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - // Validate the name - validate_skill_name(&name)?; - - // Determine final name, avoiding collisions - let base_dir = skills_dir()?; - let mut final_name = name.clone(); - if base_dir.join(&final_name).exists() { - final_name = format!("{}-imported", name); - // If that also exists, append a number - let mut counter = 2u32; - while base_dir.join(&final_name).exists() { - final_name = format!("{}-imported-{}", name, counter); - counter += 1; - } - } - - // Create the skill on disk - let dir = base_dir.join(&final_name); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?; - - let skill_path = dir.join("SKILL.md"); - let content = build_skill_md(&final_name, &description, &instructions); - fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?; - - Ok(vec![SkillInfo { - name: final_name, - description, - instructions, - path: skill_path.to_string_lossy().to_string(), - }]) -} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index ff900e0170..7ec19d7b26 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -44,12 +44,6 @@ pub fn run() { commands::agents::save_persona_avatar_bytes, commands::agents::get_avatars_dir, commands::acp::get_goose_serve_url, - commands::skills::create_skill, - commands::skills::list_skills, - commands::skills::delete_skill, - commands::skills::update_skill, - commands::skills::export_skill, - commands::skills::import_skills, commands::projects::list_projects, commands::projects::create_project, commands::projects::update_project, diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index 63eb23ba43..5f32147c1a 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { getClient } from "@/shared/api/acpConnection"; export interface SkillInfo { name: string; @@ -7,20 +7,54 @@ export interface SkillInfo { path: 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; +} + +function toSkillInfo(source: SourceEntry): SkillInfo { + return { + name: source.name, + description: source.description, + instructions: source.content, + path: source.directory, + }; +} + export async function createSkill( name: string, description: string, instructions: string, ): Promise { - return invoke("create_skill", { name, description, instructions }); + const client = await getClient(); + await client.extMethod("_goose/sources/create", { + type: "skill", + name, + description, + content: instructions, + global: true, + }); } export async function listSkills(): Promise { - return invoke("list_skills"); + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/list", { type: "skill" }); + const sources = (raw.sources ?? []) as SourceEntry[]; + return sources.map(toSkillInfo); } export async function deleteSkill(name: string): Promise { - return invoke("delete_skill", { name }); + const client = await getClient(); + await client.extMethod("_goose/sources/delete", { + type: "skill", + name, + global: true, + }); } export async function updateSkill( @@ -28,21 +62,42 @@ export async function updateSkill( description: string, instructions: string, ): Promise { - return invoke("update_skill", { name, description, instructions }); + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/update", { + type: "skill", + name, + description, + content: instructions, + global: true, + }); + return toSkillInfo(raw.source as SourceEntry); } export async function exportSkill( name: string, ): Promise<{ json: string; filename: string }> { - return invoke("export_skill", { name }); + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/export", { + type: "skill", + name, + global: true, + }); + return { json: raw.json as string, filename: raw.filename as string }; } export async function importSkills( fileBytes: number[], fileName: string, ): Promise { - return invoke("import_skills", { - fileBytes: Array.from(fileBytes), - fileName, + 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", { + data, + global: true, }); + const sources = (raw.sources ?? []) as SourceEntry[]; + return sources.map(toSkillInfo); } diff --git a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts index 0c0182c509..e778820853 100644 --- a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts +++ b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts @@ -2,6 +2,10 @@ * Playwright custom fixture that injects a Tauri IPC mock into the page * before every navigation. This allows E2E tests to run against the frontend * without the real Tauri backend. + * + * Also installs a `window.WebSocket` stub for the ACP connection so features + * like skills (which use `client.extMethod("_goose/sources/...")`) can run + * without a live goose-acp server. */ import { test as base, expect, type Page } from "@playwright/test"; @@ -11,7 +15,7 @@ import { MOCK_PERSONAS, MOCK_PROJECTS, MOCK_SKILLS } from "./mock-data"; * Build the init script that will be injected into the page via * `page.addInitScript()`. The script sets up `window.__TAURI_INTERNALS__` * with an `invoke` handler that returns mock data for every Tauri command - * the app is known to call. + * the app is known to call, plus a WebSocket mock for ACP traffic. * * Callers can override the default personas and skills arrays to test * empty-state or custom scenarios. @@ -30,8 +34,18 @@ export function buildInitScript(options?: { const PERSONAS = ${personas}; const SKILLS = ${skills}; const PROJECTS = ${projects}; + const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp"; const ACP_SESSIONS = []; + const skillToSourceEntry = (s) => ({ + type: "skill", + name: s.name, + description: s.description, + content: s.instructions ?? s.content ?? "", + directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""), + global: true, + }); + function nowIso() { return new Date().toISOString(); } @@ -120,6 +134,39 @@ export function buildInitScript(options?: { case "_goose/working_dir/update": case "goose/working_dir/update": return jsonRpcResult(message.id, {}); + case "_goose/sources/list": + return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) }); + case "_goose/sources/create": + return jsonRpcResult(message.id, { + source: { + name: message.params?.name ?? "new-skill", + type: "skill", + description: message.params?.description ?? "", + content: message.params?.content ?? "", + directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"), + global: message.params?.global ?? true, + }, + }); + case "_goose/sources/update": + return jsonRpcResult(message.id, { + source: { + name: message.params?.name ?? "updated-skill", + type: "skill", + description: message.params?.description ?? "", + content: message.params?.content ?? "", + directory: "/mock/.agents/skills/" + (message.params?.name ?? "updated-skill"), + global: message.params?.global ?? true, + }, + }); + case "_goose/sources/delete": + return jsonRpcResult(message.id, {}); + case "_goose/sources/export": + return jsonRpcResult(message.id, { + json: "{}", + filename: (message.params?.name ?? "skill") + ".skill.json", + }); + case "_goose/sources/import": + return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) }); default: return jsonRpcResult(message.id, {}); } @@ -165,6 +212,10 @@ export function buildInitScript(options?: { window.__TAURI_INTERNALS__ = { invoke(cmd, args) { switch (cmd) { + // ---- ACP transport ---- + case "get_goose_serve_url": + return Promise.resolve(FAKE_ACP_URL); + // ---- Personas ---- case "list_personas": return Promise.resolve(PERSONAS); @@ -202,28 +253,6 @@ export function buildInitScript(options?: { case "import_personas": return Promise.resolve(PERSONAS); - // ---- Skills ---- - case "list_skills": - return Promise.resolve(SKILLS); - case "create_skill": - return Promise.resolve(null); - case "update_skill": - return Promise.resolve({ - name: args?.name ?? "updated-skill", - description: args?.description ?? "", - instructions: args?.instructions ?? "", - path: "", - }); - case "delete_skill": - return Promise.resolve(null); - case "export_skill": - return Promise.resolve({ - json: "{}", - filename: "skill.json", - }); - case "import_skills": - return Promise.resolve(SKILLS); - // ---- Sessions / Misc ---- case "list_sessions": return Promise.resolve( @@ -234,8 +263,6 @@ export function buildInitScript(options?: { messageCount: session.messageCount, })), ); - case "get_goose_serve_url": - return Promise.resolve("ws://mock-goose"); case "create_session": return Promise.resolve({ id: "session-" + Math.random().toString(36).slice(2, 10), diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index dab5b19895..dc49801e33 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -12,7 +12,10 @@ import type { ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, + CreateSourceRequest, + CreateSourceResponse, DeleteSessionRequest, + DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationModelCancelRequest, @@ -27,6 +30,8 @@ import type { DictationTranscribeResponse, ExportSessionRequest, ExportSessionResponse, + ExportSourceRequest, + ExportSourceResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, @@ -39,8 +44,12 @@ import type { GetToolsResponse, ImportSessionRequest, ImportSessionResponse, + ImportSourcesRequest, + ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, + ListSourcesRequest, + ListSourcesResponse, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, @@ -51,27 +60,34 @@ import type { RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, + UpdateSourceRequest, + UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest, } from './types.gen.js'; import { zCheckSecretResponse, + zCreateSourceResponse, zDictationConfigResponse, zDictationModelDownloadProgressResponse, zDictationModelsListResponse, zDictationTranscribeResponse, zExportSessionResponse, + zExportSourceResponse, zGetExtensionsResponse, zGetProviderDetailsResponse, zGetProviderInventoryResponse, zGetSessionExtensionsResponse, zGetToolsResponse, zImportSessionResponse, + zImportSourcesResponse, zListProvidersResponse, + zListSourcesResponse, zReadConfigResponse, zReadResourceResponse, zRefreshProviderInventoryResponse, + zUpdateSourceResponse, } from './zod.gen.js'; export class GooseExtClient { @@ -208,6 +224,45 @@ export class GooseExtClient { await this.conn.extMethod("_goose/session/unarchive", params); } + async GooseSourcesCreate( + params: CreateSourceRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/create", params); + return zCreateSourceResponse.parse(raw) as CreateSourceResponse; + } + + async GooseSourcesList( + params: ListSourcesRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/list", params); + return zListSourcesResponse.parse(raw) as ListSourcesResponse; + } + + async GooseSourcesUpdate( + params: UpdateSourceRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/update", params); + return zUpdateSourceResponse.parse(raw) as UpdateSourceResponse; + } + + async GooseSourcesDelete(params: DeleteSourceRequest): Promise { + await this.conn.extMethod("_goose/sources/delete", params); + } + + async GooseSourcesExport( + params: ExportSourceRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/export", params); + return zExportSourceResponse.parse(raw) as ExportSourceResponse; + } + + async GooseSourcesImport( + params: ImportSourcesRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/import", params); + return zImportSourcesResponse.parse(raw) as ImportSourcesResponse; + } + async GooseDictationTranscribe( params: DictationTranscribeRequest, ): Promise { diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 2bb363a78f..201d8f1c41 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderInventoryRequest, GetProviderInventoryResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; +export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, CreateSourceRequest, CreateSourceResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderInventoryRequest, GetProviderInventoryResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, SourceEntry, SourceType, UnarchiveSessionRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -113,6 +113,36 @@ export const GOOSE_EXT_METHODS = [ requestType: "UnarchiveSessionRequest", responseType: "EmptyResponse", }, + { + method: "_goose/sources/create", + requestType: "CreateSourceRequest", + responseType: "CreateSourceResponse", + }, + { + method: "_goose/sources/list", + requestType: "ListSourcesRequest", + responseType: "ListSourcesResponse", + }, + { + method: "_goose/sources/update", + requestType: "UpdateSourceRequest", + responseType: "UpdateSourceResponse", + }, + { + method: "_goose/sources/delete", + requestType: "DeleteSourceRequest", + responseType: "EmptyResponse", + }, + { + method: "_goose/sources/export", + requestType: "ExportSourceRequest", + responseType: "ExportSourceResponse", + }, + { + method: "_goose/sources/import", + requestType: "ImportSourcesRequest", + responseType: "ImportSourcesResponse", + }, { method: "_goose/dictation/transcribe", requestType: "DictationTranscribeRequest", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 87c164c627..74fec01a27 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -395,6 +395,119 @@ export type UnarchiveSessionRequest = { 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; +}; + +/** + * The type of source entity. + */ +export type SourceType = 'skill'; + +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. + */ +export type SourceEntry = { + type: SourceType; + name: string; + description: string; + content: string; + /** + * Absolute path to the source's directory on disk. + */ + directory: string; + /** + * True when the source lives in the user's global sources directory; false + * when it lives inside a specific project. + */ + global: boolean; +}; + +/** + * 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. + */ +export type ListSourcesRequest = { + type?: SourceType | null; + projectDir?: string | null; +}; + +export type ListSourcesResponse = { + sources: Array; +}; + +/** + * Update an existing source's description and content. + */ +export type UpdateSourceRequest = { + type: SourceType; + name: string; + description: string; + content: string; + global: boolean; + projectDir?: string | null; +}; + +export type UpdateSourceResponse = { + source: SourceEntry; +}; + +/** + * Delete a source and its on-disk directory. + */ +export type DeleteSourceRequest = { + type: SourceType; + name: string; + global: boolean; + projectDir?: string | null; +}; + +/** + * Export a source as a portable JSON payload. + */ +export type ExportSourceRequest = { + type: SourceType; + name: string; + global: boolean; + projectDir?: string | null; +}; + +export type ExportSourceResponse = { + json: string; + filename: string; +}; + +/** + * 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. + */ +export type ImportSourcesRequest = { + data: string; + global: boolean; + projectDir?: string | null; +}; + +export type ImportSourcesResponse = { + sources: Array; +}; + /** * Transcribe audio via a dictation provider. */ @@ -535,14 +648,14 @@ export type DictationModelSelectRequest = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderInventoryRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderInventoryRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | 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 | GetProviderDetailsResponse | GetProviderInventoryResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; + result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderInventoryResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 9f95a0de6d..27e4ab08dd 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -348,6 +348,130 @@ export const zUnarchiveSessionRequest = z.object({ sessionId: z.string() }); +/** + * The type of source entity. + */ +export const zSourceType = z.enum(['skill']); + +/** + * 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() +}); + +/** + * A source — a user-editable entity backed by an on-disk directory. Sources + * may be 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() +}); + +export const zCreateSourceResponse = z.object({ + 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. + */ +export const zListSourcesRequest = z.object({ + type: z.union([ + zSourceType, + z.null() + ]).optional(), + projectDir: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zListSourcesResponse = z.object({ + sources: z.array(zSourceEntry) +}); + +/** + * Update an existing source's description and content. + */ +export const zUpdateSourceRequest = z.object({ + type: zSourceType, + name: 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 +}); + +/** + * Delete a source and its on-disk directory. + */ +export const zDeleteSourceRequest = z.object({ + type: zSourceType, + name: 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, + name: z.string(), + global: z.boolean(), + projectDir: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zExportSourceResponse = z.object({ + json: z.string(), + filename: z.string() +}); + +/** + * 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. + */ +export const zImportSourcesRequest = z.object({ + data: z.string(), + global: z.boolean(), + projectDir: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zImportSourcesResponse = z.object({ + sources: z.array(zSourceEntry) +}); + /** * Transcribe audio via a dictation provider. */ @@ -515,6 +639,12 @@ export const zExtRequest = z.object({ zImportSessionRequest, zArchiveSessionRequest, zUnarchiveSessionRequest, + zCreateSourceRequest, + zListSourcesRequest, + zUpdateSourceRequest, + zDeleteSourceRequest, + zExportSourceRequest, + zImportSourcesRequest, zDictationTranscribeRequest, zDictationConfigRequest, zDictationModelsListRequest, @@ -549,6 +679,11 @@ export const zExtResponse = z.union([ zCheckSecretResponse, zExportSessionResponse, zImportSessionResponse, + zCreateSourceResponse, + zListSourcesResponse, + zUpdateSourceResponse, + zExportSourceResponse, + zImportSourcesResponse, zDictationTranscribeResponse, zDictationConfigResponse, zDictationModelsListResponse,