import/export without json

This commit is contained in:
Jack Amadeo
2026-05-08 11:16:27 -04:00
parent d05f785ded
commit 784e297f24
21 changed files with 629 additions and 366 deletions
+12 -5
View File
@@ -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<SourceType>,
pub global: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_dir: Option<String>,
+25 -6
View File
@@ -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"
},
+18 -4
View File
@@ -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<ExportSourceResponse, agent_client_protocol::Error> {
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<ImportSourcesResponse, agent_client_protocol::Error> {
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 })
}
}
-10
View File
@@ -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::<SkillFrontmatter>(raw) {
Ok(Some((meta, body))) => (meta.description, body),
_ => (String::new(), raw.to_string()),
}
}
/// Every directory the agent reads skills from, paired with whether each is a
/// global (home-rooted) location. Order matches discovery precedence: project
/// dirs first, then global dirs.
+441 -228
View File
@@ -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<T: for<'de> 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<u8>,
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<Cursor<&mut Vec<u8>>>,
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<Vec<u8>, 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<SourceType>,
) -> Result<SourceType, Error> {
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<SourceEntry, Error> {
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<PathBuf, Error> {
file.enclosed_name()
.ok_or_else(|| Error::invalid_params().data("Invalid path in source archive"))
}
fn find_imported_skill_root(
archive: &mut zip::ZipArchive<Cursor<&[u8]>>,
) -> Result<PathBuf, Error> {
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<Cursor<&[u8]>>,
path: &Path,
) -> Result<String, Error> {
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<Cursor<&[u8]>>,
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<Vec<SourceEntry>, 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<Vec<SourceEntry>, 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<Vec<SourceEntry>, 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<Vec<SourceEntry>, 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<SourceExport, Error> {
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<SourceExport, Error> {
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<SourceType>,
global: bool,
project_dir: Option<&str>,
) -> Result<Vec<SourceEntry>, 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<String, serde_json::Value> = 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]
@@ -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<File, "name" | "type">,
): 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;
@@ -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"],
},
],
});
@@ -126,7 +126,7 @@ export function PersonaGallery({
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
accept=".agent.md,.persona.md,text/markdown,text/plain"
className="hidden"
onChange={handleFileChange}
/>
+13 -6
View File
@@ -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<SkillInfo[]> {
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,
});
@@ -14,7 +14,7 @@ export function useSkillImportExport(onAfterImport: () => Promise<void>) {
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"));
@@ -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);
}
@@ -325,7 +325,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
<input
ref={fileInputRef}
type="file"
accept=".skill.json,.json"
accept=".zip,application/zip"
className="hidden"
onChange={handleFileChange}
/>
@@ -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([]),
}));
@@ -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);
+14 -37
View File
@@ -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<Persona[]> {
}
export interface ExportResult {
json: string;
data: string;
suggestedFilename: string;
mimeType: string;
}
export async function exportPersona(id: string): Promise<ExportResult> {
@@ -163,52 +165,27 @@ export async function exportPersona(id: string): Promise<ExportResult> {
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<Persona[]> {
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<string, unknown>;
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,
});
@@ -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.",
@@ -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.",
+30
View File
@@ -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);
}
+6 -4
View File
@@ -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);
+13 -5
View File
@@ -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;
};
+14 -6
View File
@@ -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(),