diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index e9626d4589..53d2f00bae 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -970,7 +970,8 @@ pub struct DeleteSourceRequest { pub path: String, } -/// Export a source at an absolute path as a portable JSON payload. +/// Export a source at an absolute path as file bytes. Single-file sources are +/// exported as their source file; source trees are exported as a `.zip` archive. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/sources/export", response = ExportSourceResponse)] #[serde(rename_all = "camelCase")] @@ -983,18 +984,24 @@ pub struct ExportSourceRequest { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] #[serde(rename_all = "camelCase")] pub struct ExportSourceResponse { - pub json: String, + /// Base64-encoded exported file bytes. + pub data: String, pub filename: String, + pub mime_type: String, } -/// Import a source from a JSON export payload produced by `_goose/sources/export`. -/// The imported source is written into the explicit target scope; on name -/// collisions a `-imported` suffix is appended. +/// Import a source from base64-encoded file bytes. Single-file sources are +/// imported from their source file; source trees are imported from a `.zip` +/// archive. The imported source is written into the explicit target 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 filename: String, + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub source_type: Option, pub global: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub project_dir: Option, diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index fcace1e3c5..38371c454a 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -2113,23 +2113,28 @@ "type", "path" ], - "description": "Export a source at an absolute path as a portable JSON payload.", + "description": "Export a source at an absolute path as file bytes. Single-file sources are\nexported as their source file; source trees are exported as a `.zip` archive.", "x-side": "agent", "x-method": "_goose/sources/export" }, "ExportSourceResponse": { "type": "object", "properties": { - "json": { - "type": "string" + "data": { + "type": "string", + "description": "Base64-encoded exported file bytes." }, "filename": { "type": "string" + }, + "mimeType": { + "type": "string" } }, "required": [ - "json", - "filename" + "data", + "filename", + "mimeType" ], "x-side": "agent", "x-method": "_goose/sources/export" @@ -2140,6 +2145,19 @@ "data": { "type": "string" }, + "filename": { + "type": "string" + }, + "type": { + "anyOf": [ + { + "$ref": "#/$defs/SourceType" + }, + { + "type": "null" + } + ] + }, "global": { "type": "boolean" }, @@ -2152,9 +2170,10 @@ }, "required": [ "data", + "filename", "global" ], - "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.", + "description": "Import a source from base64-encoded file bytes. Single-file sources are\nimported from their source file; source trees are imported from a `.zip`\narchive. The imported source is written into the explicit target scope; on\nname collisions a `-imported` suffix is appended.", "x-side": "agent", "x-method": "_goose/sources/import" }, diff --git a/crates/goose/src/acp/server/sources.rs b/crates/goose/src/acp/server/sources.rs index 9360e7e415..fb0e8fac0b 100644 --- a/crates/goose/src/acp/server/sources.rs +++ b/crates/goose/src/acp/server/sources.rs @@ -1,4 +1,5 @@ use super::*; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; impl GooseAcpAgent { pub(super) async fn on_create_source( @@ -76,20 +77,33 @@ impl GooseAcpAgent { &self, req: ExportSourceRequest, ) -> Result { - let (json, filename) = crate::sources::export_source_with_roots( + let export = crate::sources::export_source_with_roots( req.source_type, &req.path, &self.additional_source_roots, )?; - Ok(ExportSourceResponse { json, filename }) + Ok(ExportSourceResponse { + data: BASE64.encode(export.bytes), + filename: export.filename, + mime_type: export.mime_type.to_string(), + }) } pub(super) async fn on_import_sources( &self, req: ImportSourcesRequest, ) -> Result { - let sources = - crate::sources::import_sources(&req.data, req.global, req.project_dir.as_deref())?; + let bytes = BASE64.decode(&req.data).map_err(|e| { + agent_client_protocol::Error::invalid_params() + .data(format!("Invalid source file data: {e}")) + })?; + let sources = crate::sources::import_sources( + &bytes, + &req.filename, + req.source_type, + req.global, + req.project_dir.as_deref(), + )?; Ok(ImportSourcesResponse { sources }) } } diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index 78e8299985..b4f44f8646 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -210,16 +210,6 @@ pub(crate) fn build_skill_md( 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. diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index dc31141cb7..3775e73e5a 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -5,8 +5,7 @@ use crate::config::paths::Paths; use crate::skills::{ build_skill_md, discover_skills, infer_skill_name, is_global_skill_dir, - parse_skill_frontmatter, resolve_discoverable_skill_dir, resolve_skill_dir, skill_base_dir, - validate_skill_name, + resolve_discoverable_skill_dir, resolve_skill_dir, skill_base_dir, validate_skill_name, }; use crate::source_roots::SourceRoot; use agent_client_protocol::Error; @@ -14,8 +13,10 @@ use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; use tracing::warn; +use zip::write::SimpleFileOptions; pub fn parse_frontmatter Deserialize<'de>>( content: &str, @@ -629,6 +630,359 @@ fn update_agent_source( agent_source_entry(&file_path, global, true) } +// --- Import/export helpers --- + +const MARKDOWN_MIME: &str = "text/markdown"; +const ZIP_MIME: &str = "application/zip"; + +#[derive(Debug)] +pub struct SourceExport { + pub bytes: Vec, + pub filename: String, + pub mime_type: &'static str, +} + +fn archive_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn should_skip_source_export_dir(path: &Path) -> bool { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some(".git") | Some(".hg") | Some(".svn") + ) +} + +fn add_dir_to_zip( + zip: &mut zip::ZipWriter>>, + source_dir: &Path, + archive_root: &str, +) -> Result<(), Error> { + let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let mut stack = vec![source_dir.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let entries = fs::read_dir(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to read source tree: {e}")) + })?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if !should_skip_source_export_dir(&path) { + stack.push(path); + } + continue; + } + if !path.is_file() { + continue; + } + + let relative = path.strip_prefix(source_dir).map_err(|e| { + Error::internal_error().data(format!("Failed to build archive path: {e}")) + })?; + zip.start_file( + format!("{archive_root}/{}", archive_path(relative)), + options, + ) + .map_err(|e| { + Error::internal_error().data(format!("Failed to write source archive: {e}")) + })?; + zip.write_all(&fs::read(&path).map_err(|e| { + Error::internal_error().data(format!("Failed to read source file: {e}")) + })?) + .map_err(|e| { + Error::internal_error().data(format!("Failed to write source archive: {e}")) + })?; + } + } + + Ok(()) +} + +fn zip_source_tree(source_dir: &Path, archive_root: &str) -> Result, Error> { + let mut buffer = Vec::new(); + { + let cursor = Cursor::new(&mut buffer); + let mut zip = zip::ZipWriter::new(cursor); + add_dir_to_zip(&mut zip, source_dir, archive_root)?; + zip.finish().map_err(|e| { + Error::internal_error().data(format!("Failed to finish source archive: {e}")) + })?; + } + Ok(buffer) +} + +fn safe_import_filename(filename: &str) -> Result<&str, Error> { + Path::new(filename) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| Error::invalid_params().data("Import filename must not be empty")) +} + +fn path_component_string(path: &Path, fallback: &str) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn infer_import_source_type( + filename: &str, + requested: Option, +) -> Result { + let lower = filename.to_lowercase(); + let source_type = match requested { + Some(SourceType::Skill) => SourceType::Skill, + Some(SourceType::Project) => SourceType::Project, + Some(SourceType::Agent) => SourceType::Agent, + Some(other) => { + return Err(Error::invalid_params() + .data(format!("Source type '{}' import is not supported.", other))) + } + None if lower.ends_with(".zip") => SourceType::Skill, + None if lower.ends_with(".project.md") => SourceType::Project, + None if lower.ends_with(".agent.md") || lower.ends_with(".persona.md") => SourceType::Agent, + None if lower.ends_with(".md") => SourceType::Agent, + None => { + return Err(Error::invalid_params().data( + "Unsupported source file type. Import a .md source file or .zip source tree.", + )) + } + }; + + if lower.ends_with(".zip") && source_type != SourceType::Skill { + return Err(Error::invalid_params().data("Only skill imports support .zip archives")); + } + + Ok(source_type) +} + +fn next_available_name(name: &str, mut exists: impl FnMut(&str) -> bool) -> String { + if !exists(name) { + return name.to_string(); + } + + (2..) + .map(|counter| format!("{name}-{counter}")) + .find(|candidate| !exists(candidate)) + .expect("infinite iterator") +} + +fn source_entry_from_skill_dir(dir: &Path, global: bool) -> Result { + let raw = fs::read_to_string(dir.join("SKILL.md")) + .map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?; + let (metadata, content): (crate::skills::SkillFrontmatter, String) = parse_frontmatter(&raw) + .map_err(|e| Error::invalid_params().data(format!("Invalid skill frontmatter: {e}")))? + .ok_or_else(|| Error::invalid_params().data("Skill file is missing frontmatter"))?; + let name = metadata.name.unwrap_or_else(|| infer_skill_name(dir)); + Ok(skill_source_entry( + &name, + &metadata.description, + &content, + dir, + global, + metadata.metadata, + )) +} + +fn enclosed_archive_path(file: &zip::read::ZipFile<'_, Cursor<&[u8]>>) -> Result { + file.enclosed_name() + .ok_or_else(|| Error::invalid_params().data("Invalid path in source archive")) +} + +fn find_imported_skill_root( + archive: &mut zip::ZipArchive>, +) -> Result { + for index in 0..archive.len() { + let file = archive.by_index(index).map_err(|e| { + Error::invalid_params().data(format!("Invalid source archive entry: {e}")) + })?; + let path = enclosed_archive_path(&file)?; + if path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") { + return Ok(path.parent().unwrap_or_else(|| Path::new("")).to_path_buf()); + } + } + Err(Error::invalid_params().data("Source archive must contain a SKILL.md file")) +} + +fn read_archive_text( + archive: &mut zip::ZipArchive>, + path: &Path, +) -> Result { + let mut file = archive + .by_name(&archive_path(path)) + .map_err(|e| Error::invalid_params().data(format!("Invalid source archive entry: {e}")))?; + let mut content = String::new(); + std::io::Read::read_to_string(&mut file, &mut content) + .map_err(|e| Error::invalid_params().data(format!("Source file must be UTF-8: {e}")))?; + Ok(content) +} + +fn extract_archive_root( + archive: &mut zip::ZipArchive>, + root: &Path, + target: &Path, +) -> Result<(), Error> { + for index in 0..archive.len() { + let mut file = archive.by_index(index).map_err(|e| { + Error::invalid_params().data(format!("Invalid source archive entry: {e}")) + })?; + let path = enclosed_archive_path(&file)?; + let relative = if root.as_os_str().is_empty() { + path.as_path() + } else { + match path.strip_prefix(root) { + Ok(path) => path, + Err(_) => continue, + } + }; + if relative.as_os_str().is_empty() { + continue; + } + + let target_path = target.join(relative); + if file.is_dir() { + fs::create_dir_all(&target_path).map_err(|e| { + Error::internal_error().data(format!("Failed to extract source archive: {e}")) + })?; + } else { + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + Error::internal_error().data(format!("Failed to extract source archive: {e}")) + })?; + } + let mut out = fs::File::create(&target_path).map_err(|e| { + Error::internal_error().data(format!("Failed to extract source archive: {e}")) + })?; + std::io::copy(&mut file, &mut out).map_err(|e| { + Error::internal_error().data(format!("Failed to extract source archive: {e}")) + })?; + } + } + Ok(()) +} + +fn import_skill_tree( + bytes: &[u8], + global: bool, + project_dir: Option<&str>, +) -> Result, Error> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .map_err(|e| Error::invalid_params().data(format!("Invalid source archive: {e}")))?; + let root = find_imported_skill_root(&mut archive)?; + let raw = read_archive_text(&mut archive, &root.join("SKILL.md"))?; + let (metadata, content): (crate::skills::SkillFrontmatter, String) = parse_frontmatter(&raw) + .map_err(|e| Error::invalid_params().data(format!("Invalid skill frontmatter: {e}")))? + .ok_or_else(|| Error::invalid_params().data("Skill file is missing frontmatter"))?; + let name = metadata + .name + .ok_or_else(|| Error::invalid_params().data("Skill file is missing a name"))?; + validate_skill_name(&name)?; + + let base = skill_base_dir(global, project_dir)?; + let final_name = next_available_name(&name, |candidate| base.join(candidate).exists()); + let target = base.join(&final_name); + extract_archive_root(&mut archive, &root, &target)?; + + if final_name != name { + fs::write( + target.join("SKILL.md"), + build_skill_md( + &final_name, + &metadata.description, + &content, + &metadata.metadata, + ), + ) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + } + + source_entry_from_skill_dir(&target, global).map(|source| vec![source]) +} + +fn import_skill_file( + bytes: &[u8], + global: bool, + project_dir: Option<&str>, +) -> Result, Error> { + let raw = String::from_utf8(bytes.to_vec()) + .map_err(|e| Error::invalid_params().data(format!("Source file must be UTF-8: {e}")))?; + let (metadata, content): (crate::skills::SkillFrontmatter, String) = parse_frontmatter(&raw) + .map_err(|e| Error::invalid_params().data(format!("Invalid skill frontmatter: {e}")))? + .ok_or_else(|| Error::invalid_params().data("Skill file is missing frontmatter"))?; + let name = metadata + .name + .ok_or_else(|| Error::invalid_params().data("Skill file is missing a name"))?; + validate_skill_name(&name)?; + let base = skill_base_dir(global, project_dir)?; + let final_name = next_available_name(&name, |candidate| base.join(candidate).exists()); + create_source( + SourceType::Skill, + &final_name, + &metadata.description, + &content, + global, + project_dir, + metadata.metadata, + ) + .map(|entry| vec![entry]) +} + +fn import_project_file(bytes: &[u8], filename: &str) -> Result, Error> { + let raw = String::from_utf8(bytes.to_vec()) + .map_err(|e| Error::invalid_params().data(format!("Source file must be UTF-8: {e}")))?; + let lower = filename.to_lowercase(); + let filename_stem = if lower.ends_with(".project.md") { + &filename[..filename.len() - ".project.md".len()] + } else if lower.ends_with(".md") { + &filename[..filename.len() - ".md".len()] + } else { + filename + }; + let (title, description, content, mut properties) = parse_project_frontmatter(&raw); + let mut slug = filename_stem.to_string(); + if validate_project_slug(&slug).is_err() && !title.is_empty() { + slug = slugify_agent_name(&title); + } + validate_project_slug(&slug)?; + + let final_slug = next_available_name(&slug, |candidate| project_file_path(candidate).exists()); + if !title.is_empty() { + properties.insert("title".into(), serde_json::Value::String(title)); + } + create_source( + SourceType::Project, + &final_slug, + &description, + &content, + true, + None, + properties, + ) + .map(|entry| vec![entry]) +} + +fn import_agent_file( + bytes: &[u8], + global: bool, + project_dir: Option<&str>, +) -> Result, Error> { + let raw = String::from_utf8(bytes.to_vec()) + .map_err(|e| Error::invalid_params().data(format!("Source file must be UTF-8: {e}")))?; + let (frontmatter, content) = parse_agent_frontmatter(&raw)?; + create_agent_source( + &frontmatter.name, + &frontmatter.description, + &content, + frontmatter.properties, + global, + project_dir, + ) + .map(|entry| vec![entry]) +} + // --- Public CRUD --- pub fn create_source( @@ -974,7 +1328,7 @@ pub fn list_sources_with_roots( Ok(sources) } -pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, String), Error> { +pub fn export_source(source_type: SourceType, path: &str) -> Result { export_source_with_roots(source_type, path, &[]) } @@ -982,86 +1336,46 @@ pub fn export_source_with_roots( source_type: SourceType, path: &str, additional_roots: &[SourceRoot], -) -> Result<(String, String), Error> { +) -> Result { match source_type { SourceType::Skill => { let dir = resolve_discoverable_skill_dir(path)?; - - 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 name = infer_skill_name(&dir); - - let export = serde_json::json!({ - "version": 1, - "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!("{}.skill.json", name); - Ok((json, filename)) + let name = path_component_string(&dir, "skill"); + let bytes = zip_source_tree(&dir, &name)?; + Ok(SourceExport { + bytes, + filename: format!("{name}.skill.zip"), + mime_type: ZIP_MIME, + }) } SourceType::Agent => { let file_path = resolve_agent_file_with_roots(path, additional_roots)?; - let writable = !is_read_only_agent_file(&file_path, additional_roots); + let read_only = is_read_only_agent_file(&file_path, additional_roots); let source = agent_source_entry( &file_path, - is_global_agent_file(&file_path) || !writable, - writable, + is_global_agent_file(&file_path) || read_only, + !read_only, )?; - let export = serde_json::json!({ - "version": 1, - "type": "agent", - "name": source.name, - "description": source.description, - "content": source.content, - }); - let json = serde_json::to_string_pretty(&export).map_err(|e| { - Error::internal_error().data(format!("Failed to serialize source: {e}")) + let bytes = fs::read(&file_path).map_err(|e| { + Error::internal_error().data(format!("Failed to read agent file: {e}")) })?; - let filename = format!("{}.agent.json", slugify_agent_name(&source.name)); - Ok((json, filename)) + Ok(SourceExport { + bytes, + filename: format!("{}.agent.md", slugify_agent_name(&source.name)), + mime_type: MARKDOWN_MIME, + }) } SourceType::Project => { let file = resolve_project_path(path)?; - let raw = fs::read_to_string(&file).map_err(|e| { + let filename = path_component_string(&file, "project.md"); + let bytes = fs::read(&file).map_err(|e| { Error::internal_error().data(format!("Failed to read project file: {e}")) })?; - let (title, description, content, properties) = parse_project_frontmatter(&raw); - let slug = file - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - let display_name = if title.is_empty() { - slug.clone() - } else { - title - }; - - let mut export = serde_json::json!({ - "version": 1, - "type": "project", - "name": slug, - "title": display_name, - "description": description, - "content": content, - }); - if !properties.is_empty() { - export["properties"] = serde_json::to_value(&properties).unwrap_or_default(); - } - let json = serde_json::to_string_pretty(&export).map_err(|e| { - Error::internal_error().data(format!("Failed to serialize project: {e}")) - })?; - let filename = format!("{}.project.json", slug); - Ok((json, filename)) + Ok(SourceExport { + bytes, + filename, + mime_type: MARKDOWN_MIME, + }) } _ => Err(Error::invalid_params().data(format!( "Source type '{}' export is not supported.", @@ -1071,143 +1385,27 @@ pub fn export_source_with_roots( } pub fn import_sources( - data: &str, + bytes: &[u8], + filename: &str, + source_type: Option, 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)) - ); - } - - let type_str = value - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or("skill"); - let source_type = match type_str { - "skill" => SourceType::Skill, - "project" => SourceType::Project, - "agent" => SourceType::Agent, - other => { - return Err(Error::invalid_params() - .data(format!("Source type '{}' import is not supported.", 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")); - } - - // Skills require a description; projects can omit it. - let description = value - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if source_type == SourceType::Skill && description.is_empty() { - return Err(Error::invalid_params().data("Source description must not be empty")); - } - - let content = value - .get("content") - .or_else(|| value.get("instructions")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let mut properties: HashMap = value - .get("properties") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - // The export's top-level "title" wins over a properties.title if both - // exist. - if source_type == SourceType::Project { - if let Some(title) = value.get("title").and_then(|v| v.as_str()) { - if !title.is_empty() { - properties.insert("title".into(), serde_json::Value::String(title.into())); - } - } - } - - if source_type == SourceType::Agent { - if let Some(legacy_metadata) = value.get("metadata").and_then(|v| v.as_object()) { - for (key, value) in legacy_metadata { - properties - .entry(key.clone()) - .or_insert_with(|| value.clone()); - } - } - return create_agent_source( - &name, - &description, - &content, - properties, - global, - project_dir, - ) - .map(|source| vec![source]); - } + let filename = safe_import_filename(filename)?; + let source_type = infer_import_source_type(filename, source_type)?; + let lower = filename.to_lowercase(); match source_type { - SourceType::Skill => { - validate_skill_name(&name)?; - 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); - let mut counter = 2u32; - while base.join(&final_name).exists() { - final_name = format!("{}-imported-{}", name, counter); - counter += 1; - } - } - create_source( - SourceType::Skill, - &final_name, - &description, - &content, - global, - project_dir, - properties, - ) - .map(|entry| vec![entry]) + SourceType::Skill if lower.ends_with(".zip") => { + import_skill_tree(bytes, global, project_dir) } - SourceType::Project => { - validate_project_slug(&name)?; - let mut final_name = name.clone(); - if project_file_path(&final_name).exists() { - final_name = format!("{}-imported", name); - let mut counter = 2u32; - while project_file_path(&final_name).exists() { - final_name = format!("{}-imported-{}", name, counter); - counter += 1; - } - } - create_source( - SourceType::Project, - &final_name, - &description, - &content, - true, - None, - properties, - ) - .map(|entry| vec![entry]) - } - _ => unreachable!(), + SourceType::Skill => import_skill_file(bytes, global, project_dir), + SourceType::Project => import_project_file(bytes, filename), + SourceType::Agent => import_agent_file(bytes, global, project_dir), + _ => Err(Error::invalid_params().data(format!( + "Source type '{}' import is not supported.", + source_type + ))), } } @@ -1380,15 +1578,30 @@ mod tests { .unwrap(); let portable_dir = project_a.join(".agents").join("skills").join("portable"); - let (json, filename) = - export_source(SourceType::Skill, portable_dir.to_str().unwrap()).unwrap(); - assert_eq!(filename, "portable.skill.json"); + std::fs::write(portable_dir.join("notes.txt"), "supporting context").unwrap(); - let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap(); + let export = export_source(SourceType::Skill, portable_dir.to_str().unwrap()).unwrap(); + assert_eq!(export.filename, "portable.skill.zip"); + assert_eq!(export.mime_type, "application/zip"); + + let imported = import_sources( + &export.bytes, + &export.filename, + None, + 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"); + assert!(project_b + .join(".agents") + .join("skills") + .join("portable") + .join("notes.txt") + .exists()); } #[test] @@ -1419,10 +1632,9 @@ mod tests { .find(|skill| skill.name == "portable") .expect("expected listed skill"); - let (json, filename) = - export_source(SourceType::Skill, exported_skill.path.as_str()).unwrap(); - assert_eq!(filename, "portable.skill.json"); - assert!(json.contains("\"name\": \"portable\"")); + let export = export_source(SourceType::Skill, exported_skill.path.as_str()).unwrap(); + assert_eq!(export.filename, "portable.skill.zip"); + assert_eq!(export.mime_type, "application/zip"); } #[test] @@ -1462,7 +1674,7 @@ mod tests { } #[test] - fn import_collision_appends_suffix() { + fn import_collision_uses_shared_suffix_logic() { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); @@ -1477,16 +1689,17 @@ mod tests { ) .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"); + let busy_dir = tmp.path().join(".agents").join("skills").join("busy"); + let exported = export_source(SourceType::Skill, busy_dir.to_str().unwrap()).unwrap(); + let imported = import_sources( + &exported.bytes, + &exported.filename, + None, + false, + Some(project), + ) + .unwrap(); + assert_eq!(imported[0].name, "busy-2"); } #[test] @@ -1640,15 +1853,15 @@ mod tests { let err = export_source(SourceType::Recipe, "x").unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); - let payload = serde_json::json!({ - "version": 1, - "type": "builtinSkill", - "name": "x", - "description": "d", - "content": "c", - }) - .to_string(); - let err = import_sources(&payload, false, Some(project)).unwrap_err(); + let payload = build_skill_md("x", "d", "c", &HashMap::new()).into_bytes(); + let err = import_sources( + &payload, + "x.skill.md", + Some(SourceType::BuiltinSkill), + false, + Some(project), + ) + .unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); } @@ -1748,7 +1961,7 @@ mod tests { assert_eq!(matching[0].description, "preferred"); let exported = export_source(SourceType::Skill, matching[0].path.as_str()).unwrap(); - assert!(exported.0.contains("preferred")); + assert_eq!(exported.filename, "shared-skill.skill.zip"); } #[test] diff --git a/ui/goose2/src/features/agents/lib/personaImport.ts b/ui/goose2/src/features/agents/lib/personaImport.ts index 99c0d3b135..0169d0ad39 100644 --- a/ui/goose2/src/features/agents/lib/personaImport.ts +++ b/ui/goose2/src/features/agents/lib/personaImport.ts @@ -1,10 +1,4 @@ -const JSON_MIME_TYPES = new Set([ - "", - "application/json", - "application/x-json", - "text/json", - "text/plain", -]); +const MARKDOWN_MIME_TYPES = new Set(["", "text/markdown", "text/plain"]); export interface ImportMessageDescriptor { key: @@ -19,13 +13,13 @@ export function validatePersonaImportFile( file: Pick, ): ImportMessageDescriptor | null { const lowerName = file.name.toLowerCase(); - if (!lowerName.endsWith(".json")) { + if (!lowerName.endsWith(".agent.md") && !lowerName.endsWith(".persona.md")) { return { key: "view.importInvalidExtension", } satisfies ImportMessageDescriptor; } - if (!JSON_MIME_TYPES.has(file.type)) { + if (!MARKDOWN_MIME_TYPES.has(file.type)) { return { key: "view.importInvalidMimeType", } satisfies ImportMessageDescriptor; diff --git a/ui/goose2/src/features/agents/ui/AgentsView.tsx b/ui/goose2/src/features/agents/ui/AgentsView.tsx index 7cac120ab0..fc914be857 100644 --- a/ui/goose2/src/features/agents/ui/AgentsView.tsx +++ b/ui/goose2/src/features/agents/ui/AgentsView.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { open } from "@tauri-apps/plugin-dialog"; import { Plus, Upload } from "lucide-react"; import { toast } from "sonner"; +import { downloadBase64File } from "@/shared/lib/encoding"; import { SearchBar } from "@/shared/ui/SearchBar"; import { Button, buttonVariants } from "@/shared/ui/button"; import { @@ -142,16 +143,11 @@ export function AgentsView() { async (persona: Persona) => { try { const result = await exportPersona(persona.id); - // Trigger a browser download with the JSON content - const blob = new Blob([result.json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = result.suggestedFilename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + downloadBase64File( + result.data, + result.suggestedFilename, + result.mimeType, + ); toast.success( t("view.exportedTo", { filename: result.suggestedFilename }), ); @@ -196,8 +192,8 @@ export function AgentsView() { title: t("common:actions.import"), filters: [ { - name: "JSON", - extensions: ["json"], + name: "Agent Markdown", + extensions: ["md"], }, ], }); diff --git a/ui/goose2/src/features/agents/ui/PersonaGallery.tsx b/ui/goose2/src/features/agents/ui/PersonaGallery.tsx index 88c65c1288..6719980f1d 100644 --- a/ui/goose2/src/features/agents/ui/PersonaGallery.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaGallery.tsx @@ -126,7 +126,7 @@ export function PersonaGallery({ diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index 24fadcc75b..211df966d3 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -1,5 +1,6 @@ import type { SourceEntry } from "@aaif/goose-sdk"; import { getClient } from "@/shared/api/acpConnection"; +import { bytesToBase64 } from "@/shared/lib/encoding"; import { basename, deriveProjectRoot, @@ -228,27 +229,33 @@ export async function updateSkill( export async function exportSkill( path: string, -): Promise<{ json: string; filename: string }> { +): Promise<{ data: string; filename: string; mimeType: string }> { const client = await getClient(); const response = await client.goose.GooseSourcesExport({ type: SKILL_SOURCE_TYPE, path, }); - return { json: response.json, filename: response.filename }; + return { + data: response.data, + filename: response.filename, + mimeType: response.mimeType, + }; } export async function importSkills( fileBytes: number[], fileName: string, ): Promise { - if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) { - throw new Error("File must have a .skill.json or .json extension"); + const lowerName = fileName.toLowerCase(); + if (!lowerName.endsWith(".zip")) { + throw new Error("File must be a skill .zip archive"); } - const data = new TextDecoder().decode(new Uint8Array(fileBytes)); const client = await getClient(); const response = await client.goose.GooseSourcesImport({ - data, + data: bytesToBase64(fileBytes), + filename: fileName, + type: SKILL_SOURCE_TYPE, global: true, }); diff --git a/ui/goose2/src/features/skills/hooks/useSkillImportExport.ts b/ui/goose2/src/features/skills/hooks/useSkillImportExport.ts index cc24968a97..dce92c33c1 100644 --- a/ui/goose2/src/features/skills/hooks/useSkillImportExport.ts +++ b/ui/goose2/src/features/skills/hooks/useSkillImportExport.ts @@ -14,7 +14,7 @@ export function useSkillImportExport(onAfterImport: () => Promise) { try { const result = await exportSkill(skill.path); - downloadExport(result.json, result.filename); + downloadExport(result.data, result.filename, result.mimeType); toast.success(t("view.exportedTo", { filename: result.filename })); } catch { toast.error(t("view.exportError")); diff --git a/ui/goose2/src/features/skills/lib/skillsHelpers.ts b/ui/goose2/src/features/skills/lib/skillsHelpers.ts index 89a5627ca7..aef6df65d6 100644 --- a/ui/goose2/src/features/skills/lib/skillsHelpers.ts +++ b/ui/goose2/src/features/skills/lib/skillsHelpers.ts @@ -1,3 +1,4 @@ +import { downloadBase64File } from "@/shared/lib/encoding"; import type { SkillInfo } from "../api/skills"; export type SkillsFilter = "all" | "global" | "builtin" | `project:${string}`; @@ -175,14 +176,10 @@ export function groupSkills( ]; } -export function downloadExport(json: string, filename: string) { - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - URL.revokeObjectURL(url); +export function downloadExport( + data: string, + filename: string, + mimeType: string, +) { + downloadBase64File(data, filename, mimeType); } diff --git a/ui/goose2/src/features/skills/ui/SkillsView.tsx b/ui/goose2/src/features/skills/ui/SkillsView.tsx index 128c5a2a09..56dada3aa2 100644 --- a/ui/goose2/src/features/skills/ui/SkillsView.tsx +++ b/ui/goose2/src/features/skills/ui/SkillsView.tsx @@ -325,7 +325,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) { 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 5be538aacd..0e8528ccfd 100644 --- a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx +++ b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx @@ -87,9 +87,11 @@ vi.mock("../../api/skills", () => ({ projectLinks: [], }), deleteSkill: vi.fn().mockResolvedValue(undefined), - exportSkill: vi - .fn() - .mockResolvedValue({ json: "{}", filename: "test.skill.json" }), + exportSkill: vi.fn().mockResolvedValue({ + data: "", + filename: "test.skill.zip", + mimeType: "application/zip", + }), importSkills: vi.fn().mockResolvedValue([]), })); diff --git a/ui/goose2/src/shared/api/__tests__/agents.test.ts b/ui/goose2/src/shared/api/__tests__/agents.test.ts index ad37b1d3d0..9134505467 100644 --- a/ui/goose2/src/shared/api/__tests__/agents.test.ts +++ b/ui/goose2/src/shared/api/__tests__/agents.test.ts @@ -141,8 +141,9 @@ describe("agents API", () => { it("exportPersona exports an agent source", async () => { mockGooseSourcesExport.mockResolvedValue({ - json: '{"type":"agent"}', - filename: "scout.agent.json", + data: "YWdlbnQ=", + filename: "scout.agent.md", + mimeType: "text/markdown", }); const result = await exportPersona(source.path); @@ -152,26 +153,24 @@ describe("agents API", () => { path: source.path, }); expect(result).toEqual({ - json: '{"type":"agent"}', - suggestedFilename: "scout.agent.json", + data: "YWdlbnQ=", + suggestedFilename: "scout.agent.md", + mimeType: "text/markdown", }); }); - it("importPersonas imports agent source JSON", async () => { + it("importPersonas imports agent source Markdown", async () => { mockGooseSourcesImport.mockResolvedValue({ sources: [source] }); - const data = JSON.stringify({ - version: 1, - type: "agent", - name: "Scout", - description: "Agent", - content: "Research carefully.", - }); + const data = + "---\nname: Scout\ndescription: Agent\n---\nResearch carefully."; const fileBytes = Array.from(new TextEncoder().encode(data)); - const result = await importPersonas(fileBytes, "scout.agent.json"); + const result = await importPersonas(fileBytes, "scout.agent.md"); expect(mockGooseSourcesImport).toHaveBeenCalledWith({ - data, + data: btoa(data), + filename: "scout.agent.md", + type: "agent", global: true, }); expect(result).toHaveLength(1); diff --git a/ui/goose2/src/shared/api/agents.ts b/ui/goose2/src/shared/api/agents.ts index 5d22eeeeb8..121f3ae2ed 100644 --- a/ui/goose2/src/shared/api/agents.ts +++ b/ui/goose2/src/shared/api/agents.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { SourceEntry } from "@aaif/goose-sdk"; import { getClient } from "@/shared/api/acpConnection"; +import { bytesToBase64 } from "@/shared/lib/encoding"; import type { Persona, CreatePersonaRequest, @@ -153,8 +154,9 @@ export async function refreshPersonas(): Promise { } export interface ExportResult { - json: string; + data: string; suggestedFilename: string; + mimeType: string; } export async function exportPersona(id: string): Promise { @@ -163,52 +165,27 @@ export async function exportPersona(id: string): Promise { type: AGENT_SOURCE_TYPE, path: id, }); - return { json: response.json, suggestedFilename: response.filename }; + return { + data: response.data, + suggestedFilename: response.filename, + mimeType: response.mimeType, + }; } export async function importPersonas( fileBytes: number[], fileName: string, ): Promise { - if ( - !fileName.endsWith(".agent.json") && - !fileName.endsWith(".persona.json") && - !fileName.endsWith(".json") - ) { - throw new Error( - "File must have a .agent.json, .persona.json, or .json extension", - ); + const lowerName = fileName.toLowerCase(); + if (!lowerName.endsWith(".agent.md") && !lowerName.endsWith(".persona.md")) { + throw new Error("File must have a .agent.md or .persona.md extension"); } - const raw = new TextDecoder().decode(new Uint8Array(fileBytes)); - const parsed = JSON.parse(raw) as Record; - const data = - parsed.type === AGENT_SOURCE_TYPE - ? raw - : JSON.stringify({ - version: parsed.version ?? 1, - type: AGENT_SOURCE_TYPE, - name: parsed.displayName ?? parsed.name, - description: AGENT_DESCRIPTION, - content: - parsed.systemPrompt ?? parsed.content ?? parsed.instructions ?? "", - properties: { - provider: parsed.provider, - model: parsed.model, - avatar: - typeof parsed.avatar === "string" - ? parsed.avatar - : typeof parsed.avatar === "object" && - parsed.avatar !== null && - "value" in parsed.avatar - ? (parsed.avatar as { value?: unknown }).value - : undefined, - }, - }); - const client = await getClient(); const response = await client.goose.GooseSourcesImport({ - data, + data: bytesToBase64(fileBytes), + filename: fileName, + type: AGENT_SOURCE_TYPE, global: true, }); diff --git a/ui/goose2/src/shared/i18n/locales/en/agents.json b/ui/goose2/src/shared/i18n/locales/en/agents.json index 0cfb11a3f9..f93784d156 100644 --- a/ui/goose2/src/shared/i18n/locales/en/agents.json +++ b/ui/goose2/src/shared/i18n/locales/en/agents.json @@ -76,8 +76,8 @@ "emptyAgentsTitle": "No agents yet", "exportFailed": "Failed to export agent.", "exportedTo": "Exported to {{filename}}", - "importInvalidExtension": "Unsupported file type. Choose a .json file.", - "importInvalidMimeType": "Unsupported file type. Choose a JSON file.", + "importInvalidExtension": "Unsupported file type. Choose a .agent.md or .persona.md file.", + "importInvalidMimeType": "Unsupported file type. Choose a Markdown file.", "importFailed": "Failed to import agent.", "imported_one": "Imported {{count}} agent.", "imported_other": "Imported {{count}} agents.", diff --git a/ui/goose2/src/shared/i18n/locales/es/agents.json b/ui/goose2/src/shared/i18n/locales/es/agents.json index d3fa67b58d..8cf4199d16 100644 --- a/ui/goose2/src/shared/i18n/locales/es/agents.json +++ b/ui/goose2/src/shared/i18n/locales/es/agents.json @@ -76,8 +76,8 @@ "emptyAgentsTitle": "Aún no hay agentes", "exportFailed": "No se pudo exportar el agente.", "exportedTo": "Exportado a {{filename}}", - "importInvalidExtension": "Tipo de archivo no compatible. Elige un archivo .json.", - "importInvalidMimeType": "Tipo de archivo no compatible. Elige un archivo JSON.", + "importInvalidExtension": "Tipo de archivo no compatible. Elige un archivo .agent.md o .persona.md.", + "importInvalidMimeType": "Tipo de archivo no compatible. Elige un archivo Markdown.", "importFailed": "No se pudo importar el agente.", "imported_one": "Se importó {{count}} agente.", "imported_other": "Se importaron {{count}} agentes.", diff --git a/ui/goose2/src/shared/lib/encoding.ts b/ui/goose2/src/shared/lib/encoding.ts new file mode 100644 index 0000000000..d68bd1eb0a --- /dev/null +++ b/ui/goose2/src/shared/lib/encoding.ts @@ -0,0 +1,30 @@ +export function bytesToBase64(fileBytes: number[]): string { + const bytes = new Uint8Array(fileBytes); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +export function downloadBase64File( + data: string, + filename: string, + mimeType: string, +) { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + + const blob = new Blob([bytes], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} diff --git a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts index e028fba9b2..9046609093 100644 --- a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts +++ b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts @@ -286,8 +286,9 @@ export function buildInitScript(options?: { 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: name + ".skill.json", + data: "", + filename: name + ".skill.zip", + mimeType: "application/zip", }); } case "_goose/sources/import": @@ -374,8 +375,9 @@ export function buildInitScript(options?: { return Promise.resolve(null); case "export_persona": return Promise.resolve({ - json: "{}", - suggestedFilename: "persona.json", + data: "", + suggestedFilename: "persona.agent.md", + mimeType: "text/markdown", }); case "import_personas": return Promise.resolve(PERSONAS); diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 8b880d3090..76b9384fbe 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -872,7 +872,8 @@ export type DeleteSourceRequest = { }; /** - * Export a source at an absolute path as a portable JSON payload. + * Export a source at an absolute path as file bytes. Single-file sources are + * exported as their source file; source trees are exported as a `.zip` archive. */ export type ExportSourceRequest = { type: SourceType; @@ -880,17 +881,24 @@ export type ExportSourceRequest = { }; export type ExportSourceResponse = { - json: string; + /** + * Base64-encoded exported file bytes. + */ + data: string; filename: string; + mimeType: string; }; /** - * Import a source from a JSON export payload produced by `_goose/sources/export`. - * The imported source is written into the explicit target scope; on name - * collisions a `-imported` suffix is appended. + * Import a source from base64-encoded file bytes. Single-file sources are + * imported from their source file; source trees are imported from a `.zip` + * archive. The imported source is written into the explicit target scope; on + * name collisions a `-imported` suffix is appended. */ export type ImportSourcesRequest = { data: string; + filename: string; + type?: SourceType | null; global: boolean; projectDir?: string | null; }; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 6d72c52acb..f42e175c66 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -852,7 +852,8 @@ export const zDeleteSourceRequest = z.object({ }); /** - * Export a source at an absolute path as a portable JSON payload. + * Export a source at an absolute path as file bytes. Single-file sources are + * exported as their source file; source trees are exported as a `.zip` archive. */ export const zExportSourceRequest = z.object({ type: zSourceType, @@ -860,17 +861,24 @@ export const zExportSourceRequest = z.object({ }); export const zExportSourceResponse = z.object({ - json: z.string(), - filename: z.string() + data: z.string(), + filename: z.string(), + mimeType: z.string() }); /** - * Import a source from a JSON export payload produced by `_goose/sources/export`. - * The imported source is written into the explicit target scope; on name - * collisions a `-imported` suffix is appended. + * Import a source from base64-encoded file bytes. Single-file sources are + * imported from their source file; source trees are imported from a `.zip` + * archive. 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(), + filename: z.string(), + type: z.union([ + zSourceType, + z.null() + ]).optional(), global: z.boolean(), projectDir: z.union([ z.string(),