create zips

This commit is contained in:
Jack Amadeo
2026-05-08 12:26:47 -04:00
parent 784e297f24
commit d89d7cce78
17 changed files with 345 additions and 229 deletions
+40
View File
@@ -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"
+1
View File
@@ -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);
}
}
+190
View File
@@ -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 -1
View File
@@ -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;
+1 -1
View File
@@ -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)}`;
}
+14 -51
View File
@@ -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 });
}
+17 -3
View File
@@ -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>
);
-17
View File
@@ -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.",