Align agent and skill actions

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-05-04 13:44:10 -07:00
parent 548b289caf
commit 6fbce504b4
34 changed files with 943 additions and 821 deletions
+3 -2
View File
@@ -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)]
+19 -80
View File
@@ -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<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 (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");
}
+47 -127
View File
@@ -77,9 +77,9 @@ fn validate_import_persona_path(source_path: &str) -> Result<PathBuf, 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());
.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<ImportFileReadRes
})
}
// --- Sprout-compatible persona import/export ---
// --- Markdown agent import ---
/// Sprout-compatible persona export format (version 1, camelCase keys).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersonaExportV1 {
version: u32,
display_name: String,
system_prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[derive(Debug, Deserialize)]
struct MarkdownAgentFrontmatter {
name: String,
description: Option<String>,
avatar: Option<Avatar>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
}
/// 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<CreatePersonaRequest, String> {
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<ExportResult, String> {
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<u8>,
file_name: String,
) -> Result<Vec<Persona>, 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();
-1
View File
@@ -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,
+286 -110
View File
@@ -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<Vec<Persona>>,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
avatar: Option<Avatar>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
}
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<Persona> {
fn load_legacy_json(path: &PathBuf) -> Vec<Persona> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let mut personas: Vec<Persona> =
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<Persona>, markdown: Vec<Persona>) -> Vec<Persona> {
let builtins = builtin_personas();
let mut result = builtins;
let mut seen_names: HashSet<String> = result
.iter()
.map(|p| p.display_name.to_lowercase())
.collect();
let mut seen_ids: HashSet<String> = 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<Persona> {
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<String, String> {
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<Persona> {
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<Persona> = 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<Persona> {
let personas = self.personas.lock().unwrap();
personas.clone()
@@ -310,7 +469,7 @@ impl PersonaStore {
pub fn create(&self, req: CreatePersonaRequest) -> Result<Persona, String> {
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<Persona, String> {
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(())
}
@@ -293,8 +293,8 @@ pub fn builtin_personas() -> Vec<Persona> {
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<Persona> {
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<Persona> {
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,
@@ -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<File, "name" | "type">,
): 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;
@@ -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()
@@ -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");
});
});
@@ -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<AgentStore>((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),
}));
@@ -122,9 +122,6 @@ export function AgentConfig({
{personas.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.displayName}
{p.isBuiltin
? ` (${t("common:labels.builtIn").toLowerCase()})`
: ""}
</SelectItem>
))}
</SelectContent>
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
type={type}
size="icon-xs"
variant="outline-flat"
aria-label={label}
{...props}
>
{icon}
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="center" sideOffset={8}>
<p>{label}</p>
</TooltipContent>
</Tooltip>
<Button
type={type}
size="xs"
variant="outline-flat"
leftIcon={icon}
{...props}
>
{label}
</Button>
);
}
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 ? (
<AgentHeaderActionButton
label={t("view.startChatShort")}
icon={<MessageSquarePlus className="size-3.5" />}
icon={<MessageSquarePlus aria-hidden="true" />}
onClick={() => onStartChat(persona)}
/>
) : null}
{canEditPersona ? (
<AgentHeaderActionButton
label={t("common:actions.edit")}
icon={<Pencil className="size-3.5" />}
onClick={() => onEdit(persona)}
/>
) : null}
{persona.sourcePath ? (
<AgentHeaderActionButton
label={t("view.reveal")}
icon={<FolderOpen className="size-3.5" />}
onClick={() => onReveal(persona)}
/>
) : null}
<AgentHeaderActionButton
label={t("common:actions.edit")}
icon={<Pencil aria-hidden="true" />}
onClick={() => onEdit(persona)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="xs"
variant="outline-flat"
leftIcon={<Share2 aria-hidden="true" />}
disabled={!hasFileActions}
>
{shareLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
<DropdownMenuItem onSelect={() => onCopyFile(persona)}>
<Copy className="size-3.5" />
{t("view.copyFile")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSaveCopy(persona)}>
<Save className="size-3.5" />
{t("view.saveCopy")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
@@ -191,31 +168,17 @@ export function AgentDetailPage({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
{hasFileActions ? (
<>
<DropdownMenuItem onSelect={() => onCopyFile(persona)}>
<Copy className="size-3.5" />
{t("view.copyFile")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSaveCopy(persona)}>
<Save className="size-3.5" />
{t("view.saveCopy")}
</DropdownMenuItem>
</>
) : null}
<DropdownMenuItem onSelect={() => onDuplicate(persona)}>
<Copy className="size-3.5" />
<CopyPlus className="size-3.5" />
{t("editor.duplicate")}
</DropdownMenuItem>
{canDeletePersona ? (
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete(persona)}
>
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete(persona)}
>
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
@@ -231,21 +194,7 @@ export function AgentDetailPage({
minContentSize={52}
sidebar={
<aside className="space-y-5">
<section className="space-y-5 border-b border-border pb-5">
<DetailField label={t("view.source")}>
<Badge variant="secondary">{sourceLabel}</Badge>
</DetailField>
{persona.sourcePath ? (
<DetailField
label={t("view.filePath")}
contentAs="p"
contentClassName="break-all text-foreground"
>
{persona.sourcePath}
</DetailField>
) : null}
<section className="space-y-5">
<DetailField
label={t("editor.provider")}
contentAs="p"
@@ -262,15 +211,6 @@ export function AgentDetailPage({
{modelLabel}
</DetailField>
</section>
<section className="space-y-5">
<DetailField label={t("view.created")} contentAs="p">
{formatDate(persona.createdAt)}
</DetailField>
<DetailField label={t("view.updated")} contentAs="p">
{formatDate(persona.updatedAt)}
</DetailField>
</section>
</aside>
}
>
@@ -6,7 +6,6 @@ import { toast } from "sonner";
import { SearchBar } from "@/shared/ui/SearchBar";
import { Button, buttonVariants } from "@/shared/ui/button";
import { PageHeader, PageShell } from "@/shared/ui/page-shell";
import { revealInFileManager } from "@/shared/lib/fileManager";
import { copyFileToClipboard, saveFileCopy } from "@/shared/api/system";
import {
AlertDialog,
@@ -34,7 +33,6 @@ import {
formatImportSuccessMessage,
validatePersonaImportFile,
} from "@/features/agents/lib/personaImport";
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
interface AgentsViewProps {
onStartChatWithPersona?: (persona: Persona) => void;
@@ -122,7 +120,6 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
);
const handleDeletePersona = useCallback((persona: Persona) => {
if (getPersonaSource(persona) === "builtin") return;
setDeletingPersona(persona);
}, []);
@@ -178,11 +175,6 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
[t],
);
const handleRevealPersona = useCallback((persona: Persona) => {
if (!persona.sourcePath) return;
void revealInFileManager(persona.sourcePath);
}, []);
const handleImportError = useCallback((message: string) => {
toast.error(message);
}, []);
@@ -217,8 +209,8 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
title: t("common:actions.import"),
filters: [
{
name: "JSON",
extensions: ["json"],
name: "Markdown",
extensions: ["md"],
},
],
});
@@ -295,7 +287,6 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
persona={activePersona}
onBack={() => setActivePersonaId(null)}
onEdit={(persona) => openPersonaEditor(persona, "edit")}
onReveal={handleRevealPersona}
onStartChat={onStartChatWithPersona}
onCopyFile={handleCopyPersonaFile}
onSaveCopy={handleSavePersonaCopy}
@@ -349,6 +340,7 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
personas={filteredPersonas}
hasAnyPersonas={personas.length > 0}
onSelectPersona={(p) => setActivePersonaId(p.id)}
onStartChatPersona={onStartChatWithPersona}
onEditPersona={(p) => openPersonaEditor(p, "edit")}
onDuplicatePersona={handleDuplicatePersona}
onDeletePersona={handleDeletePersona}
@@ -1,27 +1,35 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, MoreVertical, Pencil, Save, Trash2 } from "lucide-react";
import {
Copy,
CopyPlus,
MessageSquarePlus,
MoreVertical,
Pencil,
Save,
Share2,
Trash2,
} from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Avatar, AvatarImage, AvatarFallback } from "@/shared/ui/avatar";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
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 PersonaCardProps {
persona: Persona;
onSelect?: (persona: Persona) => void;
onStartChat?: (persona: Persona) => void;
onEdit?: (persona: Persona) => void;
onDuplicate?: (persona: Persona) => void;
onDelete?: (persona: Persona) => void;
@@ -33,6 +41,7 @@ interface PersonaCardProps {
export function PersonaCard({
persona,
onSelect,
onStartChat,
onEdit,
onDuplicate,
onDelete,
@@ -45,12 +54,7 @@ export function PersonaCard({
const initials = getPersonaInitials(persona.displayName);
const avatarSrc = useAvatarSrc(persona.avatar);
const personaSource = getPersonaSource(persona);
const canEditPersona = !isPersonaReadOnly(persona);
const canDeletePersona = personaSource !== "builtin";
const hasFileActions =
personaSource === "file" && Boolean(persona.sourcePath);
const isFeatured = personaSource === "builtin";
const hasFileActions = Boolean(persona.sourcePath);
const providerModelLabel = [persona.provider, persona.model]
.filter(Boolean)
.join(" / ");
@@ -75,6 +79,7 @@ export function PersonaCard({
tabIndex={0}
className={cn(
"group relative flex cursor-pointer flex-col rounded-2xl border border-border-soft bg-background p-5",
"h-full",
"transition-colors duration-200 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2",
"hover:border-border hover:bg-muted/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isActive && "border-border bg-muted/20",
@@ -109,37 +114,45 @@ export function PersonaCard({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{hasFileActions && (
<>
<DropdownMenuItem onSelect={() => onCopyFile?.(persona)}>
<Copy className="size-3.5" />
{t("view.copyFile")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSaveCopy?.(persona)}>
<Save className="size-3.5" />
{t("view.saveCopy")}
</DropdownMenuItem>
</>
)}
{canEditPersona && (
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
<Pencil className="size-3.5" />
{t("common:actions.edit")}
{onStartChat && (
<DropdownMenuItem onSelect={() => onStartChat(persona)}>
<MessageSquarePlus className="size-3.5" />
{t("view.startChatShort")}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
<Pencil className="size-3.5" />
{t("common:actions.edit")}
</DropdownMenuItem>
{hasFileActions && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Share2 className="size-3.5" />
{t("view.share")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => onCopyFile?.(persona)}>
<Copy className="size-3.5" />
{t("view.copyFile")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSaveCopy?.(persona)}>
<Save className="size-3.5" />
{t("view.saveCopy")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem onSelect={() => onDuplicate?.(persona)}>
<Copy className="size-3.5" />
<CopyPlus className="size-3.5" />
{t("common:actions.duplicate")}
</DropdownMenuItem>
{canDeletePersona && (
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete?.(persona)}
>
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
)}
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete?.(persona)}
>
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -150,11 +163,6 @@ export function PersonaCard({
<h3 className="min-w-0 truncate text-sm font-medium leading-5 text-foreground">
{persona.displayName}
</h3>
{isFeatured ? (
<Badge variant="featured" className="text-[10px]">
{t("card.featured")}
</Badge>
) : null}
</div>
{providerModelLabel ? (
@@ -5,20 +5,15 @@ import {
AvatarImage,
} from "@/shared/ui/avatar";
import { DetailField } from "@/shared/ui/detail-field";
import { Badge } from "@/shared/ui/badge";
import { MessageResponse } from "@/shared/ui/ai-elements/message";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import type { Avatar } from "@/shared/types/agents";
import {
getPersonaInitials,
type PersonaSource,
} from "@/features/agents/lib/personaPresentation";
import { getPersonaInitials } from "@/features/agents/lib/personaPresentation";
interface PersonaDetailsProps {
avatar: Avatar | null;
displayName: string;
modelLabel: string;
personaSource: PersonaSource;
providerLabel: string;
systemPrompt: string;
}
@@ -27,7 +22,6 @@ export function PersonaDetails({
avatar,
displayName,
modelLabel,
personaSource,
providerLabel,
systemPrompt,
}: PersonaDetailsProps) {
@@ -55,14 +49,6 @@ export function PersonaDetails({
>
{displayName}
</DetailField>
<div className="flex flex-wrap items-center gap-2">
{personaSource === "builtin" ? (
<Badge variant="secondary">{t("common:labels.builtIn")}</Badge>
) : null}
{personaSource === "file" ? (
<Badge variant="secondary">{t("card.fileBacked")}</Badge>
) : null}
</div>
</div>
</div>
</section>
@@ -37,11 +37,7 @@ import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory";
import { getProviderInventory } from "@/features/providers/api/inventory";
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
import {
getPersonaInitials,
getPersonaSource,
isPersonaReadOnly,
} from "@/features/agents/lib/personaPresentation";
import { getPersonaInitials } from "@/features/agents/lib/personaPresentation";
import { AvatarDropZone } from "./AvatarDropZone";
import { PersonaDetails } from "./PersonaDetails";
@@ -71,12 +67,7 @@ export function PersonaEditor({
const { t } = useTranslation(["agents", "common"]);
const isEditing = mode === "edit";
const detailsMode = mode === "details";
const readOnlyBySource = persona ? isPersonaReadOnly(persona) : false;
const isReadOnly = detailsMode || readOnlyBySource;
const personaSource = persona ? getPersonaSource(persona) : "custom";
const canEditPersona = !readOnlyBySource;
const isFileBacked = personaSource === "file";
const canDeletePersona = personaSource !== "builtin";
const isReadOnly = detailsMode;
const acpProviders = useAgentStore((s) => s.providers);
const setProviders = useAgentStore((s) => s.setProviders);
const mergeInventoryEntries = useProviderInventoryStore(
@@ -151,9 +142,6 @@ export function PersonaEditor({
? `__saved__:${model}`
: model || "__none__";
const readOnlyDescription = readOnlyBySource
? t("editor.readOnlyBuiltIn")
: null;
const providerLabel = provider
? (acpProviders.find((providerOption) => providerOption.id === provider)
?.label ?? provider)
@@ -202,11 +190,6 @@ export function PersonaEditor({
? t("editor.editTitle")
: t("editor.newTitle")}
</DialogTitle>
{readOnlyDescription ? (
<p className="text-xs text-muted-foreground">
{readOnlyDescription}
</p>
) : null}
</DialogHeader>
{detailsMode ? (
@@ -215,7 +198,6 @@ export function PersonaEditor({
avatar={avatar}
displayName={displayName}
modelLabel={modelLabel}
personaSource={personaSource}
providerLabel={providerLabel}
systemPrompt={systemPrompt}
/>
@@ -224,7 +206,7 @@ export function PersonaEditor({
<DialogBody asChild className="space-y-4 px-5 pb-5">
<form id="persona-form" onSubmit={handleSubmit}>
<div className="flex justify-center">
{isReadOnly || isFileBacked ? (
{isReadOnly ? (
<AvatarRoot className="h-16 w-16 border border-border">
<AvatarImage
src={avatarSrc ?? undefined}
@@ -301,13 +283,12 @@ export function PersonaEditor({
setModel("");
}
}}
disabled={isReadOnly || isFileBacked}
disabled={isReadOnly}
>
<SelectTrigger
className={cn(
"w-full",
(isReadOnly || isFileBacked) &&
"opacity-70 cursor-not-allowed",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
>
<SelectValue placeholder={t("common:labels.none")} />
@@ -345,13 +326,12 @@ export function PersonaEditor({
}
setModel(value);
}}
disabled={isReadOnly || isFileBacked || !provider}
disabled={isReadOnly || !provider}
>
<SelectTrigger
className={cn(
"w-full",
(isReadOnly || isFileBacked) &&
"opacity-70 cursor-not-allowed",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
>
<SelectValue
@@ -399,7 +379,7 @@ export function PersonaEditor({
<DialogFooter className="shrink-0 border-t px-5 py-4">
{detailsMode && persona ? (
<>
{onEdit && canEditPersona ? (
{onEdit ? (
<Button
type="button"
variant="outline-flat"
@@ -421,7 +401,7 @@ export function PersonaEditor({
{t("editor.duplicate")}
</Button>
) : null}
{onDelete && canDeletePersona ? (
{onDelete ? (
<Button
type="button"
variant="destructive-flat"
@@ -7,12 +7,12 @@ import { Skeleton } from "@/shared/ui/skeleton";
import type { Persona } from "@/shared/types/agents";
import { PersonaCard } from "@/features/agents/ui/PersonaCard";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
interface PersonaGalleryProps {
personas: Persona[];
activePersonaId?: string;
onSelectPersona: (persona: Persona) => void;
onStartChatPersona?: (persona: Persona) => void;
onEditPersona: (persona: Persona) => void;
onDuplicatePersona: (persona: Persona) => void;
onDeletePersona: (persona: Persona) => void;
@@ -53,6 +53,7 @@ export function PersonaGallery({
personas,
activePersonaId,
onSelectPersona,
onStartChatPersona,
onEditPersona,
onDuplicatePersona,
onDeletePersona,
@@ -74,16 +75,7 @@ export function PersonaGallery({
});
const sortedPersonas = useMemo(
() =>
[...personas].sort((a, b) => {
const aFeatured = getPersonaSource(a) === "builtin";
const bFeatured = getPersonaSource(b) === "builtin";
if (aFeatured !== bFeatured) {
return aFeatured ? -1 : 1;
}
return a.displayName.localeCompare(b.displayName);
}),
[...personas].sort((a, b) => a.displayName.localeCompare(b.displayName)),
[personas],
);
@@ -133,7 +125,7 @@ export function PersonaGallery({
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
accept=".md,text/markdown,text/plain"
className="hidden"
onChange={handleFileChange}
/>
@@ -144,13 +136,14 @@ export function PersonaGallery({
}
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{sortedPersonas.map((persona) => (
<PersonaCard
key={persona.id}
persona={persona}
isActive={persona.id === activePersonaId}
onSelect={onSelectPersona}
onStartChat={onStartChatPersona}
onEdit={onEditPersona}
onDuplicate={onDuplicatePersona}
onDelete={onDeletePersona}
@@ -166,7 +159,7 @@ export function PersonaGallery({
aria-label={t("gallery.createAria")}
{...dropHandlers}
className={cn(
"flex min-h-48 w-full flex-col items-center justify-center gap-2 rounded-2xl border border-dashed p-5",
"flex h-full min-h-48 w-full flex-col items-center justify-center gap-2 rounded-2xl border border-dashed p-5",
"text-muted-foreground transition-colors",
"hover:border-border hover:text-foreground hover:bg-muted/20",
isDragOver
@@ -186,7 +179,7 @@ export function PersonaGallery({
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
accept=".md,text/markdown,text/plain"
className="hidden"
onChange={handleFileChange}
/>
@@ -26,7 +26,6 @@ function renderDetail(
persona,
onBack: vi.fn(),
onEdit: vi.fn(),
onReveal: vi.fn(),
onStartChat: vi.fn(),
onCopyFile: vi.fn(),
onSaveCopy: vi.fn(),
@@ -40,29 +39,30 @@ function renderDetail(
}
describe("AgentDetailPage", () => {
it("shows the skills-style action rail for file-backed agents", async () => {
it("shows labeled primary actions for file-backed agents", async () => {
const user = userEvent.setup();
const persona = makePersona();
const props = renderDetail(persona);
await user.click(screen.getByRole("button", { name: "Start chat" }));
await user.click(screen.getByRole("button", { name: "Start a chat" }));
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Show in folder" }));
expect(props.onStartChat).toHaveBeenCalledWith(persona);
expect(props.onEdit).toHaveBeenCalledWith(persona);
expect(props.onReveal).toHaveBeenCalledWith(persona);
});
it("keeps file sharing actions in the overflow menu", () => {
it("keeps file actions in the share menu", async () => {
const user = userEvent.setup();
renderDetail();
await user.click(screen.getByRole("button", { name: "Share" }));
expect(screen.getByRole("button", { name: "More" })).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Copy file" }),
).not.toBeInTheDocument();
screen.getByRole("menuitem", { name: "Copy file to clipboard" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Save a copy..." }),
).not.toBeInTheDocument();
screen.getByRole("menuitem", { name: "Export a copy" }),
).toBeInTheDocument();
});
});
@@ -22,12 +22,7 @@ describe("PersonaCard", () => {
expect(screen.getByText("Coder")).toBeInTheDocument();
});
it("shows featured badge for built-in personas", () => {
render(<PersonaCard persona={makePersona({ isBuiltin: true })} />);
expect(screen.getByText("Featured")).toBeInTheDocument();
});
it("does not show featured badge for custom personas", () => {
it("does not show a provenance badge", () => {
render(<PersonaCard persona={makePersona({ isBuiltin: false })} />);
expect(screen.queryByText("Featured")).not.toBeInTheDocument();
});
@@ -76,7 +71,8 @@ describe("PersonaCard", () => {
const user = userEvent.setup();
render(
<PersonaCard
persona={makePersona()}
persona={makePersona({ sourcePath: "/tmp/code-review.md" })}
onStartChat={vi.fn()}
onEdit={vi.fn()}
onDuplicate={vi.fn()}
onDelete={vi.fn()}
@@ -85,7 +81,13 @@ describe("PersonaCard", () => {
await user.click(screen.getByRole("button", { name: /agent options/i }));
expect(screen.getByRole("menu")).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /start a chat/i }),
).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /edit/i })).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /share/i }),
).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /duplicate/i }),
).toBeInTheDocument();
@@ -94,7 +96,7 @@ describe("PersonaCard", () => {
).toBeInTheDocument();
});
it("delete is disabled for built-in personas", async () => {
it("shows delete for imported seeded personas", async () => {
const user = userEvent.setup();
render(
<PersonaCard
@@ -104,8 +106,9 @@ describe("PersonaCard", () => {
);
await user.click(screen.getByRole("button", { name: /agent options/i }));
const deleteBtn = screen.queryByRole("menuitem", { name: /delete/i });
expect(deleteBtn).toBeNull();
expect(
screen.getByRole("menuitem", { name: /delete/i }),
).toBeInTheDocument();
});
it("does not trigger selection when keyboard opens the options menu", async () => {
+4 -4
View File
@@ -167,21 +167,21 @@ export async function updateSkill(
export async function exportSkill(
path: string,
): Promise<{ json: string; filename: string }> {
): Promise<{ contents: string; filename: string }> {
const client = await getClient();
const response = await client.goose.GooseSourcesExport({
type: SKILL_SOURCE_TYPE,
path,
});
return { json: response.json, filename: response.filename };
return { contents: response.json, filename: response.filename };
}
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");
if (!fileName.toLowerCase().endsWith(".md")) {
throw new Error("File must have a .md extension");
}
const data = new TextDecoder().decode(new Uint8Array(fileBytes));
@@ -155,15 +155,3 @@ export function groupSkills(
...projectSections,
];
}
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);
}
@@ -1,14 +1,15 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
IconDots,
IconCopy,
IconDeviceFloppy,
IconFolderOpen,
IconMessagePlus,
IconPencil,
IconTrash,
} from "@tabler/icons-react";
Copy,
CopyPlus,
MessageSquarePlus,
MoreVertical,
Pencil,
Save,
Share2,
Trash2,
} from "lucide-react";
import { MessageResponse } from "@/shared/ui/ai-elements/message";
import { Button } from "@/shared/ui/button";
import { DetailField } from "@/shared/ui/detail-field";
@@ -20,7 +21,6 @@ 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 type { SkillInfo } from "../api/skills";
import type { SkillViewInfo } from "../lib/skillCategories";
@@ -28,10 +28,10 @@ interface SkillDetailPageProps {
skill: SkillViewInfo | null;
onBack: () => void;
onEdit: (skill: SkillInfo) => void;
onReveal: (skill: SkillInfo) => void;
onCopyFile: (skill: SkillInfo) => void;
onSaveCopy: (skill: SkillInfo) => void;
onStartChat?: (skill: SkillInfo) => void;
onDuplicate: (skill: SkillInfo) => void;
onDelete: (skill: SkillInfo) => void;
}
@@ -39,34 +39,24 @@ interface SkillHeaderActionButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
icon: ReactNode;
tooltipSide?: "top" | "right" | "bottom" | "left";
}
function SkillHeaderActionButton({
label,
icon,
type = "button",
tooltipSide = "top",
...props
}: SkillHeaderActionButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type={type}
size="icon-xs"
variant="outline-flat"
aria-label={label}
{...props}
>
{icon}
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide} align="center" sideOffset={8}>
<p>{label}</p>
</TooltipContent>
</Tooltip>
<Button
type={type}
size="xs"
variant="outline-flat"
leftIcon={icon}
{...props}
>
{label}
</Button>
);
}
@@ -74,10 +64,10 @@ export function SkillDetailPage({
skill,
onBack,
onEdit,
onReveal,
onCopyFile,
onSaveCopy,
onStartChat,
onDuplicate,
onDelete,
}: SkillDetailPageProps) {
const { t } = useTranslation(["skills", "common"]);
@@ -99,7 +89,7 @@ export function SkillDetailPage({
: [skill.sourceLabel];
const startChatLabel = t("view.startChatShort");
const editLabel = t("common:actions.edit");
const revealLabel = t("view.reveal");
const shareLabel = t("view.share");
const moreLabel = t("view.more");
return (
@@ -126,23 +116,37 @@ export function SkillDetailPage({
{onStartChat ? (
<SkillHeaderActionButton
label={startChatLabel}
icon={<IconMessagePlus className="size-3.5" />}
tooltipSide="top"
icon={<MessageSquarePlus aria-hidden="true" />}
onClick={() => onStartChat(skill)}
/>
) : null}
<SkillHeaderActionButton
label={editLabel}
icon={<IconPencil className="size-3.5" />}
tooltipSide="top"
icon={<Pencil aria-hidden="true" />}
onClick={() => onEdit(skill)}
/>
<SkillHeaderActionButton
label={revealLabel}
icon={<IconFolderOpen className="size-3.5" />}
tooltipSide="top"
onClick={() => onReveal(skill)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="xs"
variant="outline-flat"
leftIcon={<Share2 aria-hidden="true" />}
>
{shareLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
<DropdownMenuItem onSelect={() => onCopyFile(skill)}>
<Copy className="size-3.5" />
{t("view.copyFile")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSaveCopy(skill)}>
<Save className="size-3.5" />
{t("view.saveCopy")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -151,24 +155,20 @@ export function SkillDetailPage({
variant="outline-flat"
aria-label={moreLabel}
>
<IconDots className="size-3.5" />
<MoreVertical className="size-3.5" />
<span className="sr-only">{moreLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
<DropdownMenuItem onSelect={() => onCopyFile(skill)}>
<IconCopy className="size-3.5" />
{t("view.copyFile")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSaveCopy(skill)}>
<IconDeviceFloppy className="size-3.5" />
{t("view.saveCopy")}
<DropdownMenuItem onSelect={() => onDuplicate(skill)}>
<CopyPlus className="size-3.5" />
{t("common:actions.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete(skill)}
>
<IconTrash className="size-3.5" />
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -1,4 +1,14 @@
import { useTranslation } from "react-i18next";
import {
Copy,
CopyPlus,
MessageSquarePlus,
MoreVertical,
Pencil,
Save,
Share2,
Trash2,
} from "lucide-react";
import {
Accordion,
AccordionContent,
@@ -6,8 +16,15 @@ import {
AccordionSectionTrigger,
} from "@/shared/ui/accordion";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { IconMessagePlus } from "@tabler/icons-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import type { SkillViewInfo } from "../lib/skillCategories";
import type { SkillsSection } from "../lib/skillsHelpers";
@@ -17,6 +34,11 @@ interface SkillsListSectionsProps {
onExpandedSectionIdsChange: (ids: string[]) => void;
onSelectSkill: (skill: SkillViewInfo) => void;
onStartChat?: (skill: SkillViewInfo) => void;
onEdit: (skill: SkillViewInfo) => void;
onCopyFile: (skill: SkillViewInfo) => void;
onSaveCopy: (skill: SkillViewInfo) => void;
onDuplicate: (skill: SkillViewInfo) => void;
onDelete: (skill: SkillViewInfo) => void;
}
export function SkillsListSections({
@@ -25,8 +47,13 @@ export function SkillsListSections({
onExpandedSectionIdsChange,
onSelectSkill,
onStartChat,
onEdit,
onCopyFile,
onSaveCopy,
onDuplicate,
onDelete,
}: SkillsListSectionsProps) {
const { t } = useTranslation(["skills"]);
const { t } = useTranslation(["skills", "common"]);
return (
<Accordion
@@ -55,7 +82,7 @@ export function SkillsListSections({
{section.skills.map((skill) => (
<div
key={`${section.id}-${skill.id}`}
className="group relative flex items-center gap-3 px-5 py-4 transition-colors hover:bg-muted/20"
className="group relative px-5 py-4 transition-colors hover:bg-muted/20"
>
<button
type="button"
@@ -63,8 +90,8 @@ export function SkillsListSections({
onClick={() => onSelectSkill(skill)}
aria-label={t("view.openDetails", { name: skill.name })}
/>
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
<p className="text-sm font-normal text-foreground">
<div className="pointer-events-none relative z-10 min-w-0">
<p className="pr-10 text-sm font-normal text-foreground">
{skill.name}
</p>
{skill.description ? (
@@ -73,34 +100,65 @@ export function SkillsListSections({
</p>
) : null}
</div>
{onStartChat ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline-flat"
size="icon-xs"
className="relative z-20 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onStartChat(skill)}
aria-label={t("view.startChat", {
name: skill.name,
})}
>
<IconMessagePlus className="size-3.5" />
<span className="sr-only">
{t("view.startChatShort")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
sideOffset={8}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute top-3 right-4 z-20 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100"
onClick={(event) => event.stopPropagation()}
aria-label={t("view.optionsAria", {
name: skill.name,
})}
>
<p>{t("view.startChatShort")}</p>
</TooltipContent>
</Tooltip>
) : null}
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{onStartChat ? (
<DropdownMenuItem onSelect={() => onStartChat(skill)}>
<MessageSquarePlus className="size-3.5" />
{t("view.startChatShort")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={() => onEdit(skill)}>
<Pencil className="size-3.5" />
{t("common:actions.edit")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Share2 className="size-3.5" />
{t("view.share")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() => onCopyFile(skill)}
>
<Copy className="size-3.5" />
{t("view.copyFile")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onSaveCopy(skill)}
>
<Save className="size-3.5" />
{t("view.saveCopy")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onSelect={() => onDuplicate(skill)}>
<CopyPlus className="size-3.5" />
{t("common:actions.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete(skill)}
>
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
@@ -5,7 +5,6 @@ import { toast } from "sonner";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { Button } from "@/shared/ui/button";
import { PageHeader, PageShell } from "@/shared/ui/page-shell";
import { revealInFileManager } from "@/shared/lib/fileManager";
import { useSkillImportExport } from "../hooks/useSkillImportExport";
import { SkillDetailPage } from "./SkillDetailPage";
import { SkillsDialogs } from "./SkillsDialogs";
@@ -15,11 +14,13 @@ import { SkillsToolbar } from "./SkillsToolbar";
import { hydrateProjectNames } from "../lib/projectHydration";
import {
filterSkills,
formatSkillName,
groupSkills,
uniqueProjectFilters,
type SkillsFilter,
} from "../lib/skillsHelpers";
import {
createSkill,
deleteSkill,
listSkills,
type EditingSkill,
@@ -32,6 +33,25 @@ import {
type SkillViewInfo,
} from "../lib/skillCategories";
function getDuplicateSkillName(name: string, existingNames: Set<string>) {
const baseName = formatSkillName(`${name}-copy`) || "skill-copy";
if (!existingNames.has(baseName)) {
return baseName;
}
for (let index = 2; index < 1000; index += 1) {
const suffix = `-${index}`;
const prefix =
baseName.slice(0, 64 - suffix.length).replace(/-+$/g, "") || "skill";
const candidate = `${prefix}${suffix}`;
if (!existingNames.has(candidate)) {
return candidate;
}
}
return `skill-copy-${Date.now().toString().slice(-8)}`;
}
interface SkillsViewProps {
onStartChatWithSkill?: (skill: SkillInfo, projectId?: string | null) => void;
}
@@ -73,7 +93,6 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
return nextSkills;
} catch {
if (loadRequestIdRef.current === requestId) {
setSkills([]);
toast.error(t("view.loadError"));
}
return [];
@@ -172,9 +191,23 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
setDialogOpen(true);
};
const handleReveal = useCallback((skill: SkillInfo) => {
void revealInFileManager(skill.path);
}, []);
const handleDuplicate = useCallback(
async (skill: SkillInfo) => {
const duplicateName = getDuplicateSkillName(
skill.name,
new Set(skills.map((currentSkill) => currentSkill.name)),
);
try {
await createSkill(duplicateName, skill.description, skill.instructions);
await loadSkills();
toast.success(t("view.duplicated", { name: duplicateName }));
} catch {
toast.error(t("view.duplicateError"));
}
},
[loadSkills, skills, t],
);
const handleStartChat = useCallback(
(skill: SkillInfo) => {
@@ -243,10 +276,10 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
skill={activeSkill}
onBack={() => setActiveSkillId(null)}
onEdit={handleEdit}
onReveal={handleReveal}
onCopyFile={handleCopyFile}
onSaveCopy={handleSaveCopy}
onStartChat={onStartChatWithSkill ? handleStartChat : undefined}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
{dialogs}
@@ -297,13 +330,18 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
isDragOver={isDragOver}
/>
{!loading && filteredSkills.length > 0 ? (
{filteredSkills.length > 0 ? (
<SkillsListSections
sections={groupedSkills}
expandedSectionIds={expandedSectionIds}
onExpandedSectionIdsChange={setExpandedSectionIds}
onSelectSkill={handleSelectSkill}
onStartChat={onStartChatWithSkill ? handleStartChat : undefined}
onEdit={handleEdit}
onCopyFile={handleCopyFile}
onSaveCopy={handleSaveCopy}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
) : null}
@@ -320,7 +358,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
<input
ref={fileInputRef}
type="file"
accept=".skill.json,.json"
accept=".md,text/markdown,text/plain"
className="hidden"
onChange={handleFileChange}
/>
@@ -75,9 +75,10 @@ vi.mock("../../api/skills", () => ({
projectLinks: [],
}),
deleteSkill: vi.fn().mockResolvedValue(undefined),
exportSkill: vi
.fn()
.mockResolvedValue({ json: "{}", filename: "test.skill.json" }),
exportSkill: vi.fn().mockResolvedValue({
contents: "---\nname: test\n---\n",
filename: "SKILL.md",
}),
importSkills: vi.fn().mockResolvedValue([]),
}));
@@ -190,6 +191,52 @@ describe("SkillsView", () => {
});
});
it("keeps existing skills visible while refreshing", async () => {
const secondLoad = createDeferred<typeof mockSkills>();
listSkills
.mockResolvedValueOnce(mockSkills)
.mockReturnValueOnce(secondLoad.promise);
const { rerender } = render(<SkillsView />);
await screen.findByText("code-review");
mockProjects = [
{
id: "project-beta",
name: "beta",
workingDirs: ["/tmp/beta"],
},
];
rerender(<SkillsView />);
await waitFor(() => {
expect(listSkills).toHaveBeenCalledTimes(2);
});
expect(screen.getByText("code-review")).toBeInTheDocument();
expect(screen.getByText("test-writer")).toBeInTheDocument();
secondLoad.resolve([
{
...mockSkills[2],
id: "project:/tmp/beta/.goose/skills/beta-skill",
name: "beta-skill",
path: "/tmp/beta/.goose/skills/beta-skill",
fileLocation: "/tmp/beta/.goose/skills/beta-skill/SKILL.md",
sourceLabel: "beta",
projectLinks: [
{
id: "/tmp/beta",
name: "beta",
workingDir: "/tmp/beta",
},
],
},
]);
await screen.findByText("beta-skill");
expect(screen.queryByText("code-review")).not.toBeInTheDocument();
});
it("matches saved project working directories with trailing separators", async () => {
mockProjects = [
{
@@ -256,8 +303,9 @@ describe("SkillsView", () => {
await screen.findByText("test-writer");
await user.click(
screen.getByRole("button", { name: "Start chat with test-writer" }),
screen.getByRole("button", { name: "Options for test-writer" }),
);
await user.click(screen.getByRole("menuitem", { name: "Start a chat" }));
expect(onStartChatWithSkill).toHaveBeenCalledWith(
expect.objectContaining({ name: "test-writer" }),
@@ -276,7 +324,7 @@ describe("SkillsView", () => {
await user.click(
screen.getByRole("button", { name: "Open code-review details" }),
);
await user.click(screen.getByRole("button", { name: "Start chat" }));
await user.click(screen.getByRole("button", { name: "Start a chat" }));
expect(onStartChatWithSkill).toHaveBeenCalledWith(
expect.objectContaining({ name: "code-review" }),
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { exportPersona, importPersonas, refreshPersonas } from "../agents";
import { importPersonas, refreshPersonas } from "../agents";
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
@@ -13,23 +13,6 @@ describe("agents API", () => {
vi.clearAllMocks();
});
// ── exportPersona ────────────────────────────────────────────────────
it("exportPersona invokes correct Tauri command with ID", async () => {
const mockResult = {
json: '{"displayName":"Test"}',
suggestedFilename: "test.json",
};
mockedInvoke.mockResolvedValue(mockResult);
const result = await exportPersona("persona-123");
expect(mockedInvoke).toHaveBeenCalledWith("export_persona", {
id: "persona-123",
});
expect(result).toEqual(mockResult);
});
// ── importPersonas ───────────────────────────────────────────────────
it("importPersonas invokes correct Tauri command with bytes and filename", async () => {
@@ -46,11 +29,11 @@ describe("agents API", () => {
mockedInvoke.mockResolvedValue(mockPersonas);
const fileBytes = [0x7b, 0x7d]; // "{}"
const result = await importPersonas(fileBytes, "personas.json");
const result = await importPersonas(fileBytes, "agent.md");
expect(mockedInvoke).toHaveBeenCalledWith("import_personas", {
fileBytes,
fileName: "personas.json",
fileName: "agent.md",
});
expect(result).toEqual(mockPersonas);
});
-9
View File
@@ -30,15 +30,6 @@ export async function refreshPersonas(): Promise<Persona[]> {
return invoke("refresh_personas");
}
export interface ExportResult {
json: string;
suggestedFilename: string;
}
export async function exportPersona(id: string): Promise<ExportResult> {
return invoke("export_persona", { id });
}
export async function importPersonas(
fileBytes: number[],
fileName: string,
@@ -11,9 +11,6 @@
},
"card": {
"ariaLabel": "Agent: {{name}}",
"custom": "Custom",
"featured": "Featured",
"fileBacked": "File-backed",
"options": "Agent options"
},
"config": {
@@ -45,8 +42,6 @@
"newTitle": "New Agent",
"noModelsAvailable": "No models are available for this provider yet.",
"provider": "Provider",
"readOnlyBuiltIn": "Built-in agents are read-only. Duplicate to customize one.",
"readOnlyFile": "This agent is loaded from a file. You can review it here, but editing is disabled.",
"saveFailed": "Failed to save agent.",
"savedModelUnavailable": "{{model}} (saved, unavailable)",
"savedModelUnavailableHelp": "This agent uses a saved model that is not in the current provider inventory.",
@@ -72,10 +67,9 @@
"view": {
"copyName": "{{name}} (Copy)",
"backToAgents": "Back to agents",
"copyFile": "Copy file",
"copyFile": "Copy file to clipboard",
"copyFileFailed": "Couldn't copy the file.",
"copySaved": "Saved a copy to {{path}}",
"created": "Created",
"deleteFailed": "Failed to delete agent.",
"deleteDescription": "This agent and its configuration will be permanently removed.",
"deleteTitle": "Delete \"{{name}}\" permanently?",
@@ -84,9 +78,8 @@
"emptyAgentsDescription": "Create an agent to get started.",
"emptyAgentsTitle": "No agents yet",
"fileCopied": "File copied. Paste it into Slack, email, or another app.",
"filePath": "File path",
"importInvalidExtension": "Unsupported file type. Choose a .json file.",
"importInvalidMimeType": "Unsupported file type. Choose a JSON file.",
"importInvalidExtension": "Unsupported file type. Choose a .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.",
@@ -94,13 +87,12 @@
"newPersona": "New Agent",
"optionsAria": "Options for {{name}}",
"reveal": "Show in folder",
"saveCopy": "Save a copy...",
"saveCopy": "Export a copy",
"saveCopyFailed": "Couldn't save a copy.",
"share": "Share",
"searchPlaceholder": "Search agents",
"source": "Source",
"startChat": "Start chat with {{name}}",
"startChatShort": "Start chat",
"title": "Agents",
"updated": "Updated"
"startChat": "Start a chat with {{name}}",
"startChatShort": "Start a chat",
"title": "Agents"
}
}
@@ -41,9 +41,11 @@
"description": "Use skills to add specific instructions or behaviors to any agent.",
"detailEmptyDescription": "Select a skill to inspect its source, location, and instructions.",
"detailEmptyTitle": "Choose a skill",
"copyFile": "Copy file",
"copyFile": "Copy file to clipboard",
"copyFileError": "Couldn't copy the file.",
"copySaved": "Saved a copy to {{path}}",
"duplicateError": "Couldn't duplicate the skill.",
"duplicated": "Duplicated as \"{{name}}\"",
"dropFile": "or drop a file",
"emptyDescription": "Create a skill or import one to get started.",
"emptyTitle": "No skills yet",
@@ -61,16 +63,18 @@
"noMatchesDescription": "Try a different search term.",
"noMatchesTitle": "No matching skills",
"openDetails": "Open {{name}} details",
"optionsAria": "Options for {{name}}",
"projects": "Projects",
"reveal": "Show in folder",
"saveCopy": "Save a copy...",
"saveCopy": "Export a copy",
"saveCopyError": "Couldn't save a copy.",
"searchPlaceholder": "Search skills",
"share": "Share",
"skillCount_one": "{{displayCount}} skill",
"skillCount_other": "{{displayCount}} skills",
"source": "Source",
"startChat": "Start chat with {{name}}",
"startChatShort": "Start chat",
"startChat": "Start a chat with {{name}}",
"startChatShort": "Start a chat",
"title": "Skills",
"useInChat": "Use in chat"
}
@@ -11,9 +11,6 @@
},
"card": {
"ariaLabel": "Agente: {{name}}",
"custom": "Personalizado",
"featured": "Destacado",
"fileBacked": "Desde archivo",
"options": "Opciones del agente"
},
"config": {
@@ -45,8 +42,6 @@
"newTitle": "Nuevo agente",
"noModelsAvailable": "Todavía no hay modelos disponibles para este proveedor.",
"provider": "Proveedor",
"readOnlyBuiltIn": "Los agentes integrados son de solo lectura. Duplícalo para personalizarlo.",
"readOnlyFile": "Este agente se cargó desde un archivo. Puedes revisarlo aquí, pero la edición está deshabilitada.",
"saveFailed": "No se pudo guardar el agente.",
"savedModelUnavailable": "{{model}} (guardado, no disponible)",
"savedModelUnavailableHelp": "Este agente usa un modelo guardado que no está en el inventario actual del proveedor.",
@@ -72,10 +67,9 @@
"view": {
"copyName": "{{name}} (Copia)",
"backToAgents": "Volver a agentes",
"copyFile": "Copiar archivo",
"copyFile": "Copiar archivo al portapapeles",
"copyFileFailed": "No se pudo copiar el archivo.",
"copySaved": "Se guardó una copia en {{path}}",
"created": "Creado",
"deleteFailed": "No se pudo eliminar el agente.",
"deleteDescription": "Este agente y su configuración se eliminarán de forma permanente.",
"deleteTitle": "¿Eliminar \"{{name}}\" de forma permanente?",
@@ -84,9 +78,8 @@
"emptyAgentsDescription": "Crea un agente para empezar.",
"emptyAgentsTitle": "Aún no hay agentes",
"fileCopied": "Archivo copiado. Pégalo en Slack, correo u otra app.",
"filePath": "Ruta del archivo",
"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 .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.",
@@ -94,13 +87,12 @@
"newPersona": "Nuevo agente",
"optionsAria": "Opciones de {{name}}",
"reveal": "Mostrar en carpeta",
"saveCopy": "Guardar una copia...",
"saveCopy": "Exportar una copia",
"saveCopyFailed": "No se pudo guardar una copia.",
"share": "Compartir",
"searchPlaceholder": "Buscar agentes",
"source": "Origen",
"startChat": "Iniciar chat con {{name}}",
"startChatShort": "Iniciar chat",
"title": "Agentes",
"updated": "Actualizado"
"startChat": "Iniciar un chat con {{name}}",
"startChatShort": "Iniciar un chat",
"title": "Agentes"
}
}
@@ -41,9 +41,11 @@
"description": "Las skills son instrucciones reutilizables que ayudan a un agente a manejar tareas, flujos de trabajo o herramientas específicas.",
"detailEmptyDescription": "Selecciona una skill para inspeccionar su origen, ubicación e instrucciones.",
"detailEmptyTitle": "Elige una skill",
"copyFile": "Copiar archivo",
"copyFile": "Copiar archivo al portapapeles",
"copyFileError": "No se pudo copiar el archivo.",
"copySaved": "Se guardó una copia en {{path}}",
"duplicateError": "No se pudo duplicar la skill.",
"duplicated": "Duplicada como \"{{name}}\"",
"dropFile": "o suelta un archivo",
"emptyDescription": "Crea una skill o importa una para empezar.",
"emptyTitle": "Aún no hay skills",
@@ -61,16 +63,18 @@
"noMatchesDescription": "Prueba con otra búsqueda o filtro.",
"noMatchesTitle": "No hay skills que coincidan",
"openDetails": "Abrir detalles de {{name}}",
"optionsAria": "Opciones de {{name}}",
"projects": "Proyectos",
"reveal": "Mostrar en carpeta",
"saveCopy": "Guardar una copia...",
"saveCopy": "Exportar una copia",
"saveCopyError": "No se pudo guardar una copia.",
"searchPlaceholder": "Buscar skills",
"share": "Compartir",
"skillCount_one": "{{displayCount}} skill",
"skillCount_other": "{{displayCount}} skills",
"source": "Origen",
"startChat": "Iniciar chat con {{name}}",
"startChatShort": "Iniciar chat",
"startChat": "Iniciar un chat con {{name}}",
"startChatShort": "Iniciar un chat",
"title": "Skills",
"useInChat": "Usar en chat"
}
@@ -0,0 +1,84 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./dropdown-menu";
function SiblingMenus() {
return (
<div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger>More</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger>Share</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Copy file to clipboard</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
describe("DropdownMenu", () => {
it("closes an open sibling menu when another menu opens", async () => {
const user = userEvent.setup();
render(<SiblingMenus />);
await user.click(screen.getByRole("button", { name: "More" }));
expect(
screen.getByRole("menuitem", { name: "Duplicate" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Share" }));
expect(
screen.getByRole("menuitem", { name: "Copy file to clipboard" }),
).toBeInTheDocument();
await waitFor(() => {
expect(
screen.queryByRole("menuitem", { name: "Duplicate" }),
).not.toBeInTheDocument();
});
});
it("uses the same icon treatment for submenu triggers as menu items", async () => {
const user = userEvent.setup();
render(
<DropdownMenu modal={false}>
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<svg aria-hidden="true" />
Share
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Copy file to clipboard</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
);
await user.click(screen.getByRole("button", { name: "Actions" }));
const shareItem = screen.getByRole("menuitem", { name: "Share" });
expect(shareItem).toHaveClass("gap-2");
expect(shareItem.className).toContain("text-muted-foreground");
expect(shareItem.className).toContain("[&_svg]:pointer-events-none");
expect(shareItem.className).toContain(
"[&_svg:not([class*='size-'])]:size-4",
);
});
});
+64 -4
View File
@@ -1,13 +1,73 @@
import type * as React from "react";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/shared/lib/cn";
const DROPDOWN_MENU_OPEN_EVENT = "goose:dropdown-menu-open";
function DropdownMenu({
open: controlledOpen,
defaultOpen = false,
onOpenChange,
closeOnSiblingOpen = true,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root> & {
closeOnSiblingOpen?: boolean;
}) {
const menuId = React.useId();
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const open = controlledOpen ?? uncontrolledOpen;
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
if (controlledOpen === undefined) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
if (nextOpen && closeOnSiblingOpen && typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(DROPDOWN_MENU_OPEN_EVENT, {
detail: { id: menuId },
}),
);
}
},
[closeOnSiblingOpen, controlledOpen, menuId, onOpenChange],
);
React.useEffect(() => {
if (!closeOnSiblingOpen || !open || typeof window === "undefined") {
return;
}
const handleSiblingOpen = (event: Event) => {
const siblingMenuId = (event as CustomEvent<{ id?: string }>).detail?.id;
if (siblingMenuId === menuId) {
return;
}
if (controlledOpen === undefined) {
setUncontrolledOpen(false);
}
onOpenChange?.(false);
};
window.addEventListener(DROPDOWN_MENU_OPEN_EVENT, handleSiblingOpen);
return () => {
window.removeEventListener(DROPDOWN_MENU_OPEN_EVENT, handleSiblingOpen);
};
}, [closeOnSiblingOpen, controlledOpen, menuId, onOpenChange, open]);
return (
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
open={open}
onOpenChange={handleOpenChange}
{...props}
/>
);
}
function DropdownMenuPortal({
@@ -206,7 +266,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-muted focus:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"focus:bg-muted focus:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
+2 -7
View File
@@ -231,8 +231,8 @@ 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",
json: "---\\nname: " + name + "\\ndescription: Mock skill\\n---\\n\\nMock instructions\\n",
filename: "SKILL.md",
});
}
case "_goose/sources/import":
@@ -315,11 +315,6 @@ export function buildInitScript(options?: {
});
case "delete_persona":
return Promise.resolve(null);
case "export_persona":
return Promise.resolve({
json: "{}",
suggestedFilename: "persona.json",
});
case "import_personas":
return Promise.resolve(PERSONAS);