diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 98312fad45..277800a719 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -742,7 +742,7 @@ 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 its canonical Markdown payload. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/sources/export", response = ExportSourceResponse)] #[serde(rename_all = "camelCase")] @@ -755,11 +755,12 @@ pub struct ExportSourceRequest { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] #[serde(rename_all = "camelCase")] pub struct ExportSourceResponse { + /// Canonical source contents. The field remains `json` for wire compatibility. pub json: String, pub filename: String, } -/// Import a source from a JSON export payload produced by `_goose/sources/export`. +/// Import a source from a canonical Markdown payload produced by `_goose/sources/export`. /// 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)] diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index 9a353803af..fe91694cb4 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -1,9 +1,8 @@ //! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom 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, + build_skill_md, discover_skills, is_global_skill_dir, resolve_discoverable_skill_dir, + resolve_skill_dir, skill_base_dir, validate_skill_name, SkillFrontmatter, }; use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; @@ -180,21 +179,7 @@ pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, Str 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)) + Ok((raw, "SKILL.md".to_string())) } pub fn import_sources( @@ -202,58 +187,19 @@ pub fn import_sources( global: bool, project_dir: Option<&str>, ) -> Result, Error> { - let value: serde_json::Value = serde_json::from_str(data) - .map_err(|e| Error::invalid_params().data(format!("Invalid JSON: {e}")))?; + let (metadata, content): (SkillFrontmatter, String) = parse_frontmatter(data) + .map_err(|e| Error::invalid_params().data(format!("Invalid skill frontmatter: {e}")))? + .ok_or_else(|| Error::invalid_params().data("Missing skill frontmatter"))?; - 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)) - ); - } - - match value - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or("skill") - { - "skill" => {} - other => { - return Err(Error::invalid_params().data(format!( - "Source type '{}' is not supported. Only 'skill' is currently 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")); - } - - let description = value - .get("description") - .and_then(|v| v.as_str()) - .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"description\" field"))? - .to_string(); - if description.is_empty() { + let name = metadata + .name + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"name\" field"))?; + let description = metadata.description; + if description.trim().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(); - validate_skill_name(&name)?; let base = skill_base_dir(global, project_dir)?; @@ -378,11 +324,11 @@ mod tests { .unwrap(); let portable_dir = project_a.join(".agents").join("skills").join("portable"); - let (json, filename) = + let (markdown, filename) = export_source(SourceType::Skill, portable_dir.to_str().unwrap()).unwrap(); - assert_eq!(filename, "portable.skill.json"); + assert_eq!(filename, "SKILL.md"); - let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap(); + let imported = import_sources(&markdown, 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"); @@ -408,10 +354,10 @@ mod tests { .find(|skill| skill.name == "portable") .expect("expected listed skill"); - let (json, filename) = + let (markdown, filename) = export_source(SourceType::Skill, exported_skill.directory.as_str()).unwrap(); - assert_eq!(filename, "portable.skill.json"); - assert!(json.contains("\"name\": \"portable\"")); + assert_eq!(filename, "SKILL.md"); + assert!(markdown.contains("name: portable")); } #[test] @@ -451,14 +397,7 @@ mod tests { create_source(SourceType::Skill, "busy", "d", "c", false, Some(project)).unwrap(); - let payload = serde_json::json!({ - "version": 1, - "type": "skill", - "name": "busy", - "description": "d", - "content": "c", - }) - .to_string(); + let payload = build_skill_md("busy", "d", "c"); let imported = import_sources(&payload, false, Some(project)).unwrap(); assert_eq!(imported[0].name, "busy-imported"); } diff --git a/ui/goose2/src-tauri/src/commands/agents.rs b/ui/goose2/src-tauri/src/commands/agents.rs index 3d3564c4cb..18c07c29b1 100644 --- a/ui/goose2/src-tauri/src/commands/agents.rs +++ b/ui/goose2/src-tauri/src/commands/agents.rs @@ -77,9 +77,9 @@ fn validate_import_persona_path(source_path: &str) -> Result { 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()); + .ok_or_else(|| "Unsupported file type. Expected a .md file.".to_string())?; + if !extension.eq_ignore_ascii_case("md") { + return Err("Unsupported file type. Expected a .md file.".to_string()); } let metadata = std::fs::metadata(&path) @@ -111,153 +111,73 @@ pub fn read_import_persona_file(source_path: String) -> Result, avatar: Option, - #[serde(skip_serializing_if = "Option::is_none")] provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] model: Option, } -/// Result returned by export_persona containing the JSON string and a suggested filename. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ExportResult { - pub json: String, - pub suggested_filename: String, -} - -/// Convert a display name into a filesystem-safe slug. -/// Lowercase, replace non-alphanumeric with hyphens, collapse runs, trim, max 50 chars. -pub fn slugify(name: &str) -> String { - let slug: String = name - .to_lowercase() - .chars() - .map(|c| if c.is_alphanumeric() { c } else { '-' }) - .collect(); - - // Collapse consecutive hyphens - let mut collapsed = String::with_capacity(slug.len()); - let mut prev_hyphen = false; - for c in slug.chars() { - if c == '-' { - if !prev_hyphen { - collapsed.push('-'); - } - prev_hyphen = true; - } else { - collapsed.push(c); - prev_hyphen = false; - } +fn parse_markdown_agent(content: &str) -> Result { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return Err("Missing frontmatter delimiter".to_string()); } - let trimmed = collapsed.trim_matches('-'); - let result = if trimmed.len() > 50 { - // Cut at 50 chars without splitting mid-char, then trim trailing hyphens - trimmed[..50].trim_end_matches('-').to_string() - } else { - trimmed.to_string() - }; + let after_first = &trimmed[3..]; + let end_idx = after_first + .find("\n---") + .ok_or_else(|| "Missing closing frontmatter delimiter".to_string())?; + let yaml = &after_first[..end_idx]; + let body = after_first[end_idx + 4..].trim().to_string(); + let frontmatter: MarkdownAgentFrontmatter = serde_yaml::from_str(yaml) + .map_err(|e| format!("Invalid frontmatter YAML: {}", e))?; - if result.is_empty() { - "persona".to_string() - } else { - result + if frontmatter.name.trim().is_empty() { + return Err("Agent name cannot be empty".to_string()); } -} -/// Export a persona as sprout-compatible JSON (version 1). -/// Returns the JSON string and a suggested filename. -#[tauri::command] -pub fn export_persona(store: State<'_, PersonaStore>, id: String) -> Result { - let persona = store - .get(&id) - .ok_or_else(|| format!("Persona '{}' not found", id))?; - - // For export, only include URL avatars (local files aren't portable) - let export_avatar = match &persona.avatar { - Some(Avatar::Url(url)) => Some(Avatar::Url(url.clone())), - _ => None, + let system_prompt = if body.is_empty() { + frontmatter + .description + .clone() + .unwrap_or_else(|| format!("You are {}.", frontmatter.name)) + } else { + body }; - let export = PersonaExportV1 { - version: 1, - display_name: persona.display_name.clone(), - system_prompt: persona.system_prompt, - avatar: export_avatar, - provider: persona.provider, - model: persona.model, - }; + if system_prompt.trim().is_empty() { + return Err("Agent prompt cannot be empty".to_string()); + } - let json = serde_json::to_string_pretty(&export) - .map_err(|e| format!("Failed to serialize persona: {}", e))?; - - let slug = slugify(&persona.display_name); - let suggested_filename = format!("{}.persona.json", slug); - - Ok(ExportResult { - json, - suggested_filename, + Ok(CreatePersonaRequest { + display_name: frontmatter.name, + avatar: frontmatter.avatar, + system_prompt, + provider: frontmatter.provider, + model: frontmatter.model, }) } -/// Import personas from sprout-compatible JSON (version 1). -/// Accepts raw file bytes and the original filename. -/// Returns the list of newly created personas. +/// Import an agent from its canonical Markdown format. #[tauri::command] pub fn import_personas( store: State<'_, PersonaStore>, file_bytes: Vec, file_name: String, ) -> Result, String> { - // Validate file extension - if !file_name.ends_with(".persona.json") && !file_name.ends_with(".json") { - return Err("Unsupported file type. Expected a .persona.json or .json file.".to_string()); + if !file_name.to_lowercase().ends_with(".md") { + return Err("Unsupported file type. Expected a .md file.".to_string()); } - // Parse the bytes as UTF-8 let content = String::from_utf8(file_bytes).map_err(|_| "File is not valid UTF-8 text".to_string())?; - - // Parse as JSON - let export: PersonaExportV1 = - serde_json::from_str(&content).map_err(|e| format!("Invalid persona JSON: {}", e))?; - - // Validate version - if export.version != 1 { - return Err(format!( - "Unsupported persona format version {}. Expected version 1.", - export.version - )); - } - - // Validate required fields - if export.display_name.trim().is_empty() { - return Err("Persona displayName cannot be empty".to_string()); - } - if export.system_prompt.trim().is_empty() { - return Err("Persona systemPrompt cannot be empty".to_string()); - } - - // Create the persona via the store - let request = CreatePersonaRequest { - display_name: export.display_name, - avatar: export.avatar, - system_prompt: export.system_prompt, - provider: export.provider, - model: export.model, - }; - - let persona = store.create(request)?; + let request = parse_markdown_agent(&content)?; + let persona = store.import_markdown(&request.display_name, &content)?; Ok(vec![persona]) } @@ -266,8 +186,8 @@ 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"); + fn validate_import_persona_path_rejects_non_markdown_files() { + let path = std::env::temp_dir().join("persona-import.json"); std::fs::write(&path, b"{}").unwrap(); let result = validate_import_persona_path(path.to_str().unwrap()); @@ -288,8 +208,8 @@ mod tests { } #[test] - fn validate_import_persona_path_accepts_json_files() { - let path = std::env::temp_dir().join(format!("persona-import-{}.json", std::process::id())); + fn validate_import_persona_path_accepts_markdown_files() { + let path = std::env::temp_dir().join(format!("persona-import-{}.md", std::process::id())); std::fs::write(&path, b"{}").unwrap(); let validated = validate_import_persona_path(path.to_str().unwrap()).unwrap(); diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 26070ce02f..47326468e1 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -36,7 +36,6 @@ pub fn run() { commands::agents::update_persona, commands::agents::delete_persona, commands::agents::refresh_personas, - commands::agents::export_persona, commands::agents::import_personas, commands::agents::read_import_persona_file, commands::agents::save_persona_avatar, diff --git a/ui/goose2/src-tauri/src/services/personas.rs b/ui/goose2/src-tauri/src/services/personas.rs index a43dbc34c3..f763d0449a 100644 --- a/ui/goose2/src-tauri/src/services/personas.rs +++ b/ui/goose2/src-tauri/src/services/personas.rs @@ -2,32 +2,36 @@ use crate::types::agents::{ builtin_personas, Avatar, CreatePersonaRequest, Persona, UpdatePersonaRequest, }; use log::warn; -use std::collections::HashSet; +use serde::{Deserialize, Serialize}; use std::path::{Component, Path, PathBuf}; use std::sync::Mutex; pub struct PersonaStore { personas: Mutex>, - store_path: PathBuf, } /// YAML frontmatter fields parsed from markdown persona files. -#[derive(serde::Deserialize)] +#[derive(Clone, Default, Deserialize, Serialize)] struct MarkdownFrontmatter { name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + avatar: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, } impl PersonaStore { pub fn new() -> Self { let store_path = Self::store_path(); - let stored = Self::load_from_disk(&store_path); - let markdown = Self::load_markdown_personas(); - let merged = Self::merge_all(stored, markdown); - Self { - personas: Mutex::new(merged), - store_path, - } + Self::migrate_legacy_markdown_agents(); + Self::migrate_legacy_personas_json(&store_path); + Self::ensure_seed_agents(); + let merged = Self::load_markdown_personas(); + Self { personas: Mutex::new(merged) } } fn store_path() -> PathBuf { @@ -35,6 +39,18 @@ impl PersonaStore { base.join(".goose").join("personas.json") } + fn migration_marker_path() -> PathBuf { + Self::agents_dir().join(".personas-json-migrated") + } + + fn seed_marker_path() -> PathBuf { + Self::agents_dir().join(".seed-agents-installed") + } + + fn legacy_markdown_migration_marker_path() -> PathBuf { + Self::agents_dir().join(".goose-agents-migrated") + } + /// Path to the avatars directory (~/.goose/avatars/). pub fn avatars_dir() -> PathBuf { dirs::home_dir() @@ -43,66 +59,149 @@ impl PersonaStore { .join("avatars") } - fn load_from_disk(path: &PathBuf) -> Vec { + fn load_legacy_json(path: &PathBuf) -> Vec { match std::fs::read_to_string(path) { - Ok(contents) => { - let mut personas: Vec = - serde_json::from_str(&contents).unwrap_or_default(); - let source_path = path.to_string_lossy().to_string(); - for persona in &mut personas { - persona.source_path = Some(source_path.clone()); - } - personas - } + Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), Err(_) => Vec::new(), } } - /// Merge builtins, JSON custom personas, and markdown personas. - /// Priority: builtins first, then JSON custom, then markdown. - /// Deduplication is by display_name (case-insensitive). - fn merge_all(stored: Vec, markdown: Vec) -> Vec { - let builtins = builtin_personas(); - - let mut result = builtins; - let mut seen_names: HashSet = result - .iter() - .map(|p| p.display_name.to_lowercase()) - .collect(); - let mut seen_ids: HashSet = result.iter().map(|p| p.id.clone()).collect(); - - // Add custom (non-builtin) personas from JSON - for persona in stored { - if !seen_ids.contains(&persona.id) { - seen_names.insert(persona.display_name.to_lowercase()); - seen_ids.insert(persona.id.clone()); - result.push(persona); - } - } - - // Add markdown personas, skipping any whose name already exists - for persona in markdown { - if !seen_names.contains(&persona.display_name.to_lowercase()) - && !seen_ids.contains(&persona.id) - { - seen_names.insert(persona.display_name.to_lowercase()); - seen_ids.insert(persona.id.clone()); - result.push(persona); - } - } - - result + /// Canonical directory containing global markdown agent files. + fn agents_dir() -> PathBuf { + dirs::home_dir() + .expect("home dir") + .join(".agents") + .join("agents") } - /// Directory containing markdown persona files. - fn agents_dir() -> PathBuf { + /// Previous markdown-agent location. Read only for one-time migration. + fn legacy_agents_dir() -> PathBuf { dirs::home_dir() .expect("home dir") .join(".goose") .join("agents") } - /// Scan `~/.goose/agents/*.md` and parse each into a Persona. + fn ensure_seed_agents() { + let dir = Self::agents_dir(); + if let Err(err) = std::fs::create_dir_all(&dir) { + warn!("Failed to create agents directory {:?}: {}", dir, err); + return; + } + if Self::seed_marker_path().exists() { + return; + } + + for persona in builtin_personas() { + let path = dir.join(format!("{}.md", Self::slugify_name(&persona.display_name))); + if path.exists() { + continue; + } + if let Err(err) = std::fs::write(&path, Self::persona_to_markdown(&persona)) { + warn!("Failed to seed agent {:?}: {}", path, err); + } + } + let _ = std::fs::write(Self::seed_marker_path(), chrono::Utc::now().to_rfc3339()); + } + + fn migrate_legacy_markdown_agents() { + let legacy_dir = Self::legacy_agents_dir(); + if !legacy_dir.is_dir() || Self::legacy_markdown_migration_marker_path().exists() { + return; + } + + let target_dir = Self::agents_dir(); + if let Err(err) = std::fs::create_dir_all(&target_dir) { + warn!("Failed to create agents directory {:?}: {}", target_dir, err); + return; + } + + let entries = match std::fs::read_dir(&legacy_dir) { + Ok(entries) => entries, + Err(err) => { + warn!("Failed to read legacy agents directory {:?}: {}", legacy_dir, err); + return; + } + }; + + for entry in entries.flatten() { + let source = entry.path(); + if source.extension().and_then(|ext| ext.to_str()) != Some("md") { + continue; + } + + let Some(file_name) = source.file_name() else { + continue; + }; + let destination = + Self::unique_agent_path(&target_dir, file_name.to_string_lossy().as_ref()); + if let Err(err) = std::fs::copy(&source, &destination) { + warn!( + "Failed to migrate legacy agent {:?} to {:?}: {}", + source, destination, err + ); + } + } + let _ = std::fs::write( + Self::legacy_markdown_migration_marker_path(), + chrono::Utc::now().to_rfc3339(), + ); + } + + fn migrate_legacy_personas_json(path: &PathBuf) { + if !path.is_file() || Self::migration_marker_path().exists() { + return; + } + + let personas = Self::load_legacy_json(path); + let dir = Self::agents_dir(); + if let Err(err) = std::fs::create_dir_all(&dir) { + warn!("Failed to create agents directory {:?}: {}", dir, err); + return; + } + + if personas.is_empty() { + let _ = std::fs::write(Self::migration_marker_path(), ""); + return; + } + + for persona in personas { + let filename = format!("{}.md", Self::slugify_name(&persona.display_name)); + let path = Self::unique_agent_path(&dir, &filename); + if let Err(err) = std::fs::write(&path, Self::persona_to_markdown(&persona)) { + warn!("Failed to migrate persona '{}' to markdown: {}", persona.display_name, err); + } + } + + let _ = std::fs::write(Self::migration_marker_path(), chrono::Utc::now().to_rfc3339()); + } + + fn unique_agent_path(dir: &Path, filename: &str) -> PathBuf { + let path = dir.join(filename); + if !path.exists() { + return path; + } + + let stem = Path::new(filename) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("agent"); + let extension = Path::new(filename) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("md"); + + for counter in 2.. { + let candidate = dir.join(format!("{}-{}.{}", stem, counter, extension)); + if !candidate.exists() { + return candidate; + } + } + + unreachable!("counter loop always returns"); + } + + /// Scan the canonical global agents directory and parse each Markdown file into a Persona. fn load_markdown_personas() -> Vec { let dir = Self::agents_dir(); if !dir.is_dir() { @@ -181,10 +280,10 @@ impl PersonaStore { Ok(Persona { id, display_name: frontmatter.name, - avatar: None, + avatar: frontmatter.avatar, system_prompt, - provider: None, - model: None, + provider: frontmatter.provider, + model: frontmatter.model, is_builtin: false, is_from_disk: true, source_path: Some(path.to_string_lossy().to_string()), @@ -210,6 +309,74 @@ impl PersonaStore { Ok((yaml_str, body)) } + fn slugify_name(name: &str) -> String { + let mut slug = String::new(); + let mut previous_hyphen = false; + + for ch in name.to_lowercase().chars() { + let next = if ch.is_ascii_alphanumeric() { + Some(ch) + } else if ch.is_whitespace() || ch == '-' || ch == '_' { + Some('-') + } else { + None + }; + + if let Some(ch) = next { + if ch == '-' { + if !previous_hyphen && !slug.is_empty() { + slug.push(ch); + } + previous_hyphen = true; + } else { + slug.push(ch); + previous_hyphen = false; + } + } + } + + let slug = slug.trim_matches('-'); + if slug.is_empty() { + "agent".to_string() + } else { + slug.chars().take(64).collect() + } + } + + fn persona_to_frontmatter(persona: &Persona) -> MarkdownFrontmatter { + MarkdownFrontmatter { + name: persona.display_name.clone(), + description: None, + avatar: persona.avatar.clone(), + provider: persona.provider.clone(), + model: persona.model.clone(), + } + } + + fn markdown_from_parts(frontmatter: &MarkdownFrontmatter, body: &str) -> Result { + let yaml = serde_yaml::to_string(frontmatter) + .map_err(|e| format!("Failed to serialize frontmatter: {}", e))?; + let body = body.trim(); + if body.is_empty() { + Ok(format!("---\n{}---\n", yaml)) + } else { + Ok(format!("---\n{}---\n\n{}\n", yaml, body)) + } + } + + fn persona_to_markdown(persona: &Persona) -> String { + Self::markdown_from_parts( + &Self::persona_to_frontmatter(persona), + &persona.system_prompt, + ) + .unwrap_or_else(|_| { + format!( + "---\nname: {}\n---\n\n{}\n", + persona.display_name, persona.system_prompt + ) + }) + } + fn update_markdown_persona_file( id: &str, req: &UpdatePersonaRequest, @@ -219,26 +386,39 @@ impl PersonaStore { std::fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e))?; let (yaml_str, current_body) = Self::split_markdown_persona(&content)?; - let mut frontmatter: serde_yaml::Mapping = serde_yaml::from_str(yaml_str) + let original_frontmatter: MarkdownFrontmatter = serde_yaml::from_str(yaml_str) .map_err(|e| format!("Invalid frontmatter YAML: {}", e))?; + let mut frontmatter = original_frontmatter.clone(); if let Some(name) = &req.display_name { - frontmatter.insert( - serde_yaml::Value::String("name".to_string()), - serde_yaml::Value::String(name.clone()), - ); + frontmatter.name = name.clone(); + } + if let Some(avatar) = &req.avatar { + frontmatter.avatar = avatar.clone(); + } + if let Some(provider) = &req.provider { + frontmatter.provider = Some(provider.clone()); + } + if let Some(model) = &req.model { + frontmatter.model = Some(model.clone()); } - let body = req - .system_prompt - .clone() - .unwrap_or(current_body) - .trim() - .to_string(); - - let yaml = serde_yaml::to_string(&frontmatter) - .map_err(|e| format!("Failed to serialize frontmatter: {}", e))?; - let next_content = format!("---\n{}---\n\n{}\n", yaml, body); + let current_system_prompt = if current_body.is_empty() { + original_frontmatter + .description + .clone() + .unwrap_or_else(|| format!("You are {}.", original_frontmatter.name)) + } else { + current_body.clone() + }; + let body = match &req.system_prompt { + Some(prompt) if current_body.is_empty() && prompt.trim() == current_system_prompt => { + String::new() + } + Some(prompt) => prompt.trim().to_string(), + None => current_body, + }; + let next_content = Self::markdown_from_parts(&frontmatter, &body)?; std::fs::write(&path, next_content) .map_err(|e| format!("Failed to write file '{}': {}", path.display(), e))?; @@ -269,34 +449,13 @@ impl PersonaStore { /// Re-scan markdown personas and update the in-memory list. /// Returns the full updated persona list. pub fn refresh_markdown(&self) -> Vec { - let stored = Self::load_from_disk(&self.store_path); let markdown = Self::load_markdown_personas(); - let merged = Self::merge_all(stored, markdown); let mut personas = self.personas.lock().unwrap(); - *personas = merged; + *personas = markdown; personas.clone() } - fn save_to_disk(&self, personas: &[Persona]) { - if let Some(parent) = self.store_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - // Only persist app-created personas. Source paths are runtime metadata. - let custom: Vec = personas - .iter() - .filter(|p| !p.is_builtin && !p.is_from_disk) - .map(|persona| { - let mut persona = persona.clone(); - persona.source_path = None; - persona - }) - .collect(); - if let Ok(json) = serde_json::to_string_pretty(&custom) { - let _ = std::fs::write(&self.store_path, json); - } - } - pub fn list(&self) -> Vec { let personas = self.personas.lock().unwrap(); personas.clone() @@ -310,7 +469,7 @@ impl PersonaStore { pub fn create(&self, req: CreatePersonaRequest) -> Result { let now = chrono::Utc::now().to_rfc3339(); - let persona = Persona { + let mut persona = Persona { id: uuid::Uuid::new_v4().to_string(), display_name: req.display_name, avatar: req.avatar, @@ -318,15 +477,41 @@ impl PersonaStore { provider: req.provider, model: req.model, is_builtin: false, - is_from_disk: false, - source_path: Some(self.store_path.to_string_lossy().to_string()), + is_from_disk: true, + source_path: None, created_at: now.clone(), updated_at: now, }; + let agents_dir = Self::agents_dir(); + std::fs::create_dir_all(&agents_dir) + .map_err(|e| format!("Failed to create agents directory: {}", e))?; + let filename = format!("{}.md", Self::slugify_name(&persona.display_name)); + let path = Self::unique_agent_path(&agents_dir, &filename); + std::fs::write(&path, Self::persona_to_markdown(&persona)) + .map_err(|e| format!("Failed to write agent file '{}': {}", path.display(), e))?; + persona = Self::parse_markdown_persona(&path)?; + + let mut personas = self.personas.lock().unwrap(); + personas.push(persona.clone()); + Ok(persona) + } + + pub fn import_markdown( + &self, + display_name: &str, + markdown: &str, + ) -> Result { + let agents_dir = Self::agents_dir(); + std::fs::create_dir_all(&agents_dir) + .map_err(|e| format!("Failed to create agents directory: {}", e))?; + let filename = format!("{}.md", Self::slugify_name(display_name)); + let path = Self::unique_agent_path(&agents_dir, &filename); + std::fs::write(&path, markdown) + .map_err(|e| format!("Failed to write agent file '{}': {}", path.display(), e))?; + let persona = Self::parse_markdown_persona(&path)?; let mut personas = self.personas.lock().unwrap(); personas.push(persona.clone()); - self.save_to_disk(&personas); Ok(persona) } @@ -337,9 +522,6 @@ impl PersonaStore { .find(|p| p.id == id) .ok_or_else(|| format!("Persona '{}' not found", id))?; - if persona.is_builtin { - return Err("Cannot update a built-in persona".to_string()); - } if persona.is_from_disk { let updated = Self::update_markdown_persona_file(id, &req)?; *persona = updated.clone(); @@ -365,7 +547,6 @@ impl PersonaStore { persona.updated_at = chrono::Utc::now().to_rfc3339(); let updated = persona.clone(); - self.save_to_disk(&personas); Ok(updated) } @@ -378,9 +559,6 @@ impl PersonaStore { .cloned() .ok_or_else(|| format!("Persona '{}' not found", id))?; - if persona.is_builtin { - return Err("Cannot delete a built-in persona".to_string()); - } if persona.is_from_disk { let path = Self::markdown_persona_path(id)?; match std::fs::remove_file(&path) { @@ -396,7 +574,6 @@ impl PersonaStore { } personas.retain(|p| p.id != id); - self.save_to_disk(&personas); return Ok(()); } @@ -407,7 +584,6 @@ impl PersonaStore { } personas.retain(|p| p.id != id); - self.save_to_disk(&personas); Ok(()) } diff --git a/ui/goose2/src-tauri/src/types/builtin_personas.rs b/ui/goose2/src-tauri/src/types/builtin_personas.rs index 9ed476b52a..32c8ca0eb4 100644 --- a/ui/goose2/src-tauri/src/types/builtin_personas.rs +++ b/ui/goose2/src-tauri/src/types/builtin_personas.rs @@ -293,8 +293,8 @@ pub fn builtin_personas() -> Vec { system_prompt: SOLO_SYSTEM_PROMPT_TEXT.to_string(), provider: Some("goose".to_string()), model: Some("claude-sonnet-4-20250514".to_string()), - is_builtin: true, - is_from_disk: false, + is_builtin: false, + is_from_disk: true, source_path: None, created_at: now.clone(), updated_at: now.clone(), @@ -306,8 +306,8 @@ pub fn builtin_personas() -> Vec { system_prompt: SCOUT_SYSTEM_PROMPT_TEXT.to_string(), provider: Some("goose".to_string()), model: Some("claude-sonnet-4-20250514".to_string()), - is_builtin: true, - is_from_disk: false, + is_builtin: false, + is_from_disk: true, source_path: None, created_at: now.clone(), updated_at: now.clone(), @@ -319,8 +319,8 @@ pub fn builtin_personas() -> Vec { system_prompt: RALPH_SYSTEM_PROMPT_TEXT.to_string(), provider: Some("goose".to_string()), model: Some("claude-sonnet-4-20250514".to_string()), - is_builtin: true, - is_from_disk: false, + is_builtin: false, + is_from_disk: true, source_path: None, created_at: now.clone(), updated_at: now, diff --git a/ui/goose2/src/features/agents/lib/personaImport.ts b/ui/goose2/src/features/agents/lib/personaImport.ts index 99c0d3b135..f1734e2ea4 100644 --- a/ui/goose2/src/features/agents/lib/personaImport.ts +++ b/ui/goose2/src/features/agents/lib/personaImport.ts @@ -1,8 +1,7 @@ -const JSON_MIME_TYPES = new Set([ +const MARKDOWN_MIME_TYPES = new Set([ "", - "application/json", - "application/x-json", - "text/json", + "text/markdown", + "text/x-markdown", "text/plain", ]); @@ -19,13 +18,13 @@ export function validatePersonaImportFile( file: Pick, ): ImportMessageDescriptor | null { const lowerName = file.name.toLowerCase(); - if (!lowerName.endsWith(".json")) { + if (!lowerName.endsWith(".md")) { return { key: "view.importInvalidExtension", } satisfies ImportMessageDescriptor; } - if (!JSON_MIME_TYPES.has(file.type)) { + if (!MARKDOWN_MIME_TYPES.has(file.type)) { return { key: "view.importInvalidMimeType", } satisfies ImportMessageDescriptor; diff --git a/ui/goose2/src/features/agents/lib/personaPresentation.ts b/ui/goose2/src/features/agents/lib/personaPresentation.ts index 192e41824d..bda1885ab6 100644 --- a/ui/goose2/src/features/agents/lib/personaPresentation.ts +++ b/ui/goose2/src/features/agents/lib/personaPresentation.ts @@ -1,21 +1,3 @@ -import type { Persona } from "@/shared/types/agents"; - -export type PersonaSource = "builtin" | "file" | "custom"; - -export function getPersonaSource(persona: Persona): PersonaSource { - if (persona.isBuiltin) { - return "builtin"; - } - if (persona.isFromDisk) { - return "file"; - } - return "custom"; -} - -export function isPersonaReadOnly(persona: Persona): boolean { - return getPersonaSource(persona) === "builtin"; -} - export function getPersonaInitials(displayName: string): string { const initials = displayName .trim() diff --git a/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts b/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts index 5887c5877f..27bcb97d54 100644 --- a/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts +++ b/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts @@ -177,28 +177,4 @@ describe("agentStore", () => { expect(result).toHaveLength(2); expect(result.map((a) => a.id).sort()).toEqual(["a1", "a3"]); }); - - it("getBuiltinPersonas returns only builtins", () => { - useAgentStore - .getState() - .setPersonas([ - makePersona({ id: "b", isBuiltin: true }), - makePersona({ id: "c", isBuiltin: false }), - ]); - const builtins = useAgentStore.getState().getBuiltinPersonas(); - expect(builtins).toHaveLength(1); - expect(builtins[0].id).toBe("b"); - }); - - it("getCustomPersonas returns only non-builtins", () => { - useAgentStore - .getState() - .setPersonas([ - makePersona({ id: "b", isBuiltin: true }), - makePersona({ id: "c", isBuiltin: false }), - ]); - const custom = useAgentStore.getState().getCustomPersonas(); - expect(custom).toHaveLength(1); - expect(custom[0].id).toBe("c"); - }); }); diff --git a/ui/goose2/src/features/agents/stores/agentStore.ts b/ui/goose2/src/features/agents/stores/agentStore.ts index e0e3eb2531..938e5c7f56 100644 --- a/ui/goose2/src/features/agents/stores/agentStore.ts +++ b/ui/goose2/src/features/agents/stores/agentStore.ts @@ -97,8 +97,6 @@ interface AgentStoreActions { getPersonaById: (id: string) => Persona | undefined; getAgentById: (id: string) => Agent | undefined; getAgentsByPersona: (personaId: string) => Agent[]; - getBuiltinPersonas: () => Persona[]; - getCustomPersonas: () => Persona[]; } export type AgentStore = AgentStoreState & AgentStoreActions; @@ -216,8 +214,4 @@ export const useAgentStore = create((set, get) => ({ getAgentsByPersona: (personaId) => get().agents.filter((a) => a.personaId === personaId), - - getBuiltinPersonas: () => get().personas.filter((p) => p.isBuiltin), - - getCustomPersonas: () => get().personas.filter((p) => !p.isBuiltin), })); diff --git a/ui/goose2/src/features/agents/ui/AgentConfig.tsx b/ui/goose2/src/features/agents/ui/AgentConfig.tsx index 2d8146d092..9fecba57a2 100644 --- a/ui/goose2/src/features/agents/ui/AgentConfig.tsx +++ b/ui/goose2/src/features/agents/ui/AgentConfig.tsx @@ -122,9 +122,6 @@ export function AgentConfig({ {personas.map((p) => ( {p.displayName} - {p.isBuiltin - ? ` (${t("common:labels.builtIn").toLowerCase()})` - : ""} ))} diff --git a/ui/goose2/src/features/agents/ui/AgentDetailPage.tsx b/ui/goose2/src/features/agents/ui/AgentDetailPage.tsx index 248697f186..ef96e161c6 100644 --- a/ui/goose2/src/features/agents/ui/AgentDetailPage.tsx +++ b/ui/goose2/src/features/agents/ui/AgentDetailPage.tsx @@ -2,11 +2,12 @@ import { useState, type ButtonHTMLAttributes, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Copy, - FolderOpen, + CopyPlus, MessageSquarePlus, MoreVertical, Pencil, Save, + Share2, Trash2, } from "lucide-react"; import { MessageResponse } from "@/shared/ui/ai-elements/message"; @@ -15,7 +16,6 @@ import { AvatarFallback, AvatarImage, } from "@/shared/ui/avatar"; -import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; import { DetailField } from "@/shared/ui/detail-field"; import { @@ -26,20 +26,14 @@ import { } from "@/shared/ui/dropdown-menu"; import { PageColumns } from "@/shared/ui/page-columns"; import { DetailPageShell, PageHeader } from "@/shared/ui/page-shell"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import type { Persona } from "@/shared/types/agents"; -import { - getPersonaInitials, - getPersonaSource, - isPersonaReadOnly, -} from "@/features/agents/lib/personaPresentation"; +import { getPersonaInitials } from "@/features/agents/lib/personaPresentation"; interface AgentDetailPageProps { persona: Persona; onBack: () => void; onEdit: (persona: Persona) => void; - onReveal: (persona: Persona) => void; onStartChat?: (persona: Persona) => void; onCopyFile: (persona: Persona) => void; onSaveCopy: (persona: Persona) => void; @@ -60,44 +54,22 @@ function AgentHeaderActionButton({ ...props }: AgentHeaderActionButtonProps) { return ( - - - - - -

{label}

-
-
+ ); } -function formatDate(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - - return new Intl.DateTimeFormat(undefined, { - month: "long", - day: "numeric", - year: "numeric", - }).format(date); -} - export function AgentDetailPage({ persona, onBack, onEdit, - onReveal, onStartChat, onCopyFile, onSaveCopy, @@ -108,19 +80,10 @@ export function AgentDetailPage({ const [menuOpen, setMenuOpen] = useState(false); const avatarSrc = useAvatarSrc(persona.avatar); const initials = getPersonaInitials(persona.displayName); - const personaSource = getPersonaSource(persona); - const canEditPersona = !isPersonaReadOnly(persona); - const canDeletePersona = personaSource !== "builtin"; - const hasFileActions = - personaSource === "file" && Boolean(persona.sourcePath); - const sourceLabel = - personaSource === "builtin" - ? t("common:labels.builtIn") - : personaSource === "file" - ? t("card.fileBacked") - : t("card.custom"); + const hasFileActions = Boolean(persona.sourcePath); const providerLabel = persona.provider || t("common:labels.none"); const modelLabel = persona.model || t("common:labels.none"); + const shareLabel = t("view.share"); const moreLabel = t("view.more"); return ( @@ -160,24 +123,38 @@ export function AgentDetailPage({ {onStartChat ? ( } + icon={