mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
create zips
This commit is contained in:
Generated
+40
@@ -1219,6 +1219,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1673,6 +1674,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5151,6 +5153,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -6468,12 +6476,44 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap 2.13.1",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.2.0"
|
||||
|
||||
@@ -34,6 +34,7 @@ ignore = "0.4.25"
|
||||
base64 = "0.22"
|
||||
mime_guess = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
zip = { version = "8.6", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportFileReadResult {
|
||||
pub file_bytes: Vec<u8>,
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
fn validate_import_persona_path(source_path: &str) -> Result<PathBuf, String> {
|
||||
let path = PathBuf::from(source_path);
|
||||
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("Selected file path is empty".to_string());
|
||||
}
|
||||
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.ok_or_else(|| "Unsupported file type. Expected a .json file.".to_string())?;
|
||||
if !extension.eq_ignore_ascii_case("json") {
|
||||
return Err("Unsupported file type. Expected a .json file.".to_string());
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(&path)
|
||||
.map_err(|err| format!("Failed to access import file '{}': {}", path.display(), err))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(format!(
|
||||
"Selected import path '{}' is not a file",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_import_persona_file(source_path: String) -> Result<ImportFileReadResult, String> {
|
||||
let path = validate_import_persona_path(&source_path)?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| "Selected file is missing a valid filename".to_string())?
|
||||
.to_string();
|
||||
let file_bytes = std::fs::read(&path)
|
||||
.map_err(|err| format!("Failed to read import file '{}': {}", path.display(), err))?;
|
||||
|
||||
Ok(ImportFileReadResult {
|
||||
file_bytes,
|
||||
file_name,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_import_persona_path;
|
||||
|
||||
#[test]
|
||||
fn validate_import_persona_path_rejects_non_json_files() {
|
||||
let path = std::env::temp_dir().join("persona-import.txt");
|
||||
std::fs::write(&path, b"{}").unwrap();
|
||||
|
||||
let result = validate_import_persona_path(path.to_str().unwrap());
|
||||
|
||||
assert!(result.is_err());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_import_persona_path_rejects_directories() {
|
||||
let dir = std::env::temp_dir().join(format!("persona-import-dir-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let result = validate_import_persona_path(dir.to_str().unwrap());
|
||||
|
||||
assert!(result.is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_import_persona_path_accepts_json_files() {
|
||||
let path = std::env::temp_dir().join(format!("persona-import-{}.json", std::process::id()));
|
||||
std::fs::write(&path, b"{}").unwrap();
|
||||
|
||||
let validated = validate_import_persona_path(path.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(validated, path);
|
||||
let _ = std::fs::remove_file(validated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ZipArchiveResult {
|
||||
pub data: String,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
fn validate_archive_root(source_path: &str) -> Result<PathBuf, String> {
|
||||
let path = PathBuf::from(source_path);
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("Selected directory path is empty".to_string());
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&path)
|
||||
.map_err(|err| format!("Failed to access directory '{}': {}", path.display(), err))?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(format!(
|
||||
"Selected path '{}' is not a directory",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
if !path.join("SKILL.md").is_file() {
|
||||
return Err(format!(
|
||||
"Selected directory '{}' must contain a SKILL.md file",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn archive_root_name(root: &Path) -> String {
|
||||
root.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("skill")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn archive_filename(root: &Path) -> String {
|
||||
format!("{}.skill.zip", archive_root_name(root))
|
||||
}
|
||||
|
||||
fn archive_path(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
fn relative_zip_path(root: &Path, path: &Path) -> Result<String, String> {
|
||||
let relative = path.strip_prefix(root).map_err(|err| {
|
||||
format!(
|
||||
"Failed to resolve '{}' relative to '{}': {}",
|
||||
path.display(),
|
||||
root.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
Ok(format!(
|
||||
"{}/{}",
|
||||
archive_root_name(root),
|
||||
archive_path(relative)
|
||||
))
|
||||
}
|
||||
|
||||
fn should_skip_archive_dir(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.file_name().and_then(|name| name.to_str()),
|
||||
Some(".git") | Some(".hg") | Some(".svn")
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_files(root: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
|
||||
while let Some(dir) = stack.pop() {
|
||||
let entries = fs::read_dir(&dir)
|
||||
.map_err(|err| format!("Failed to read directory '{}': {}", dir.display(), err))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|err| {
|
||||
format!(
|
||||
"Failed to read directory entry in '{}': {}",
|
||||
dir.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
let path = entry.path();
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|err| format!("Failed to inspect '{}': {}", path.display(), err))?;
|
||||
if file_type.is_dir() {
|
||||
if !should_skip_archive_dir(&path) {
|
||||
stack.push(path);
|
||||
}
|
||||
} else if file_type.is_file() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.sort_by_key(|path| path.to_string_lossy().to_lowercase());
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn zip_directory(root: &Path) -> Result<Vec<u8>, String> {
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
let mut zip = zip::ZipWriter::new(&mut cursor);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
|
||||
for path in collect_files(root)? {
|
||||
let name = relative_zip_path(root, &path)?;
|
||||
zip.start_file(name, options)
|
||||
.map_err(|err| format!("Failed to add file to archive: {err}"))?;
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|err| format!("Failed to read '{}': {}", path.display(), err))?;
|
||||
zip.write_all(&bytes)
|
||||
.map_err(|err| format!("Failed to write archive entry: {err}"))?;
|
||||
}
|
||||
|
||||
zip.finish()
|
||||
.map_err(|err| format!("Failed to finish archive: {err}"))?;
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_zip_archive(source_path: String) -> Result<ZipArchiveResult, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let root = validate_archive_root(&source_path)?;
|
||||
let bytes = zip_directory(&root)?;
|
||||
Ok(ZipArchiveResult {
|
||||
data: BASE64.encode(bytes),
|
||||
filename: archive_filename(&root),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Failed to create archive: {err}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{archive_filename, validate_archive_root, zip_directory};
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
#[test]
|
||||
fn validates_skill_directory() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
fs::write(dir.path().join("SKILL.md"), "---\nname: test\n---\n").expect("skill");
|
||||
|
||||
assert_eq!(
|
||||
validate_archive_root(dir.path().to_str().unwrap()).unwrap(),
|
||||
dir.path()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_directory_without_skill_file() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
|
||||
let error = validate_archive_root(dir.path().to_str().unwrap()).expect_err("missing skill");
|
||||
|
||||
assert!(error.contains("SKILL.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_archive_with_root_directory() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let root = dir.path().join("my-skill");
|
||||
fs::create_dir_all(root.join("assets")).expect("dirs");
|
||||
fs::write(root.join("SKILL.md"), "---\nname: my-skill\n---\nBody").expect("skill");
|
||||
fs::write(root.join("assets").join("note.txt"), "note").expect("note");
|
||||
|
||||
let bytes = zip_directory(&root).expect("zip");
|
||||
let mut archive = ZipArchive::new(std::io::Cursor::new(bytes)).expect("archive");
|
||||
|
||||
assert!(archive.by_name("my-skill/SKILL.md").is_ok());
|
||||
assert!(archive.by_name("my-skill/assets/note.txt").is_ok());
|
||||
assert_eq!(archive_filename(&root), "my-skill.skill.zip");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
pub mod acp;
|
||||
pub mod agent_setup;
|
||||
pub mod agents;
|
||||
pub mod archive;
|
||||
pub mod distro;
|
||||
pub mod doctor;
|
||||
pub mod git;
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::agents::read_import_persona_file,
|
||||
commands::archive::create_zip_archive,
|
||||
commands::acp::get_goose_serve_url,
|
||||
commands::acp::get_goose_serve_host_info,
|
||||
commands::project_icons::scan_project_icons,
|
||||
|
||||
@@ -1,33 +1,8 @@
|
||||
const MARKDOWN_MIME_TYPES = new Set(["", "text/markdown", "text/plain"]);
|
||||
|
||||
export interface ImportMessageDescriptor {
|
||||
key:
|
||||
| "view.importInvalidExtension"
|
||||
| "view.importInvalidMimeType"
|
||||
| "view.imported_one"
|
||||
| "view.imported_other";
|
||||
key: "view.imported_one" | "view.imported_other";
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function validatePersonaImportFile(
|
||||
file: Pick<File, "name" | "type">,
|
||||
): ImportMessageDescriptor | null {
|
||||
const lowerName = file.name.toLowerCase();
|
||||
if (!lowerName.endsWith(".agent.md") && !lowerName.endsWith(".persona.md")) {
|
||||
return {
|
||||
key: "view.importInvalidExtension",
|
||||
} satisfies ImportMessageDescriptor;
|
||||
}
|
||||
|
||||
if (!MARKDOWN_MIME_TYPES.has(file.type)) {
|
||||
return {
|
||||
key: "view.importInvalidMimeType",
|
||||
} satisfies ImportMessageDescriptor;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatImportSuccessMessage(
|
||||
importedCount: number,
|
||||
): ImportMessageDescriptor {
|
||||
@@ -42,11 +17,16 @@ export function formatImportSuccessMessage(
|
||||
}
|
||||
|
||||
export function formatAgentError(error: unknown, fallback: string): string {
|
||||
if (typeof error === "string" && error.trim().length > 0) {
|
||||
return error;
|
||||
const message =
|
||||
typeof error === "string" && error.trim().length > 0
|
||||
? error.trim()
|
||||
: error instanceof Error && error.message.trim().length > 0
|
||||
? error.message.trim()
|
||||
: fallback;
|
||||
|
||||
if (typeof error !== "object" || error === null || !("data" in error)) {
|
||||
return message;
|
||||
}
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
|
||||
return `${message}\n${JSON.stringify(error.data)}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
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";
|
||||
@@ -23,11 +22,7 @@ import {
|
||||
} from "@/features/agents/stores/agentSelectors";
|
||||
import { PersonaGallery } from "@/features/agents/ui/PersonaGallery";
|
||||
import { PersonaEditor } from "@/features/agents/ui/PersonaEditor";
|
||||
import {
|
||||
exportPersona,
|
||||
importPersonas,
|
||||
readImportPersonaFile,
|
||||
} from "@/shared/api/agents";
|
||||
import { exportPersona, importPersonas } from "@/shared/api/agents";
|
||||
import { usePersonas } from "@/features/agents/hooks/usePersonas";
|
||||
import type {
|
||||
Persona,
|
||||
@@ -37,9 +32,9 @@ import type {
|
||||
import {
|
||||
formatAgentError,
|
||||
formatImportSuccessMessage,
|
||||
validatePersonaImportFile,
|
||||
} from "@/features/agents/lib/personaImport";
|
||||
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
|
||||
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
|
||||
|
||||
export function AgentsView() {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
@@ -162,14 +157,6 @@ export function AgentsView() {
|
||||
toast.error(message);
|
||||
}, []);
|
||||
|
||||
const validateImportFile = useCallback(
|
||||
(file: Pick<File, "name" | "type">) => {
|
||||
const message = validatePersonaImportFile(file);
|
||||
return message ? t(message.key, message.options) : null;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleImportFileBytes = useCallback(
|
||||
async (fileBytes: number[], fileName: string) => {
|
||||
try {
|
||||
@@ -184,40 +171,10 @@ export function AgentsView() {
|
||||
[refreshFromDisk, t],
|
||||
);
|
||||
|
||||
const handleImportPicker = useCallback(async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
title: t("common:actions.import"),
|
||||
filters: [
|
||||
{
|
||||
name: "Agent Markdown",
|
||||
extensions: ["md"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!selected || Array.isArray(selected)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { fileBytes, fileName } = await readImportPersonaFile(selected);
|
||||
const validationMessage = validateImportFile({
|
||||
name: fileName,
|
||||
type: "",
|
||||
});
|
||||
|
||||
if (validationMessage) {
|
||||
toast.error(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
await handleImportFileBytes(fileBytes, fileName);
|
||||
} catch (err) {
|
||||
toast.error(formatAgentError(err, t("view.importFailed")));
|
||||
}
|
||||
}, [handleImportFileBytes, t, validateImportFile]);
|
||||
const { fileInputRef, handleFileChange, openFilePicker } = useFileImportZone({
|
||||
onImportFile: handleImportFileBytes,
|
||||
onImportError: handleImportError,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col h-full min-h-0">
|
||||
@@ -238,7 +195,7 @@ export function AgentsView() {
|
||||
type="button"
|
||||
variant="outline-flat"
|
||||
size="sm"
|
||||
onClick={() => void handleImportPicker()}
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{t("common:actions.import")}
|
||||
@@ -252,6 +209,13 @@ export function AgentsView() {
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t("view.newPersona")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".md,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -273,7 +237,6 @@ export function AgentsView() {
|
||||
onExportPersona={handleExportPersona}
|
||||
onCreatePersona={() => openPersonaEditor()}
|
||||
onImportFile={handleImportFileBytes}
|
||||
validateImportFile={validateImportFile}
|
||||
onImportError={handleImportError}
|
||||
isLoading={personasLoading}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,6 @@ interface PersonaGalleryProps {
|
||||
onExportPersona?: (persona: Persona) => void;
|
||||
onCreatePersona: () => void;
|
||||
onImportFile?: (fileBytes: number[], fileName: string) => void;
|
||||
validateImportFile?: (file: Pick<File, "name" | "type">) => string | null;
|
||||
onImportError?: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
@@ -47,7 +46,6 @@ export function PersonaGallery({
|
||||
onExportPersona,
|
||||
onCreatePersona,
|
||||
onImportFile,
|
||||
validateImportFile,
|
||||
onImportError,
|
||||
isLoading = false,
|
||||
}: PersonaGalleryProps) {
|
||||
@@ -55,7 +53,6 @@ export function PersonaGallery({
|
||||
const { fileInputRef, isDragOver, dropHandlers, handleFileChange } =
|
||||
useFileImportZone({
|
||||
onImportFile: onImportFile ?? (() => {}),
|
||||
validateFile: validateImportFile,
|
||||
onImportError,
|
||||
});
|
||||
const sorted = useMemo(() => {
|
||||
@@ -126,7 +123,7 @@ export function PersonaGallery({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".agent.md,.persona.md,text/markdown,text/plain"
|
||||
accept=".md,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface ZipArchiveResult {
|
||||
data: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export async function createZipArchive(
|
||||
sourcePath: string,
|
||||
): Promise<ZipArchiveResult> {
|
||||
return invoke("create_zip_archive", { sourcePath });
|
||||
}
|
||||
@@ -242,8 +242,8 @@ export async function exportSkill(
|
||||
};
|
||||
}
|
||||
|
||||
export async function importSkills(
|
||||
fileBytes: number[],
|
||||
async function importSkillsBase64(
|
||||
data: string,
|
||||
fileName: string,
|
||||
): Promise<SkillInfo[]> {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
@@ -253,7 +253,7 @@ export async function importSkills(
|
||||
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesImport({
|
||||
data: bytesToBase64(fileBytes),
|
||||
data,
|
||||
filename: fileName,
|
||||
type: SKILL_SOURCE_TYPE,
|
||||
global: true,
|
||||
@@ -261,3 +261,17 @@ export async function importSkills(
|
||||
|
||||
return response.sources.filter(isFilesystemSkillSource).map(toSkillInfo);
|
||||
}
|
||||
|
||||
export async function importSkills(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
): Promise<SkillInfo[]> {
|
||||
return importSkillsBase64(bytesToBase64(fileBytes), fileName);
|
||||
}
|
||||
|
||||
export async function importSkillsArchive(
|
||||
data: string,
|
||||
fileName: string,
|
||||
): Promise<SkillInfo[]> {
|
||||
return importSkillsBase64(data, fileName);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
|
||||
import { exportSkill, importSkills, type SkillInfo } from "../api/skills";
|
||||
import { createZipArchive } from "../api/archive";
|
||||
import {
|
||||
exportSkill,
|
||||
importSkills,
|
||||
importSkillsArchive,
|
||||
type SkillInfo,
|
||||
} from "../api/skills";
|
||||
import { formatSkillError } from "../lib/formatSkillError";
|
||||
import { downloadExport } from "../lib/skillsHelpers";
|
||||
|
||||
export function useSkillImportExport(onAfterImport: () => Promise<void>) {
|
||||
const { t } = useTranslation(["skills"]);
|
||||
const { t } = useTranslation(["skills", "common"]);
|
||||
|
||||
const handleExport = async (skill: SkillInfo) => {
|
||||
if (skill.sourceKind === "builtin") {
|
||||
@@ -22,16 +30,39 @@ export function useSkillImportExport(onAfterImport: () => Promise<void>) {
|
||||
};
|
||||
|
||||
const handleImport = async (fileBytes: number[], fileName: string) => {
|
||||
await importSkills(fileBytes, fileName);
|
||||
await onAfterImport();
|
||||
toast.success(t("view.importSuccess"));
|
||||
};
|
||||
|
||||
const handleImportDirectory = async () => {
|
||||
try {
|
||||
await importSkills(fileBytes, fileName);
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: true,
|
||||
title: t("common:actions.import"),
|
||||
});
|
||||
|
||||
if (!selected || Array.isArray(selected)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archive = await createZipArchive(selected);
|
||||
await importSkillsArchive(archive.data, archive.filename);
|
||||
await onAfterImport();
|
||||
toast.success(t("view.importSuccess"));
|
||||
} catch {
|
||||
toast.error(t("view.importError"));
|
||||
} catch (error) {
|
||||
toast.error(formatSkillError(error, t("view.importError")));
|
||||
}
|
||||
};
|
||||
|
||||
const fileImport = useFileImportZone({ onImportFile: handleImport });
|
||||
const fileImport = useFileImportZone({
|
||||
onImportFile: (fileBytes, fileName) => {
|
||||
void handleImport(fileBytes, fileName).catch((error) => {
|
||||
toast.error(formatSkillError(error, t("view.importError")));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { ...fileImport, handleExport };
|
||||
return { ...fileImport, handleImportDirectory, handleExport };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export function formatSkillError(error: unknown, fallback: string): string {
|
||||
const message =
|
||||
typeof error === "string" && error.trim().length > 0
|
||||
? error.trim()
|
||||
: error instanceof Error && error.message.trim().length > 0
|
||||
? error.message.trim()
|
||||
: fallback;
|
||||
|
||||
if (typeof error !== "object" || error === null || !("data" in error)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return `${message}\n${JSON.stringify(error.data)}`;
|
||||
}
|
||||
@@ -209,14 +209,8 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
await loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
const {
|
||||
fileInputRef,
|
||||
isDragOver,
|
||||
dropHandlers,
|
||||
handleFileChange,
|
||||
openFilePicker,
|
||||
handleExport,
|
||||
} = useSkillImportExport(refreshSkills);
|
||||
const { isDragOver, dropHandlers, handleImportDirectory, handleExport } =
|
||||
useSkillImportExport(refreshSkills);
|
||||
|
||||
const handleShare = useCallback(
|
||||
(skill: SkillInfo) => {
|
||||
@@ -273,7 +267,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
type="button"
|
||||
variant="outline-flat"
|
||||
size="xs"
|
||||
onClick={openFilePicker}
|
||||
onClick={() => void handleImportDirectory()}
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
{t("common:actions.import")}
|
||||
@@ -318,18 +312,10 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
isDragOver={isDragOver}
|
||||
dropHandlers={dropHandlers}
|
||||
onNewSkill={handleNewSkill}
|
||||
onImport={openFilePicker}
|
||||
onImport={() => void handleImportDirectory()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{dialogs}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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";
|
||||
@@ -176,11 +175,6 @@ export async function importPersonas(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
): Promise<Persona[]> {
|
||||
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 client = await getClient();
|
||||
const response = await client.goose.GooseSourcesImport({
|
||||
data: bytesToBase64(fileBytes),
|
||||
@@ -191,14 +185,3 @@ export async function importPersonas(
|
||||
|
||||
return response.sources.filter(isAgentSource).map(toPersona);
|
||||
}
|
||||
|
||||
export interface ImportFileReadResult {
|
||||
fileBytes: number[];
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export async function readImportPersonaFile(
|
||||
sourcePath: string,
|
||||
): Promise<ImportFileReadResult> {
|
||||
return invoke("read_import_persona_file", { sourcePath });
|
||||
}
|
||||
|
||||
@@ -76,8 +76,6 @@
|
||||
"emptyAgentsTitle": "No agents yet",
|
||||
"exportFailed": "Failed to export agent.",
|
||||
"exportedTo": "Exported to {{filename}}",
|
||||
"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,6 @@
|
||||
"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 .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.",
|
||||
|
||||
Reference in New Issue
Block a user