Agents crud (#9084)

This commit is contained in:
Jack Amadeo
2026-05-11 13:33:32 -04:00
committed by GitHub
parent 40d4b118bb
commit a38922d95e
43 changed files with 1463 additions and 1458 deletions
+14
View File
@@ -7,6 +7,7 @@ use goose::config::{Config, GooseMode};
#[cfg(feature = "telemetry")]
use goose::posthog::get_telemetry_choice;
use goose::recipe::Recipe;
use goose::source_roots::SourceRoot;
use goose_mcp::mcp_server_runner::{serve, McpCommand};
use goose_mcp::{AutoVisualiserRouter, ComputerControllerServer, MemoryServer, TutorialServer};
@@ -1123,11 +1124,24 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec<String>) ->
builtins
};
let additional_source_roots = Config::global()
.get_param::<String>("ADDITIONAL_AGENT_SOURCE_ROOTS")
.ok()
.map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.map(|path| {
let path = path.canonicalize().unwrap_or(path);
SourceRoot::read_only(path)
})
.collect();
let server = Arc::new(AcpServer::new(AcpServerFactoryConfig {
builtins,
data_dir: Paths::data_dir(),
config_dir: Paths::config_dir(),
goose_platform: GoosePlatform::GooseCli,
additional_source_roots,
}));
let secret_key = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV)
.ok()
+4
View File
@@ -852,6 +852,10 @@ pub struct SourceEntry {
/// True when the source lives in the user's global sources directory; false
/// when it lives inside a specific project.
pub global: bool,
/// True when this source can be modified through source CRUD methods.
/// Client-provided bundled sources are returned as read-only.
#[serde(default)]
pub writable: bool,
/// Paths (absolute) of additional files that live alongside the source.
/// Only skills currently populate this; empty for other source types.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
+5
View File
@@ -1957,6 +1957,11 @@
"type": "boolean",
"description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project."
},
"writable": {
"type": "boolean",
"description": "True when this source can be modified through source CRUD methods.\nClient-provided bundled sources are returned as read-only.",
"default": false
},
"supportingFiles": {
"type": "array",
"items": {
+24 -17
View File
@@ -23,6 +23,7 @@ use crate::providers::inventory::{
};
use crate::session::session_manager::SessionType;
use crate::session::{EnabledExtensionsState, Session, SessionManager};
use crate::source_roots::SourceRoot;
use crate::utils::sanitize_unicode_tags;
use agent_client_protocol::schema::{
AgentCapabilities, Annotations, AuthMethod, AuthMethodAgent, AuthenticateRequest,
@@ -216,6 +217,17 @@ struct AgentSetupRequest {
prebuilt_provider: Option<Arc<dyn Provider>>,
}
pub struct GooseAcpAgentOptions {
pub provider_factory: AcpProviderFactory,
pub builtins: Vec<String>,
pub data_dir: std::path::PathBuf,
pub config_dir: std::path::PathBuf,
pub goose_mode: GooseMode,
pub disable_session_naming: bool,
pub goose_platform: GoosePlatform,
pub additional_source_roots: Vec<SourceRoot>,
}
pub struct GooseAcpAgent {
sessions: Arc<Mutex<HashMap<String, GooseAcpSession>>>,
provider_factory: AcpProviderFactory,
@@ -230,6 +242,7 @@ pub struct GooseAcpAgent {
disable_session_naming: bool,
provider_inventory: ProviderInventoryService,
goose_platform: GoosePlatform,
additional_source_roots: Vec<SourceRoot>,
}
/// Shorten a session/thread id for perf log correlation.
@@ -1036,16 +1049,8 @@ impl GooseAcpAgent {
}
// TODO: goose reads Paths::in_state_dir globally (e.g. RequestLog), ignoring this data_dir.
pub async fn new(
provider_factory: AcpProviderFactory,
builtins: Vec<String>,
data_dir: std::path::PathBuf,
config_dir: std::path::PathBuf,
goose_mode: GooseMode,
disable_session_naming: bool,
goose_platform: GoosePlatform,
) -> Result<Self> {
let session_manager = Arc::new(SessionManager::new(data_dir));
pub async fn new(options: GooseAcpAgentOptions) -> Result<Self> {
let session_manager = Arc::new(SessionManager::new(options.data_dir));
// Eagerly initialize the SQLite pool so it's ready when providers/sessions need it.
let storage_clone = session_manager.storage().clone();
@@ -1053,23 +1058,24 @@ impl GooseAcpAgent {
let _ = storage_clone.pool().await;
});
let permission_manager = Arc::new(PermissionManager::new(config_dir.clone()));
let permission_manager = Arc::new(PermissionManager::new(options.config_dir.clone()));
let provider_inventory = ProviderInventoryService::new(session_manager.storage().clone());
Ok(Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
provider_factory,
builtins,
provider_factory: options.provider_factory,
builtins: options.builtins,
client_fs_capabilities: OnceCell::new(),
client_terminal: OnceCell::new(),
client_mcp_host_info: OnceCell::new(),
config_dir,
config_dir: options.config_dir,
session_manager,
permission_manager,
goose_mode,
disable_session_naming,
goose_mode: options.goose_mode,
disable_session_naming: options.disable_session_naming,
provider_inventory,
goose_platform,
goose_platform: options.goose_platform,
additional_source_roots: options.additional_source_roots,
})
}
@@ -3315,6 +3321,7 @@ pub async fn run(builtins: Vec<String>) -> Result<()> {
data_dir: Paths::data_dir(),
config_dir: Paths::config_dir(),
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
},
);
let agent = server.create_agent().await?;
+17 -5
View File
@@ -33,10 +33,11 @@ impl GooseAcpAgent {
&self,
req: ListSourcesRequest,
) -> Result<ListSourcesResponse, agent_client_protocol::Error> {
let sources = crate::sources::list_sources(
let sources = crate::sources::list_sources_with_roots(
req.source_type,
req.project_dir.as_deref(),
req.include_project_sources,
&self.additional_source_roots,
)?;
Ok(ListSourcesResponse { sources })
}
@@ -45,13 +46,16 @@ impl GooseAcpAgent {
&self,
req: UpdateSourceRequest,
) -> Result<UpdateSourceResponse, agent_client_protocol::Error> {
let source = crate::sources::update_source(
let source = crate::sources::update_source_with_roots(
req.source_type,
&req.path,
&req.name,
&req.description,
&req.content,
req.properties,
crate::sources::UpdateSourceOptions {
properties: req.properties,
additional_roots: &self.additional_source_roots,
},
)?;
Ok(UpdateSourceResponse { source })
}
@@ -60,7 +64,11 @@ impl GooseAcpAgent {
&self,
req: DeleteSourceRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
crate::sources::delete_source(req.source_type, &req.path)?;
crate::sources::delete_source_with_roots(
req.source_type,
&req.path,
&self.additional_source_roots,
)?;
Ok(EmptyResponse {})
}
@@ -68,7 +76,11 @@ impl GooseAcpAgent {
&self,
req: ExportSourceRequest,
) -> Result<ExportSourceResponse, agent_client_protocol::Error> {
let (json, filename) = crate::sources::export_source(req.source_type, &req.path)?;
let (json, filename) = crate::sources::export_source_with_roots(
req.source_type,
&req.path,
&self.additional_source_roots,
)?;
Ok(ExportSourceResponse { json, filename })
}
+10 -7
View File
@@ -1,5 +1,6 @@
use crate::acp::server::{AcpProviderFactory, GooseAcpAgent};
use crate::acp::server::{AcpProviderFactory, GooseAcpAgent, GooseAcpAgentOptions};
use crate::agents::GoosePlatform;
use crate::source_roots::SourceRoot;
use anyhow::Result;
use std::sync::Arc;
use tracing::info;
@@ -9,6 +10,7 @@ pub struct AcpServerFactoryConfig {
pub data_dir: std::path::PathBuf,
pub config_dir: std::path::PathBuf,
pub goose_platform: GoosePlatform,
pub additional_source_roots: Vec<SourceRoot>,
}
pub struct AcpServer {
@@ -39,15 +41,16 @@ impl AcpServer {
})
});
let agent = GooseAcpAgent::new(
let agent = GooseAcpAgent::new(GooseAcpAgentOptions {
provider_factory,
self.config.builtins.clone(),
self.config.data_dir.clone(),
self.config.config_dir.clone(),
builtins: self.config.builtins.clone(),
data_dir: self.config.data_dir.clone(),
config_dir: self.config.config_dir.clone(),
goose_mode,
disable_session_naming,
self.config.goose_platform.clone(),
)
goose_platform: self.config.goose_platform.clone(),
additional_source_roots: self.config.additional_source_roots.clone(),
})
.await?;
info!("Created new ACP agent");
@@ -127,6 +127,7 @@ fn parse_agent_content(content: &str, path: &Path) -> Option<SourceEntry> {
content: body,
path: path.to_string_lossy().into_owned(),
global: false,
writable: true,
supporting_files: Vec::new(),
properties: std::collections::HashMap::new(),
})
@@ -174,6 +175,7 @@ fn scan_recipes_from_dir(
content: recipe.instructions.clone().unwrap_or_default(),
path: path.to_string_lossy().into_owned(),
global: false,
writable: true,
supporting_files: Vec::new(),
properties: std::collections::HashMap::new(),
});
@@ -602,6 +604,7 @@ impl SummonClient {
content: String::new(),
path: sr.path.clone(),
global: false,
writable: true,
supporting_files: Vec::new(),
properties: std::collections::HashMap::new(),
});
+7
View File
@@ -12,6 +12,7 @@ impl Paths {
DirType::Data => base.join("data"),
DirType::State => base.join("state"),
DirType::Plugins => base.join(".agents").join("plugins"),
DirType::Agents => base.join(".agents").join("agents"),
}
} else {
// NOTE: "Block" is kept here for backwards compatibility with existing
@@ -29,6 +30,7 @@ impl Paths {
DirType::Data => strategy.data_dir(),
DirType::State => strategy.state_dir().unwrap_or(strategy.data_dir()),
DirType::Plugins => strategy.home_dir().join(".agents").join("plugins"),
DirType::Agents => strategy.home_dir().join(".agents").join("agents"),
}
}
}
@@ -49,6 +51,10 @@ impl Paths {
Self::get_dir(DirType::Plugins)
}
pub fn agents_dir() -> PathBuf {
Self::get_dir(DirType::Agents)
}
pub fn in_state_dir(subpath: &str) -> PathBuf {
Self::state_dir().join(subpath)
}
@@ -67,4 +73,5 @@ enum DirType {
Data,
State,
Plugins,
Agents,
}
+1
View File
@@ -42,6 +42,7 @@ pub mod session;
pub mod session_context;
pub mod skills;
pub mod slash_commands;
pub mod source_roots;
pub mod sources;
pub mod subprocess;
pub mod token_counter;
+1
View File
@@ -284,6 +284,7 @@ fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option<Sourc
content: body,
path: path.to_string_lossy().into_owned(),
global,
writable: true,
supporting_files: Vec::new(),
properties: metadata.metadata,
})
+16
View File
@@ -0,0 +1,16 @@
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceRoot {
pub path: PathBuf,
pub writable: bool,
}
impl SourceRoot {
pub fn read_only(path: PathBuf) -> Self {
Self {
path,
writable: false,
}
}
}
+543 -44
View File
@@ -8,12 +8,14 @@ use crate::skills::{
parse_skill_frontmatter, resolve_discoverable_skill_dir, resolve_skill_dir, skill_base_dir,
validate_skill_name,
};
use crate::source_roots::SourceRoot;
use agent_client_protocol::Error;
use fs_err as fs;
use goose_sdk::custom_requests::{SourceEntry, SourceType};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::warn;
pub fn parse_frontmatter<T: for<'de> Deserialize<'de>>(
content: &str,
@@ -32,7 +34,7 @@ pub fn parse_frontmatter<T: for<'de> Deserialize<'de>>(
fn require_mutable_type(source_type: SourceType) -> Result<(), Error> {
match source_type {
SourceType::Skill | SourceType::Project => Ok(()),
SourceType::Skill | SourceType::Project | SourceType::Agent => Ok(()),
other => Err(Error::invalid_params().data(format!(
"Source type '{other}' is not supported for mutation."
))),
@@ -44,6 +46,7 @@ fn require_listable_type(source_type: Option<SourceType>) -> Result<SourceType,
SourceType::Skill => Ok(SourceType::Skill),
SourceType::BuiltinSkill => Ok(SourceType::BuiltinSkill),
SourceType::Project => Ok(SourceType::Project),
SourceType::Agent => Ok(SourceType::Agent),
other => Err(Error::invalid_params().data(format!(
"Source type '{}' is not supported for listing.",
other
@@ -54,7 +57,7 @@ fn require_listable_type(source_type: Option<SourceType>) -> Result<SourceType,
// --- Project helpers ---
#[derive(Deserialize)]
struct ProjectFront {
struct MarkdownSourceFrontmatter {
#[serde(default)]
name: String,
#[serde(default)]
@@ -71,37 +74,39 @@ fn project_file_path(slug: &str) -> PathBuf {
projects_dir().join(format!("{slug}.md"))
}
fn build_project_md(
fn build_source_markdown(
name: &str,
description: &str,
content: &str,
properties: &HashMap<String, serde_json::Value>,
) -> String {
let mut fm = serde_yaml::Mapping::new();
fm.insert(
) -> Result<String, Error> {
let mut frontmatter = serde_yaml::Mapping::new();
frontmatter.insert(
serde_yaml::Value::String("name".into()),
serde_yaml::Value::String(name.into()),
);
fm.insert(
frontmatter.insert(
serde_yaml::Value::String("description".into()),
serde_yaml::Value::String(description.into()),
);
for (k, v) in properties {
if k == "name" || k == "description" {
for (key, value) in properties {
if key == "name" || key == "description" {
continue;
}
if let Ok(yv) = serde_yaml::to_value(v) {
fm.insert(serde_yaml::Value::String(k.clone()), yv);
}
let value = serde_yaml::to_value(value).map_err(|e| {
Error::internal_error().data(format!("Failed to serialize source property: {e}"))
})?;
frontmatter.insert(serde_yaml::Value::String(key.clone()), value);
}
let yaml = serde_yaml::to_string(&fm).unwrap_or_default();
let yaml = serde_yaml::to_string(&frontmatter)
.map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?;
let mut md = format!("---\n{yaml}---\n");
if !content.is_empty() {
md.push('\n');
md.push_str(content);
md.push('\n');
}
md
Ok(md)
}
/// Returns (display_name, description, body, properties).
@@ -116,7 +121,7 @@ fn parse_project_frontmatter(
HashMap::new(),
);
}
match parse_frontmatter::<ProjectFront>(raw) {
match parse_frontmatter::<MarkdownSourceFrontmatter>(raw) {
Ok(Some((meta, body))) => (meta.name, meta.description, body, meta.properties),
_ => (
String::new(),
@@ -155,6 +160,18 @@ fn read_existing_project_properties(file: &Path) -> HashMap<String, serde_json::
properties
}
/// Read the properties bag out of an existing agent file.
fn read_existing_agent_properties(file: &Path) -> HashMap<String, serde_json::Value> {
let raw = match fs::read_to_string(file) {
Ok(s) => s,
Err(_) => return HashMap::new(),
};
match parse_agent_frontmatter(&raw) {
Ok((frontmatter, _)) => frontmatter.properties,
Err(_) => HashMap::new(),
}
}
fn project_entry_from_file(file: &Path) -> Option<SourceEntry> {
let slug = file.file_stem().and_then(|s| s.to_str())?.to_string();
if slug.is_empty() {
@@ -182,6 +199,7 @@ fn project_entry_from_file(file: &Path) -> Option<SourceEntry> {
content,
path: file.to_string_lossy().into_owned(),
global: true,
writable: true,
supporting_files: Vec::new(),
properties,
})
@@ -277,6 +295,7 @@ fn skill_source_entry(
content: content.to_string(),
path: dir.to_string_lossy().to_string(),
global,
writable: true,
supporting_files: Vec::new(),
properties,
}
@@ -290,6 +309,322 @@ fn builtin_skill_entry(mut source: SourceEntry) -> SourceEntry {
source
}
fn agent_base_dir(global: bool, project_dir: Option<&str>) -> Result<PathBuf, Error> {
if global {
Ok(Paths::agents_dir())
} else {
let project_dir = project_dir.ok_or_else(|| {
Error::invalid_params().data("projectDir is required when global is false")
})?;
if project_dir.trim().is_empty() {
return Err(
Error::invalid_params().data("projectDir must not be empty when global is false")
);
}
Ok(Path::new(project_dir).join(".agents").join("agents"))
}
}
fn validate_agent_name(name: &str) -> Result<(), Error> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(Error::invalid_params().data("Agent name must not be empty"));
}
if trimmed.len() > 80 {
return Err(Error::invalid_params().data(format!(
"Invalid agent name \"{}\". Names must be at most 80 characters.",
name
)));
}
if trimmed.chars().any(|ch| matches!(ch, '/' | '\\')) {
return Err(Error::invalid_params().data(format!(
"Invalid agent name \"{}\". Names must not contain path separators.",
name
)));
}
Ok(())
}
fn slugify_agent_name(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect();
let mut collapsed = String::with_capacity(slug.len());
let mut previous_hyphen = false;
for ch in slug.chars() {
if ch == '-' {
if !previous_hyphen {
collapsed.push('-');
}
previous_hyphen = true;
} else {
collapsed.push(ch);
previous_hyphen = false;
}
}
let trimmed = collapsed.trim_matches('-');
if trimmed.is_empty() {
"agent".to_string()
} else {
trimmed
.chars()
.take(64)
.collect::<String>()
.trim_end_matches('-')
.to_string()
}
}
fn parse_agent_frontmatter(raw: &str) -> Result<(MarkdownSourceFrontmatter, String), Error> {
parse_frontmatter::<MarkdownSourceFrontmatter>(raw)
.map_err(|e| Error::invalid_params().data(format!("Invalid agent frontmatter: {e}")))?
.ok_or_else(|| Error::invalid_params().data("Agent file is missing frontmatter"))
}
fn agent_source_entry(path: &Path, global: bool, writable: bool) -> Result<SourceEntry, Error> {
let raw = fs::read_to_string(path)
.map_err(|e| Error::internal_error().data(format!("Failed to read agent file: {e}")))?;
let (frontmatter, content) = parse_agent_frontmatter(&raw)?;
Ok({
SourceEntry {
source_type: SourceType::Agent,
name: frontmatter.name,
description: frontmatter.description,
content,
path: path.to_string_lossy().to_string(),
global,
writable,
supporting_files: Vec::new(),
properties: frontmatter.properties,
}
})
}
fn canonicalize_or_original(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn is_under_root(path: &Path, root: &Path) -> bool {
canonicalize_or_original(path).starts_with(canonicalize_or_original(root))
}
fn is_read_only_agent_file(path: &Path, additional_roots: &[SourceRoot]) -> bool {
additional_roots
.iter()
.filter(|root| !root.writable)
.any(|root| is_under_root(path, &root.path))
}
fn reject_read_only_agent_file(path: &Path, additional_roots: &[SourceRoot]) -> Result<(), Error> {
if is_read_only_agent_file(path, additional_roots) {
return Err(Error::invalid_params().data("Source is read-only"));
}
Ok(())
}
fn is_global_agent_file(path: &Path) -> bool {
let canonical_path = canonicalize_or_original(path);
let mut global_roots = Vec::new();
global_roots.push(Paths::agents_dir());
if let Some(home) = dirs::home_dir() {
global_roots.push(home.join(".agents").join("agents"));
global_roots.push(home.join(".goose").join("agents"));
global_roots.push(home.join(".claude").join("agents"));
}
global_roots.push(Paths::config_dir().join("agents"));
global_roots
.into_iter()
.any(|root| canonical_path.starts_with(canonicalize_or_original(&root)))
}
fn resolve_agent_file_with_roots(
path: &str,
additional_roots: &[SourceRoot],
) -> Result<PathBuf, Error> {
if path.is_empty() {
return Err(Error::invalid_params().data("Source path must not be empty"));
}
let canonical_file = Path::new(path)
.canonicalize()
.map_err(|_| Error::invalid_params().data(format!("Source \"{}\" not found", path)))?;
let parent_name = canonical_file
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str());
let grandparent_name = canonical_file
.parent()
.and_then(Path::parent)
.and_then(Path::file_name)
.and_then(|name| name.to_str());
let in_agent_dir = parent_name == Some("agents")
&& matches!(
grandparent_name,
Some(".goose") | Some(".claude") | Some(".agents")
);
let in_additional_root = additional_roots
.iter()
.any(|root| is_under_root(&canonical_file, &root.path));
if !canonical_file.is_file()
|| canonical_file.extension().and_then(|ext| ext.to_str()) != Some("md")
|| (!in_agent_dir && !is_global_agent_file(&canonical_file) && !in_additional_root)
{
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", path)));
}
Ok(canonical_file)
}
fn list_agent_dirs(working_dir: Option<&Path>, additional_roots: &[SourceRoot]) -> Vec<SourceRoot> {
let mut dirs = Vec::new();
if let Some(working_dir) = working_dir {
dirs.push(SourceRoot {
path: working_dir.join(".agents").join("agents"),
writable: true,
});
dirs.push(SourceRoot {
path: working_dir.join(".goose").join("agents"),
writable: true,
});
dirs.push(SourceRoot {
path: working_dir.join(".claude").join("agents"),
writable: true,
});
}
dirs.push(SourceRoot {
path: Paths::agents_dir(),
writable: true,
});
if let Some(home) = dirs::home_dir() {
dirs.push(SourceRoot {
path: home.join(".agents").join("agents"),
writable: true,
});
dirs.push(SourceRoot {
path: home.join(".goose").join("agents"),
writable: true,
});
dirs.push(SourceRoot {
path: home.join(".claude").join("agents"),
writable: true,
});
}
dirs.push(SourceRoot {
path: Paths::config_dir().join("agents"),
writable: true,
});
dirs.extend(additional_roots.iter().cloned());
dirs
}
fn is_project_agent_file(path: &Path, working_dir: &Path) -> bool {
[".agents", ".goose", ".claude"]
.into_iter()
.map(|dir| working_dir.join(dir).join("agents"))
.any(|root| is_under_root(path, &root))
}
fn list_agent_sources(
project_dir: Option<&str>,
additional_roots: &[SourceRoot],
) -> Vec<SourceEntry> {
let working_dir = project_dir
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from);
let mut seen = std::collections::HashSet::new();
let mut sources = Vec::new();
for root in list_agent_dirs(working_dir.as_deref(), additional_roots) {
let entries = match fs::read_dir(&root.path) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let global = working_dir
.as_deref()
.is_none_or(|working_dir| !is_project_agent_file(&path, working_dir));
match agent_source_entry(&path, global, root.writable) {
Ok(source) => {
let key = source.name.to_lowercase();
if seen.insert(key) {
sources.push(source);
}
}
Err(err) => warn!("Skipping agent source {}: {:?}", path.display(), err),
}
}
}
sources
}
fn create_agent_source(
name: &str,
description: &str,
content: &str,
properties: HashMap<String, serde_json::Value>,
global: bool,
project_dir: Option<&str>,
) -> Result<SourceEntry, Error> {
validate_agent_name(name)?;
let base = agent_base_dir(global, project_dir)?;
let slug = slugify_agent_name(name);
let mut file_path = base.join(format!("{slug}.md"));
if file_path.exists() {
let mut counter = 2u32;
loop {
file_path = base.join(format!("{slug}-{counter}.md"));
if !file_path.exists() {
break;
}
counter += 1;
}
}
fs::create_dir_all(&base).map_err(|e| {
Error::internal_error().data(format!("Failed to create source directory: {e}"))
})?;
let md = build_source_markdown(name, description, content, &properties)?;
fs::write(&file_path, md)
.map_err(|e| Error::internal_error().data(format!("Failed to write agent file: {e}")))?;
agent_source_entry(&file_path, global, true)
}
fn update_agent_source(
path: &str,
name: &str,
description: &str,
content: &str,
properties: Option<HashMap<String, serde_json::Value>>,
additional_roots: &[SourceRoot],
) -> Result<SourceEntry, Error> {
validate_agent_name(name)?;
let file_path = resolve_agent_file_with_roots(path, additional_roots)?;
reject_read_only_agent_file(&file_path, additional_roots)?;
let global = is_global_agent_file(&file_path);
let resolved_properties = match properties {
Some(p) => p,
None => read_existing_agent_properties(&file_path),
};
let md = build_source_markdown(name, description, content, &resolved_properties)?;
fs::write(&file_path, md)
.map_err(|e| Error::internal_error().data(format!("Failed to write agent file: {e}")))?;
agent_source_entry(&file_path, global, true)
}
// --- Public CRUD ---
pub fn create_source(
@@ -302,6 +637,9 @@ pub fn create_source(
properties: HashMap<String, serde_json::Value>,
) -> Result<SourceEntry, Error> {
require_mutable_type(source_type)?;
if source_type == SourceType::Agent {
return create_agent_source(name, description, content, properties, global, project_dir);
}
match source_type {
SourceType::Skill => {
@@ -348,7 +686,7 @@ pub fn create_source(
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(name);
let md = build_project_md(display_name, description, content, &properties);
let md = build_source_markdown(display_name, description, content, &properties)?;
fs::write(&file, md).map_err(|e| {
Error::internal_error().data(format!("Failed to write project file: {e}"))
})?;
@@ -359,15 +697,30 @@ pub fn create_source(
}
}
pub fn update_source(
pub struct UpdateSourceOptions<'a> {
pub properties: Option<HashMap<String, serde_json::Value>>,
pub additional_roots: &'a [SourceRoot],
}
pub fn update_source_with_roots(
source_type: SourceType,
path: &str,
name: &str,
description: &str,
content: &str,
properties: Option<HashMap<String, serde_json::Value>>,
options: UpdateSourceOptions<'_>,
) -> Result<SourceEntry, Error> {
require_mutable_type(source_type)?;
if source_type == SourceType::Agent {
return update_agent_source(
path,
name,
description,
content,
options.properties,
options.additional_roots,
);
}
match source_type {
SourceType::Skill => {
@@ -381,10 +734,7 @@ pub fn update_source(
Error::internal_error().data("Failed to resolve source directory name")
})?;
// When the caller doesn't supply properties, preserve whatever
// is already on disk so per-skill frontmatter metadata isn't
// silently erased on edit.
let resolved_properties = match properties {
let resolved_properties = match options.properties {
Some(p) => p,
None => read_existing_skill_properties(&dir),
};
@@ -428,9 +778,6 @@ pub fn update_source(
validate_project_slug(name)?;
let file = resolve_project_path(path)?;
// We don't currently support renaming a project (it would change
// the slug used as the stable thread.project_id). Reject mismatches
// to surface this clearly.
let current_slug = file
.file_stem()
.and_then(|s| s.to_str())
@@ -442,8 +789,7 @@ pub fn update_source(
)));
}
// Same preserve-on-None semantics as skills.
let resolved_properties = match properties {
let resolved_properties = match options.properties {
Some(p) => p,
None => read_existing_project_properties(&file),
};
@@ -452,7 +798,8 @@ pub fn update_source(
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(name);
let md = build_project_md(display_name, description, content, &resolved_properties);
let md =
build_source_markdown(display_name, description, content, &resolved_properties)?;
fs::write(&file, md).map_err(|e| {
Error::internal_error().data(format!("Failed to write project file: {e}"))
})?;
@@ -464,6 +811,14 @@ pub fn update_source(
}
pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> {
delete_source_with_roots(source_type, path, &[])
}
pub fn delete_source_with_roots(
source_type: SourceType,
path: &str,
additional_roots: &[SourceRoot],
) -> Result<(), Error> {
require_mutable_type(source_type)?;
match source_type {
@@ -479,6 +834,13 @@ pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> {
Error::internal_error().data(format!("Failed to delete project: {e}"))
})?;
}
SourceType::Agent => {
let file_path = resolve_agent_file_with_roots(path, additional_roots)?;
reject_read_only_agent_file(&file_path, additional_roots)?;
fs::remove_file(&file_path).map_err(|e| {
Error::internal_error().data(format!("Failed to delete source: {e}"))
})?;
}
_ => unreachable!("guarded by require_mutable_type"),
}
Ok(())
@@ -488,6 +850,15 @@ pub fn list_sources(
source_type: Option<SourceType>,
project_dir: Option<&str>,
include_project_sources: bool,
) -> Result<Vec<SourceEntry>, Error> {
list_sources_with_roots(source_type, project_dir, include_project_sources, &[])
}
pub fn list_sources_with_roots(
source_type: Option<SourceType>,
project_dir: Option<&str>,
include_project_sources: bool,
additional_roots: &[SourceRoot],
) -> Result<Vec<SourceEntry>, Error> {
if let Some(t) = source_type {
require_listable_type(Some(t))?;
@@ -564,7 +935,10 @@ pub fn list_sources(
SourceType::Project => {
sources.extend(read_project_dir()?);
}
SourceType::Recipe | SourceType::Subrecipe | SourceType::Agent => {
SourceType::Agent => {
sources.extend(list_agent_sources(project_dir, additional_roots));
}
SourceType::Recipe | SourceType::Subrecipe => {
return Err(Error::invalid_params()
.data(format!("Source type '{}' listing is not supported.", kind)));
}
@@ -576,6 +950,14 @@ pub fn list_sources(
}
pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, String), Error> {
export_source_with_roots(source_type, path, &[])
}
pub fn export_source_with_roots(
source_type: SourceType,
path: &str,
additional_roots: &[SourceRoot],
) -> Result<(String, String), Error> {
match source_type {
SourceType::Skill => {
let dir = resolve_discoverable_skill_dir(path)?;
@@ -601,6 +983,27 @@ pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, Str
let filename = format!("{}.skill.json", name);
Ok((json, filename))
}
SourceType::Agent => {
let file_path = resolve_agent_file_with_roots(path, additional_roots)?;
let writable = !is_read_only_agent_file(&file_path, additional_roots);
let source = agent_source_entry(
&file_path,
is_global_agent_file(&file_path) || !writable,
writable,
)?;
let export = serde_json::json!({
"version": 1,
"type": "agent",
"name": source.name,
"description": source.description,
"content": source.content,
});
let json = serde_json::to_string_pretty(&export).map_err(|e| {
Error::internal_error().data(format!("Failed to serialize source: {e}"))
})?;
let filename = format!("{}.agent.json", slugify_agent_name(&source.name));
Ok((json, filename))
}
SourceType::Project => {
let file = resolve_project_path(path)?;
let raw = fs::read_to_string(&file).map_err(|e| {
@@ -667,6 +1070,7 @@ pub fn import_sources(
let source_type = match type_str {
"skill" => SourceType::Skill,
"project" => SourceType::Project,
"agent" => SourceType::Agent,
other => {
return Err(Error::invalid_params()
.data(format!("Source type '{}' import is not supported.", other)));
@@ -713,6 +1117,25 @@ pub fn import_sources(
}
}
if source_type == SourceType::Agent {
if let Some(legacy_metadata) = value.get("metadata").and_then(|v| v.as_object()) {
for (key, value) in legacy_metadata {
properties
.entry(key.clone())
.or_insert_with(|| value.clone());
}
}
return create_agent_source(
&name,
&description,
&content,
properties,
global,
project_dir,
)
.map(|source| vec![source]);
}
match source_type {
SourceType::Skill => {
validate_skill_name(&name)?;
@@ -753,7 +1176,7 @@ pub fn import_sources(
&final_name,
&description,
&content,
true, // projects are always global
true,
None,
properties,
)
@@ -782,6 +1205,54 @@ mod tests {
assert!(validate_skill_name(&"a".repeat(65)).is_err());
}
#[test]
fn lists_additional_read_only_agent_roots() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("builtin").join("agents");
std::fs::create_dir_all(&root).unwrap();
let agent_path = root.join("solo.md");
std::fs::write(
&agent_path,
"---\nname: Solo\ndescription: Built in\n---\n\nYou are Solo.",
)
.unwrap();
let sources = list_sources_with_roots(
Some(SourceType::Agent),
None,
false,
&[SourceRoot::read_only(root.clone())],
)
.unwrap();
let solo = sources.iter().find(|source| source.name == "Solo").unwrap();
assert!(!solo.writable);
assert!(solo.global);
assert_eq!(solo.path, agent_path.to_string_lossy());
let err = update_source_with_roots(
SourceType::Agent,
&solo.path,
"Solo",
"Built in",
"Updated",
UpdateSourceOptions {
properties: None,
additional_roots: &[SourceRoot::read_only(root.canonicalize().unwrap())],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("read-only"));
let err = delete_source_with_roots(
SourceType::Agent,
&solo.path,
&[SourceRoot::read_only(root.canonicalize().unwrap())],
)
.unwrap_err();
assert!(format!("{:?}", err).contains("read-only"));
}
#[test]
fn create_list_update_delete_project_skill() {
let tmp = TempDir::new().unwrap();
@@ -805,13 +1276,16 @@ mod tests {
let listed = list_sources(Some(SourceType::Skill), Some(project), false).unwrap();
assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global));
let updated = update_source(
let updated = update_source_with_roots(
SourceType::Skill,
created.path.as_str(),
"my-skill",
"now does a different thing",
"step three",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap();
assert_eq!(updated.description, "now does a different thing");
@@ -946,13 +1420,16 @@ mod tests {
)
.unwrap();
let updated = update_source(
let updated = update_source_with_roots(
SourceType::Skill,
claude_skill_dir.to_str().unwrap(),
"portable",
"updated description",
"updated body",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap();
@@ -1001,13 +1478,16 @@ mod tests {
.join(".goose")
.join("skills")
.join("no-such-skill");
let err = update_source(
let err = update_source_with_roots(
SourceType::Skill,
missing_dir.to_str().unwrap(),
"no-such-skill",
"d",
"c",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("not found"));
@@ -1109,19 +1589,32 @@ mod tests {
.unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = update_source(
let err = update_source_with_roots(
SourceType::BuiltinSkill,
"builtin://skills/x",
"x",
"d",
"c",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = update_source(SourceType::Recipe, "x", "x", "d", "c", Some(HashMap::new()))
.unwrap_err();
let err = update_source_with_roots(
SourceType::Recipe,
"x",
"x",
"d",
"c",
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = delete_source(SourceType::BuiltinSkill, "builtin://skills/x").unwrap_err();
@@ -1173,13 +1666,16 @@ mod tests {
.unwrap();
let skill_dir = tmp.path().join(".agents").join("skills").join("my-dir");
let updated = update_source(
let updated = update_source_with_roots(
SourceType::Skill,
skill_dir.to_str().unwrap(),
"my-dir",
"new description",
"new body",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap();
// Name is derived from the frontmatter written by create_source
@@ -1268,13 +1764,16 @@ mod tests {
.unwrap();
let attempted_escape = project.join(".goose").join("escaped");
let err = update_source(
let err = update_source_with_roots(
SourceType::Skill,
attempted_escape.to_str().unwrap(),
"escaped",
"new description",
"new content",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("not found"));
+8 -7
View File
@@ -10,7 +10,7 @@ use agent_client_protocol::schema::{
};
use async_trait::async_trait;
use fs_err as fs;
use goose::acp::server::{serve, AcpProviderFactory, GooseAcpAgent};
use goose::acp::server::{serve, AcpProviderFactory, GooseAcpAgent, GooseAcpAgentOptions};
pub use goose::acp::{map_permission_response, PermissionDecision};
use goose::agents::GoosePlatform;
use goose::builtin_extension::register_builtin_extensions;
@@ -186,15 +186,16 @@ pub async fn spawn_acp_server_in_process(
})
});
let agent = GooseAcpAgent::new(
let agent = GooseAcpAgent::new(GooseAcpAgentOptions {
provider_factory,
builtins.to_vec(),
data_root.to_path_buf(),
data_root.to_path_buf(),
builtins: builtins.to_vec(),
data_dir: data_root.to_path_buf(),
config_dir: data_root.to_path_buf(),
goose_mode,
disable_session_naming,
GoosePlatform::GooseCli,
)
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
})
.await
.unwrap();
let agent = Arc::new(agent);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-29
View File
@@ -600,10 +600,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -1656,7 +1654,6 @@ name = "goose2"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs",
"doctor",
"ignore",
@@ -1665,7 +1662,6 @@ dependencies = [
"mime_guess",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-app-test-driver",
@@ -3702,12 +3698,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -4008,19 +3998,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.1",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -5262,12 +5239,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "url"
version = "2.5.8"
-2
View File
@@ -29,8 +29,6 @@ dirs = "6.0.0"
log = "0.4.29"
tokio = { version = "1.50.0", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
serde_yaml = "0.9"
doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" }
ignore = "0.4.25"
base64 = "0.22"
+1 -210
View File
@@ -1,64 +1,5 @@
use crate::services::personas::PersonaStore;
use crate::types::agents::*;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use std::path::PathBuf;
use tauri::State;
#[tauri::command]
pub fn list_personas(store: State<'_, PersonaStore>) -> Vec<Persona> {
store.list()
}
#[tauri::command]
pub fn create_persona(
store: State<'_, PersonaStore>,
request: CreatePersonaRequest,
) -> Result<Persona, String> {
store.create(request)
}
#[tauri::command]
pub fn update_persona(
store: State<'_, PersonaStore>,
id: String,
request: UpdatePersonaRequest,
) -> Result<Persona, String> {
store.update(&id, request)
}
#[tauri::command]
pub fn delete_persona(store: State<'_, PersonaStore>, id: String) -> Result<(), String> {
store.delete(&id)
}
#[tauri::command]
pub fn refresh_personas(store: State<'_, PersonaStore>) -> Vec<Persona> {
store.refresh_markdown()
}
/// Save avatar from a local file path for a persona.
/// Copies the file into ~/.goose/avatars/{persona_id}.{ext}.
/// Returns the stored filename (e.g. "persona-id.png").
#[tauri::command]
pub fn save_persona_avatar(persona_id: String, source_path: String) -> Result<String, String> {
PersonaStore::save_avatar_from_path(&persona_id, &source_path)
}
/// Save avatar from raw bytes (for drag-and-drop from the browser).
#[tauri::command]
pub fn save_persona_avatar_bytes(
persona_id: String,
bytes: Vec<u8>,
extension: String,
) -> Result<String, String> {
PersonaStore::save_avatar_from_bytes(&persona_id, &bytes, &extension)
}
/// Returns the absolute path to the avatars directory (~/.goose/avatars/).
#[tauri::command]
pub fn get_avatars_dir() -> String {
PersonaStore::avatars_dir().to_string_lossy().to_string()
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -111,156 +52,6 @@ pub fn read_import_persona_file(source_path: String) -> Result<ImportFileReadRes
})
}
// --- Sprout-compatible persona import/export ---
/// 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")]
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;
}
}
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()
};
if result.is_empty() {
"persona".to_string()
} else {
result
}
}
/// 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 export = PersonaExportV1 {
version: 1,
display_name: persona.display_name.clone(),
system_prompt: persona.system_prompt,
avatar: export_avatar,
provider: persona.provider,
model: persona.model,
};
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,
})
}
/// Import personas from sprout-compatible JSON (version 1).
/// Accepts raw file bytes and the original filename.
/// Returns the list of newly created personas.
#[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());
}
// 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)?;
Ok(vec![persona])
}
#[cfg(test)]
mod tests {
use super::validate_import_persona_path;
+1 -13
View File
@@ -3,7 +3,6 @@ mod services;
mod types;
use services::distro_bundle::DistroBundleState;
use services::personas::PersonaStore;
use tauri::Manager;
use tauri_plugin_window_state::StateFlags;
@@ -25,8 +24,7 @@ pub fn run() {
tauri_plugin_window_state::Builder::default()
.with_state_flags(StateFlags::all() & !StateFlags::VISIBLE)
.build(),
)
.manage(PersonaStore::new());
);
#[cfg(feature = "app-test-driver")]
let builder = builder.plugin(tauri_plugin_app_test_driver::init());
@@ -37,17 +35,7 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::agents::list_personas,
commands::agents::create_persona,
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,
commands::agents::save_persona_avatar_bytes,
commands::agents::get_avatars_dir,
commands::acp::get_goose_serve_url,
commands::acp::get_goose_serve_host_info,
commands::project_icons::scan_project_icons,
@@ -1,4 +1,4 @@
use tauri::Manager;
use tauri::{Manager, Runtime};
use tauri_plugin_shell::ShellExt;
use std::ffi::OsString;
@@ -13,6 +13,8 @@ use tokio::sync::OnceCell;
const GOOSE_SERVE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const GOOSE_SERVE_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(100);
const LOCALHOST: &str = "127.0.0.1";
const ADDITIONAL_AGENT_SOURCE_ROOTS_ENV: &str = "ADDITIONAL_AGENT_SOURCE_ROOTS";
const BUNDLED_AGENT_ROOT_DIR: &str = "builtin-sources/agents";
// ---------------------------------------------------------------------------
// GooseServeProcess — singleton that owns the long-lived `goose serve` child
// ---------------------------------------------------------------------------
@@ -84,8 +86,10 @@ impl GooseServeProcess {
}
}
command.arg("serve");
add_bundled_agent_root_env(&app_handle, &mut command);
command
.arg("serve")
.arg("--host")
.arg(LOCALHOST)
.arg("--port")
@@ -121,6 +125,46 @@ impl GooseServeProcess {
}
}
fn add_bundled_agent_root_env<R: Runtime>(manager: &impl Manager<R>, command: &mut Command) {
let resource_dir = match manager.path().resource_dir() {
Ok(path) => path,
Err(error) => {
log::warn!("Failed to resolve Tauri resource dir for bundled sources: {error}");
return;
}
};
let root = resource_dir.join(BUNDLED_AGENT_ROOT_DIR);
if !root.is_dir() {
log::debug!(
"No bundled source root found at {}; skipping",
root.display()
);
return;
}
append_additional_agent_roots_env(command, &root);
}
fn append_additional_agent_roots_env(command: &mut Command, root: &std::path::Path) {
let existing = std::env::var_os(ADDITIONAL_AGENT_SOURCE_ROOTS_ENV);
let mut roots: Vec<PathBuf> = existing
.as_ref()
.map(std::env::split_paths)
.map(Iterator::collect)
.unwrap_or_default();
roots.push(root.to_path_buf());
match std::env::join_paths(&roots) {
Ok(joined) => {
command.env(ADDITIONAL_AGENT_SOURCE_ROOTS_ENV, joined);
}
Err(error) => {
eprintln!("Failed to set {ADDITIONAL_AGENT_SOURCE_ROOTS_ENV}: {error}");
}
}
}
pub fn get_goose_command(app_handle: &tauri::AppHandle) -> Result<Command, String> {
if let Ok(override_path) = std::env::var("GOOSE_BIN") {
Ok(Command::new(override_path))
-1
View File
@@ -1,3 +1,2 @@
pub mod acp;
pub mod distro_bundle;
pub mod personas;
@@ -1,457 +0,0 @@
use crate::types::agents::{
builtin_personas, Avatar, CreatePersonaRequest, Persona, UpdatePersonaRequest,
};
use log::warn;
use std::collections::HashSet;
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)]
struct MarkdownFrontmatter {
name: String,
description: 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,
}
}
fn store_path() -> PathBuf {
let base = dirs::home_dir().expect("home dir");
base.join(".goose").join("personas.json")
}
/// Path to the avatars directory (~/.goose/avatars/).
pub fn avatars_dir() -> PathBuf {
dirs::home_dir()
.expect("home dir")
.join(".goose")
.join("avatars")
}
fn load_from_disk(path: &PathBuf) -> Vec<Persona> {
match std::fs::read_to_string(path) {
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
}
/// Directory containing markdown persona files.
fn agents_dir() -> PathBuf {
dirs::home_dir()
.expect("home dir")
.join(".goose")
.join("agents")
}
/// Scan `~/.goose/agents/*.md` and parse each into a Persona.
fn load_markdown_personas() -> Vec<Persona> {
let dir = Self::agents_dir();
if !dir.is_dir() {
return Vec::new();
}
let mut personas = Vec::new();
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(err) => {
warn!("Failed to read agents directory {:?}: {}", dir, err);
return Vec::new();
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
match Self::parse_markdown_persona(&path) {
Ok(persona) => personas.push(persona),
Err(err) => {
warn!("Skipping {:?}: {}", path, err);
}
}
}
personas
}
/// Parse a single markdown file with YAML frontmatter into a Persona.
fn parse_markdown_persona(path: &std::path::Path) -> Result<Persona, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
// Expect file to start with "---"
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Err("Missing frontmatter delimiter".to_string());
}
// Find the closing "---"
let after_first = &trimmed[3..];
let end_idx = after_first
.find("\n---")
.ok_or_else(|| "Missing closing frontmatter delimiter".to_string())?;
let yaml_str = &after_first[..end_idx];
let body = after_first[end_idx + 4..].trim().to_string();
let frontmatter: MarkdownFrontmatter = serde_yaml::from_str(yaml_str)
.map_err(|e| format!("Invalid frontmatter YAML: {}", e))?;
// Derive a stable ID from the filename (without extension)
let slug = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let id = format!("md-{}", slug);
// Use the file modification time for timestamps, fall back to now
let mod_time = std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| {
let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
let dt = chrono::DateTime::from_timestamp(
duration.as_secs() as i64,
duration.subsec_nanos(),
)?;
Some(dt.to_rfc3339())
})
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
// Use the body as system prompt. If body is empty, use description or a fallback.
let system_prompt = if body.is_empty() {
frontmatter
.description
.clone()
.unwrap_or_else(|| format!("You are {}.", frontmatter.name))
} else {
body
};
Ok(Persona {
id,
display_name: frontmatter.name,
avatar: None,
system_prompt,
provider: None,
model: None,
is_builtin: false,
is_from_disk: true,
created_at: mod_time.clone(),
updated_at: mod_time,
})
}
fn markdown_persona_path(id: &str) -> Result<PathBuf, String> {
let slug = id
.strip_prefix("md-")
.ok_or_else(|| format!("Persona '{}' is not a file-backed persona", id))?;
Self::validate_markdown_persona_slug(slug)?;
Ok(Self::agents_dir().join(format!("{}.md", slug)))
}
fn validate_markdown_persona_slug(slug: &str) -> Result<(), String> {
if slug.chars().any(|c| matches!(c, '/' | '\\')) {
return Err(format!("Persona '{}' has an invalid file-backed ID", slug));
}
let mut components = Path::new(slug).components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(()),
_ => Err(format!("Persona '{}' has an invalid file-backed ID", slug)),
}
}
/// 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.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 custom personas (not builtins, not from markdown files)
let custom: Vec<&Persona> = personas
.iter()
.filter(|p| !p.is_builtin && !p.is_from_disk)
.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()
}
#[allow(dead_code)]
pub fn get(&self, id: &str) -> Option<Persona> {
let personas = self.personas.lock().unwrap();
personas.iter().find(|p| p.id == id).cloned()
}
pub fn create(&self, req: CreatePersonaRequest) -> Result<Persona, String> {
let now = chrono::Utc::now().to_rfc3339();
let persona = Persona {
id: uuid::Uuid::new_v4().to_string(),
display_name: req.display_name,
avatar: req.avatar,
system_prompt: req.system_prompt,
provider: req.provider,
model: req.model,
is_builtin: false,
is_from_disk: false,
created_at: now.clone(),
updated_at: now,
};
let mut personas = self.personas.lock().unwrap();
personas.push(persona.clone());
self.save_to_disk(&personas);
Ok(persona)
}
pub fn update(&self, id: &str, req: UpdatePersonaRequest) -> Result<Persona, String> {
let mut personas = self.personas.lock().unwrap();
let persona = personas
.iter_mut()
.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 {
return Err("Cannot update a markdown persona — edit the file directly".to_string());
}
if let Some(name) = req.display_name {
persona.display_name = name;
}
if let Some(avatar_value) = req.avatar {
// Some(None) → clear, Some(Some(a)) → set
persona.avatar = avatar_value;
}
if let Some(prompt) = req.system_prompt {
persona.system_prompt = prompt;
}
if let Some(provider) = req.provider {
persona.provider = Some(provider);
}
if let Some(model) = req.model {
persona.model = Some(model);
}
persona.updated_at = chrono::Utc::now().to_rfc3339();
let updated = persona.clone();
self.save_to_disk(&personas);
Ok(updated)
}
pub fn delete(&self, id: &str) -> Result<(), String> {
let mut personas = self.personas.lock().unwrap();
let persona = personas
.iter()
.find(|p| p.id == id)
.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) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(format!(
"Failed to delete file-backed persona '{}': {}",
path.display(),
err
));
}
}
personas.retain(|p| p.id != id);
self.save_to_disk(&personas);
return Ok(());
}
// Clean up local avatar file if present
if let Some(Avatar::Local(filename)) = &persona.avatar {
let path = Self::avatars_dir().join(filename);
let _ = std::fs::remove_file(path);
}
personas.retain(|p| p.id != id);
self.save_to_disk(&personas);
Ok(())
}
/// Copy an avatar image from a source path to ~/.goose/avatars/{persona_id}.{ext}.
/// Returns the filename (not full path).
pub fn save_avatar_from_path(persona_id: &str, source_path: &str) -> Result<String, String> {
let avatars_dir = Self::avatars_dir();
std::fs::create_dir_all(&avatars_dir)
.map_err(|e| format!("Failed to create avatars directory: {}", e))?;
let source = std::path::Path::new(source_path);
// Extract extension from source filename
let ext = source
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let stored_name = format!("{}.{}", persona_id, ext);
let dest = avatars_dir.join(&stored_name);
// Remove any existing avatar for this persona (different extension)
if let Ok(entries) = std::fs::read_dir(&avatars_dir) {
let prefix = format!("{}.", persona_id);
for entry in entries.flatten() {
let name = entry.file_name();
if let Some(name_str) = name.to_str() {
if name_str.starts_with(&prefix) && name_str != stored_name {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
std::fs::copy(source, &dest).map_err(|e| format!("Failed to copy avatar file: {}", e))?;
Ok(stored_name)
}
/// Write avatar image bytes to ~/.goose/avatars/{persona_id}.{ext}.
/// Returns the filename (not full path).
pub fn save_avatar_from_bytes(
persona_id: &str,
bytes: &[u8],
extension: &str,
) -> Result<String, String> {
let avatars_dir = Self::avatars_dir();
std::fs::create_dir_all(&avatars_dir)
.map_err(|e| format!("Failed to create avatars directory: {}", e))?;
let ext = extension.to_lowercase();
let stored_name = format!("{}.{}", persona_id, ext);
let dest = avatars_dir.join(&stored_name);
// Remove any existing avatar for this persona (different extension)
if let Ok(entries) = std::fs::read_dir(&avatars_dir) {
let prefix = format!("{}.", persona_id);
for entry in entries.flatten() {
let name = entry.file_name();
if let Some(name_str) = name.to_str() {
if name_str.starts_with(&prefix) && name_str != stored_name {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
std::fs::write(&dest, bytes).map_err(|e| format!("Failed to write avatar file: {}", e))?;
Ok(stored_name)
}
/// Delete avatar file for a persona.
#[allow(dead_code)]
pub fn delete_avatar_file(filename: &str) {
let path = Self::avatars_dir().join(filename);
let _ = std::fs::remove_file(path);
}
}
#[cfg(test)]
mod tests {
use super::PersonaStore;
#[test]
fn markdown_persona_path_rejects_parent_segments() {
assert!(PersonaStore::markdown_persona_path("md-../secret").is_err());
assert!(PersonaStore::markdown_persona_path("md-..").is_err());
}
#[test]
fn markdown_persona_path_rejects_path_separators() {
assert!(PersonaStore::markdown_persona_path("md-nested/slug").is_err());
assert!(PersonaStore::markdown_persona_path(r"md-nested\slug").is_err());
}
#[test]
fn markdown_persona_path_accepts_normal_slug() {
let path = PersonaStore::markdown_persona_path("md-scout").unwrap();
let file_name = path.file_name().and_then(|name| name.to_str());
assert_eq!(file_name, Some("scout.md"));
}
}
-198
View File
@@ -1,198 +0,0 @@
use serde::{Deserialize, Deserializer, Serialize};
/// Avatar for a persona — either a remote URL or a local file in ~/.goose/avatars/.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum Avatar {
#[serde(rename = "url")]
Url(String),
#[serde(rename = "local")]
Local(String),
}
/// Custom deserializer that handles migration from old format.
/// Accepts:
/// - null → None
/// - "https://..." (bare string) → Some(Avatar::Url(s))
/// - { "type": "url", "value": "..." } → Some(Avatar::Url(...))
/// - { "type": "local", "value": "x" } → Some(Avatar::Local(...))
fn deserialize_avatar_compat<'de, D>(deserializer: D) -> Result<Option<Avatar>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum AvatarOrString {
Avatar(Avatar),
BareString(String),
}
let opt: Option<AvatarOrString> = Option::deserialize(deserializer)?;
match opt {
None => Ok(None),
Some(AvatarOrString::BareString(s)) => {
if s.is_empty() {
Ok(None)
} else {
Ok(Some(Avatar::Url(s)))
}
}
Some(AvatarOrString::Avatar(a)) => Ok(Some(a)),
}
}
/// Deserializer for UpdatePersonaRequest: distinguishes "field absent" from "field: null".
/// - JSON field absent → None (don't update)
/// - "avatar": null → Some(None) (clear the avatar)
/// - "avatar": {...} or "str" → Some(Some(Avatar))
fn deserialize_avatar_update<'de, D>(deserializer: D) -> Result<Option<Option<Avatar>>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum AvatarOrString {
Avatar(Avatar),
BareString(String),
}
let opt: Option<AvatarOrString> = Option::deserialize(deserializer)?;
match opt {
None => Ok(Some(None)), // explicit null → clear
Some(AvatarOrString::BareString(s)) => {
if s.is_empty() {
Ok(Some(None))
} else {
Ok(Some(Some(Avatar::Url(s))))
}
}
Some(AvatarOrString::Avatar(a)) => Ok(Some(Some(a))),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Persona {
pub id: String,
pub display_name: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "avatarUrl",
deserialize_with = "deserialize_avatar_compat"
)]
pub avatar: Option<Avatar>,
pub system_prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub is_builtin: bool,
#[serde(default)]
pub is_from_disk: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePersonaRequest {
pub display_name: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_avatar_compat"
)]
pub avatar: Option<Avatar>,
pub system_prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePersonaRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_avatar_update"
)]
pub avatar: Option<Option<Avatar>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Agent {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub persona_id: Option<String>,
pub provider: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
pub connection_type: String,
pub status: String,
pub is_builtin: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub acp_endpoint: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub provider_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub persona_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub model_name: Option<String>,
pub created_at: String,
pub updated_at: String,
pub message_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_message_preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub archived_at: Option<String>,
}
/// Partial update for a session — only provided fields are applied.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionUpdate {
pub title: Option<String>,
pub provider_id: Option<String>,
pub persona_id: Option<String>,
pub model_name: Option<String>,
#[serde(default, deserialize_with = "deserialize_nullable_field")]
pub project_id: Option<Option<String>>,
}
fn deserialize_nullable_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<Option<T>>::deserialize(deserializer)
}
pub use super::builtin_personas::builtin_personas;
File diff suppressed because one or more lines are too long
-3
View File
@@ -1,5 +1,2 @@
#[allow(dead_code)]
pub mod agents;
pub mod builtin_personas;
#[allow(dead_code)]
pub mod messages;
+2 -1
View File
@@ -47,7 +47,8 @@
"icons/icon.ico"
],
"resources": {
"../distro": "distro"
"../distro": "distro",
"../builtin-sources": "builtin-sources"
},
"externalBin": ["../../../target/release/goose"],
"linux": {
@@ -6,6 +6,9 @@ export function getPersonaSource(persona: Persona): PersonaSource {
if (persona.isBuiltin) {
return "builtin";
}
if (persona.writable === true) {
return "custom";
}
if (persona.isFromDisk) {
return "file";
}
@@ -13,5 +16,5 @@ export function getPersonaSource(persona: Persona): PersonaSource {
}
export function isPersonaReadOnly(persona: Persona): boolean {
return getPersonaSource(persona) !== "custom";
return persona.writable === false || getPersonaSource(persona) !== "custom";
}
@@ -219,5 +219,6 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
getBuiltinPersonas: () => get().personas.filter((p) => p.isBuiltin),
getCustomPersonas: () => get().personas.filter((p) => !p.isBuiltin),
getCustomPersonas: () =>
get().personas.filter((p) => !p.isBuiltin && p.writable !== false),
}));
@@ -119,7 +119,8 @@ export function AgentsView() {
);
const handleDeletePersona = useCallback((persona: Persona) => {
if (getPersonaSource(persona) === "builtin") return;
if (getPersonaSource(persona) === "builtin" || persona.writable === false)
return;
setDeletingPersona(persona);
}, []);
@@ -4,21 +4,27 @@ import { Camera, X } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import { savePersonaAvatar, savePersonaAvatarBytes } from "@/shared/api/agents";
import { open } from "@tauri-apps/plugin-dialog";
import type { Avatar } from "@/shared/types/agents";
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg"];
function filePathToFileUrl(filePath: string): string {
const normalizedPath = filePath.replaceAll("\\", "/");
const url = new URL("file://");
url.pathname = normalizedPath.startsWith("/")
? normalizedPath
: `/${normalizedPath}`;
return url.href;
}
interface AvatarDropZoneProps {
personaId: string;
avatar: Avatar | null | undefined;
onChange: (avatar: Avatar | null) => void;
disabled?: boolean;
}
export function AvatarDropZone({
personaId,
avatar,
onChange,
disabled = false,
@@ -44,11 +50,20 @@ export function AvatarDropZone({
setIsUploading(true);
try {
const buffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(buffer));
const filename = await savePersonaAvatarBytes(personaId, bytes, ext);
const value = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Avatar file could not be read as a data URL"));
}
});
reader.addEventListener("error", () => reject(reader.error));
reader.readAsDataURL(file);
});
onChange({ type: "local", value: filename });
onChange({ type: "url", value });
} catch (err) {
console.error("Failed to save avatar:", err);
setError(t("avatar.saveFailed"));
@@ -56,7 +71,7 @@ export function AvatarDropZone({
setIsUploading(false);
}
},
[personaId, onChange, t],
[onChange, t],
);
/** Save a file selected via the native file picker (has a path). */
@@ -72,9 +87,7 @@ export function AvatarDropZone({
setIsUploading(true);
try {
const filename = await savePersonaAvatar(personaId, filePath);
onChange({ type: "local", value: filename });
onChange({ type: "url", value: filePathToFileUrl(filePath) });
} catch (err) {
console.error("Failed to save avatar:", err);
setError(t("avatar.saveFailed"));
@@ -82,7 +95,7 @@ export function AvatarDropZone({
setIsUploading(false);
}
},
[personaId, onChange, t],
[onChange, t],
);
// Standard HTML5 drag-and-drop (works when dragDropEnabled is false)
@@ -13,7 +13,10 @@ import {
} from "@/shared/ui/dropdown-menu";
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
import type { Persona } from "@/shared/types/agents";
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
import {
getPersonaSource,
isPersonaReadOnly,
} from "@/features/agents/lib/personaPresentation";
interface PersonaCardProps {
persona: Persona;
@@ -40,8 +43,9 @@ export function PersonaCard({
const initials = persona.displayName.charAt(0).toUpperCase();
const avatarSrc = useAvatarSrc(persona.avatar);
const personaSource = getPersonaSource(persona);
const canEditPersona = personaSource === "custom";
const canDeletePersona = personaSource !== "builtin";
const canEditPersona = !isPersonaReadOnly(persona);
const canDeletePersona =
personaSource !== "builtin" && persona.writable !== false;
const providerModelLabel = [persona.provider, persona.model]
.filter(Boolean)
.join(" / ");
@@ -72,8 +72,9 @@ export function PersonaEditor({
const readOnlyBySource = persona ? isPersonaReadOnly(persona) : false;
const isReadOnly = detailsMode || readOnlyBySource;
const personaSource = persona ? getPersonaSource(persona) : "custom";
const canEditPersona = personaSource === "custom";
const canDeletePersona = personaSource !== "builtin";
const canEditPersona = !readOnlyBySource;
const canDeletePersona =
personaSource !== "builtin" && persona?.writable !== false;
const acpProviders = useAgentStore((s) => s.providers);
const setProviders = useAgentStore((s) => s.setProviders);
const mergeInventoryEntries = useProviderInventoryStore(
@@ -187,9 +188,6 @@ export function PersonaEditor({
const initials = displayName.charAt(0).toUpperCase() || "?";
// For new personas, use a temporary ID for the avatar upload
const avatarPersonaId = persona?.id ?? "new-persona";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col gap-0 p-0">
@@ -236,7 +234,6 @@ export function PersonaEditor({
</AvatarRoot>
) : (
<AvatarDropZone
personaId={avatarPersonaId}
avatar={avatar}
onChange={setAvatar}
disabled={isReadOnly}
@@ -60,10 +60,10 @@ export function PersonaGallery({
});
const sorted = useMemo(() => {
const builtins = personas
.filter((p) => p.isBuiltin)
.filter((p) => p.isBuiltin || p.writable === false)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
const custom = personas
.filter((p) => !p.isBuiltin)
.filter((p) => !p.isBuiltin && p.writable !== false)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
return [...builtins, ...custom];
}, [personas]);
@@ -246,12 +246,12 @@ function MentionAvatar({ persona }: { persona: Persona }) {
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full",
persona.isBuiltin
persona.isBuiltin || persona.writable === false
? "bg-foreground/10 text-foreground"
: "bg-brand/10 text-brand",
)}
>
{persona.isBuiltin ? (
{persona.isBuiltin || persona.writable === false ? (
<Sparkles className="h-3.5 w-3.5" />
) : (
<User className="h-3.5 w-3.5" />
@@ -43,11 +43,11 @@ export function PersonaPicker({
);
const builtinPersonas = useMemo(
() => personas.filter((p) => p.isBuiltin),
() => personas.filter((p) => p.isBuiltin || p.writable === false),
[personas],
);
const customPersonas = useMemo(
() => personas.filter((p) => !p.isBuiltin),
() => personas.filter((p) => !p.isBuiltin && p.writable !== false),
[personas],
);
@@ -211,7 +211,9 @@ function PersonaAvatar({
);
}
const isBuiltin = persona?.isBuiltin ?? true;
const isBuiltin = persona
? persona.isBuiltin || persona.writable === false
: true;
return (
<div
+163 -53
View File
@@ -1,78 +1,188 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { exportPersona, importPersonas, refreshPersonas } from "../agents";
import {
createPersona,
deletePersona,
exportPersona,
importPersonas,
listPersonas,
refreshPersonas,
updatePersona,
} from "../agents";
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
const mockGooseSourcesCreate = vi.fn();
const mockGooseSourcesDelete = vi.fn();
const mockGooseSourcesExport = vi.fn();
const mockGooseSourcesImport = vi.fn();
const mockGooseSourcesList = vi.fn();
const mockGooseSourcesUpdate = vi.fn();
vi.mock("@/shared/api/acpConnection", () => ({
getClient: async () => ({
goose: {
GooseSourcesCreate: (...args: unknown[]) =>
mockGooseSourcesCreate(...args),
GooseSourcesDelete: (...args: unknown[]) =>
mockGooseSourcesDelete(...args),
GooseSourcesExport: (...args: unknown[]) =>
mockGooseSourcesExport(...args),
GooseSourcesImport: (...args: unknown[]) =>
mockGooseSourcesImport(...args),
GooseSourcesList: (...args: unknown[]) => mockGooseSourcesList(...args),
GooseSourcesUpdate: (...args: unknown[]) =>
mockGooseSourcesUpdate(...args),
},
}),
}));
const mockedInvoke = vi.mocked(invoke);
const source = {
type: "agent",
name: "Scout",
description: "Agent",
content: "Research carefully.",
path: "/Users/test/.agents/agents/scout.md",
global: true,
writable: true,
properties: {
provider: "goose",
model: "claude-sonnet-4",
avatar: "file:///Users/test/.goose/avatars/agents/scout.png",
},
};
describe("agents API", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── exportPersona ────────────────────────────────────────────────────
it("listPersonas maps agent sources to personas", async () => {
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
it("exportPersona invokes correct Tauri command with ID", async () => {
const mockResult = {
json: '{"displayName":"Test"}',
suggestedFilename: "test.json",
};
mockedInvoke.mockResolvedValue(mockResult);
const result = await listPersonas();
const result = await exportPersona("persona-123");
expect(mockedInvoke).toHaveBeenCalledWith("export_persona", {
id: "persona-123",
});
expect(result).toEqual(mockResult);
expect(mockGooseSourcesList).toHaveBeenCalledWith({ type: "agent" });
expect(result).toEqual([
{
id: source.path,
displayName: "Scout",
avatar: { type: "url", value: source.properties.avatar },
systemPrompt: "Research carefully.",
provider: "goose",
model: "claude-sonnet-4",
isBuiltin: false,
isFromDisk: true,
writable: true,
createdAt: "",
updatedAt: "",
},
]);
});
// ── importPersonas ───────────────────────────────────────────────────
it("createPersona creates a global agent source", async () => {
mockGooseSourcesCreate.mockResolvedValue({ source });
it("importPersonas invokes correct Tauri command with bytes and filename", async () => {
const mockPersonas = [
{
id: "imported-1",
displayName: "Imported",
systemPrompt: "Hello",
isBuiltin: false,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
},
];
mockedInvoke.mockResolvedValue(mockPersonas);
const fileBytes = [0x7b, 0x7d]; // "{}"
const result = await importPersonas(fileBytes, "personas.json");
expect(mockedInvoke).toHaveBeenCalledWith("import_personas", {
fileBytes,
fileName: "personas.json",
const result = await createPersona({
displayName: "Scout",
avatar: { type: "url", value: source.properties.avatar },
systemPrompt: "Research carefully.",
provider: "goose",
model: "claude-sonnet-4",
});
expect(result).toEqual(mockPersonas);
expect(mockGooseSourcesCreate).toHaveBeenCalledWith({
type: "agent",
name: "Scout",
description: "Agent",
content: "Research carefully.",
properties: {
provider: "goose",
model: "claude-sonnet-4",
avatar: source.properties.avatar,
},
global: true,
});
expect(result.displayName).toBe("Scout");
});
// ── refreshPersonas ──────────────────────────────────────────────────
it("updatePersona loads existing agent source and updates by path", async () => {
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
mockGooseSourcesUpdate.mockResolvedValue({
source: { ...source, name: "Scout 2" },
});
it("refreshPersonas invokes correct Tauri command", async () => {
const mockPersonas = [
{
id: "p1",
displayName: "Refreshed",
systemPrompt: "Prompt",
isBuiltin: false,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
const result = await updatePersona(source.path, {
displayName: "Scout 2",
});
expect(mockGooseSourcesUpdate).toHaveBeenCalledWith({
type: "agent",
path: source.path,
name: "Scout 2",
description: "Agent",
content: "Research carefully.",
properties: {
provider: "goose",
model: "claude-sonnet-4",
avatar: source.properties.avatar,
},
];
mockedInvoke.mockResolvedValue(mockPersonas);
});
expect(result.displayName).toBe("Scout 2");
});
it("deletePersona deletes an agent source by path", async () => {
mockGooseSourcesDelete.mockResolvedValue(undefined);
await deletePersona(source.path);
expect(mockGooseSourcesDelete).toHaveBeenCalledWith({
type: "agent",
path: source.path,
});
});
it("exportPersona exports an agent source", async () => {
mockGooseSourcesExport.mockResolvedValue({
json: '{"type":"agent"}',
filename: "scout.agent.json",
});
const result = await exportPersona(source.path);
expect(mockGooseSourcesExport).toHaveBeenCalledWith({
type: "agent",
path: source.path,
});
expect(result).toEqual({
json: '{"type":"agent"}',
suggestedFilename: "scout.agent.json",
});
});
it("importPersonas imports agent source JSON", async () => {
mockGooseSourcesImport.mockResolvedValue({ sources: [source] });
const data = JSON.stringify({
version: 1,
type: "agent",
name: "Scout",
description: "Agent",
content: "Research carefully.",
});
const fileBytes = Array.from(new TextEncoder().encode(data));
const result = await importPersonas(fileBytes, "scout.agent.json");
expect(mockGooseSourcesImport).toHaveBeenCalledWith({
data,
global: true,
});
expect(result).toHaveLength(1);
});
it("refreshPersonas lists personas", async () => {
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
const result = await refreshPersonas();
expect(mockedInvoke).toHaveBeenCalledWith("refresh_personas");
expect(result).toEqual(mockPersonas);
expect(mockGooseSourcesList).toHaveBeenCalledWith({ type: "agent" });
expect(result).toHaveLength(1);
});
});
+176 -26
View File
@@ -1,33 +1,155 @@
import { invoke } from "@tauri-apps/api/core";
import type { SourceEntry } from "@aaif/goose-sdk";
import { getClient } from "@/shared/api/acpConnection";
import type {
Persona,
CreatePersonaRequest,
UpdatePersonaRequest,
Avatar,
} from "@/shared/types/agents";
const AGENT_SOURCE_TYPE = "agent" as const;
const AGENT_DESCRIPTION = "Agent";
type AgentSourceProperties = {
provider?: string;
model?: string;
avatar?: string;
};
type AgentSourceEntry = SourceEntry & {
type: typeof AGENT_SOURCE_TYPE;
properties: AgentSourceProperties;
};
function isAgentSource(source: SourceEntry): source is AgentSourceEntry {
return source.type === AGENT_SOURCE_TYPE;
}
function avatarToProperty(
avatar: Avatar | null | undefined,
): string | undefined {
if (!avatar) return undefined;
return avatar.value;
}
function propertyToAvatar(value: string | undefined): Avatar | null {
if (!value) return null;
return { type: "url", value };
}
function personaProperties(
request: CreatePersonaRequest | UpdatePersonaRequest,
): AgentSourceProperties | undefined {
const properties: AgentSourceProperties = {};
if (request.provider) properties.provider = request.provider;
if (request.model) properties.model = request.model;
const avatar = avatarToProperty(request.avatar);
if (avatar) properties.avatar = avatar;
return properties;
}
function toPersona(source: AgentSourceEntry): Persona {
const writable = source.writable !== false;
return {
id: source.path,
displayName: source.name,
avatar: propertyToAvatar(source.properties?.avatar),
systemPrompt: source.content,
provider: source.properties?.provider,
model: source.properties?.model,
isBuiltin: !writable,
isFromDisk: writable,
writable,
createdAt: "",
updatedAt: "",
};
}
async function listAgentSources(): Promise<AgentSourceEntry[]> {
const client = await getClient();
const response = await client.goose.GooseSourcesList({
type: AGENT_SOURCE_TYPE,
});
return response.sources.filter(isAgentSource);
}
async function getAgentSource(id: string): Promise<AgentSourceEntry> {
const source = (await listAgentSources()).find(
(source) => source.path === id,
);
if (!source) {
throw new Error(`Agent '${id}' not found`);
}
return source;
}
export async function listPersonas(): Promise<Persona[]> {
return invoke("list_personas");
return (await listAgentSources()).map(toPersona);
}
export async function createPersona(
request: CreatePersonaRequest,
): Promise<Persona> {
return invoke("create_persona", { request });
const client = await getClient();
const response = await client.goose.GooseSourcesCreate({
type: AGENT_SOURCE_TYPE,
name: request.displayName,
description: AGENT_DESCRIPTION,
content: request.systemPrompt,
properties: personaProperties(request),
global: true,
});
if (!isAgentSource(response.source)) {
throw new Error(`Unexpected source type returned: ${response.source.type}`);
}
return toPersona(response.source);
}
export async function updatePersona(
id: string,
request: UpdatePersonaRequest,
): Promise<Persona> {
return invoke("update_persona", { id, request });
const existing = await getAgentSource(id);
const client = await getClient();
const merged: CreatePersonaRequest = {
displayName: request.displayName ?? existing.name,
avatar:
request.avatar === undefined
? propertyToAvatar(existing.properties?.avatar)
: request.avatar,
systemPrompt: request.systemPrompt ?? existing.content,
provider: request.provider ?? existing.properties?.provider,
model: request.model ?? existing.properties?.model,
};
const response = await client.goose.GooseSourcesUpdate({
type: AGENT_SOURCE_TYPE,
path: id,
name: merged.displayName,
description: existing.description || AGENT_DESCRIPTION,
content: merged.systemPrompt,
properties: personaProperties(merged),
});
if (!isAgentSource(response.source)) {
throw new Error(`Unexpected source type returned: ${response.source.type}`);
}
return toPersona(response.source);
}
export async function deletePersona(id: string): Promise<void> {
return invoke("delete_persona", { id });
const client = await getClient();
await client.goose.GooseSourcesDelete({
type: AGENT_SOURCE_TYPE,
path: id,
});
}
export async function refreshPersonas(): Promise<Persona[]> {
return invoke("refresh_personas");
return listPersonas();
}
export interface ExportResult {
@@ -36,14 +158,61 @@ export interface ExportResult {
}
export async function exportPersona(id: string): Promise<ExportResult> {
return invoke("export_persona", { id });
const client = await getClient();
const response = await client.goose.GooseSourcesExport({
type: AGENT_SOURCE_TYPE,
path: id,
});
return { json: response.json, suggestedFilename: response.filename };
}
export async function importPersonas(
fileBytes: number[],
fileName: string,
): Promise<Persona[]> {
return invoke("import_personas", { fileBytes, fileName });
if (
!fileName.endsWith(".agent.json") &&
!fileName.endsWith(".persona.json") &&
!fileName.endsWith(".json")
) {
throw new Error(
"File must have a .agent.json, .persona.json, or .json extension",
);
}
const raw = new TextDecoder().decode(new Uint8Array(fileBytes));
const parsed = JSON.parse(raw) as Record<string, unknown>;
const data =
parsed.type === AGENT_SOURCE_TYPE
? raw
: JSON.stringify({
version: parsed.version ?? 1,
type: AGENT_SOURCE_TYPE,
name: parsed.displayName ?? parsed.name,
description: AGENT_DESCRIPTION,
content:
parsed.systemPrompt ?? parsed.content ?? parsed.instructions ?? "",
properties: {
provider: parsed.provider,
model: parsed.model,
avatar:
typeof parsed.avatar === "string"
? parsed.avatar
: typeof parsed.avatar === "object" &&
parsed.avatar !== null &&
"value" in parsed.avatar
? (parsed.avatar as { value?: unknown }).value
: undefined,
},
});
const client = await getClient();
const response = await client.goose.GooseSourcesImport({
data,
global: true,
});
return response.sources.filter(isAgentSource).map(toPersona);
}
export interface ImportFileReadResult {
@@ -56,22 +225,3 @@ export async function readImportPersonaFile(
): Promise<ImportFileReadResult> {
return invoke("read_import_persona_file", { sourcePath });
}
export async function savePersonaAvatar(
personaId: string,
sourcePath: string,
): Promise<string> {
return invoke("save_persona_avatar", { personaId, sourcePath });
}
export async function savePersonaAvatarBytes(
personaId: string,
bytes: number[],
extension: string,
): Promise<string> {
return invoke("save_persona_avatar_bytes", { personaId, bytes, extension });
}
export async function getAvatarsDir(): Promise<string> {
return invoke("get_avatars_dir");
}
+9 -15
View File
@@ -1,28 +1,22 @@
import { convertFileSrc } from "@tauri-apps/api/core";
import { getAvatarsDir } from "@/shared/api/agents";
import type { Avatar } from "@/shared/types/agents";
let cachedAvatarsDir: string | null = null;
async function ensureAvatarsDir(): Promise<string> {
if (!cachedAvatarsDir) {
cachedAvatarsDir = await getAvatarsDir();
function resolveFileUrl(value: string): string {
try {
return convertFileSrc(decodeURIComponent(new URL(value).pathname));
} catch {
return value;
}
return cachedAvatarsDir;
}
/**
* Resolve an Avatar to a displayable image URL.
* Lazily fetches the avatars directory on first call for a local avatar.
*/
export async function resolveAvatarSrc(
avatar: Avatar | null | undefined,
): Promise<string | undefined> {
if (!avatar) return undefined;
if (avatar.type === "url") return avatar.value;
if (avatar.type === "local") {
const dir = await ensureAvatarsDir();
return convertFileSrc(`${dir}/${avatar.value}`);
if (avatar.type === "url") {
return avatar.value.startsWith("file://")
? resolveFileUrl(avatar.value)
: avatar.value;
}
return undefined;
}
+21 -4
View File
@@ -3,10 +3,26 @@
// a narrow union.
export type ProviderType = string;
// Avatar type — either a remote URL or a local file in ~/.goose/avatars/
export type Avatar =
| { type: "url"; value: string }
| { type: "local"; value: string };
export interface ProviderConfig {
type: ProviderType;
name: string;
description?: string;
models: ModelInfo[];
requiresApiKey: boolean;
apiKeyEnvVar?: string;
}
export interface ModelInfo {
id: string;
name: string;
contextWindow: number;
supportsTools: boolean;
supportsVision: boolean;
supportsThinking: boolean;
}
// Avatar type — remote, data, and file URLs are stored directly in source properties.
export type Avatar = { type: "url"; value: string };
// Persona types (from sprout)
export interface Persona {
@@ -18,6 +34,7 @@ export interface Persona {
model?: string;
isBuiltin: boolean;
isFromDisk?: boolean;
writable?: boolean;
createdAt: string;
updatedAt: string;
}
+37 -7
View File
@@ -124,6 +124,22 @@ export function buildInitScript(options?: {
supportingFiles: [],
});
const personaToSourceEntry = (p) => ({
type: "agent",
name: p.displayName ?? p.name,
description: p.description ?? "Agent",
content: p.systemPrompt ?? p.content ?? "",
path: p.id ?? p.path ?? ("/mock/.agents/agents/" + (p.displayName ?? p.name)),
global: p.global ?? true,
writable: p.writable ?? !p.isBuiltin,
supportingFiles: [],
properties: {
provider: p.provider,
model: p.model,
avatar: typeof p.avatar === "string" ? p.avatar : p.avatar?.value,
},
});
const projectToSourceEntry = (p) => ({
type: "project",
name: p.id ?? p.name?.toLowerCase(),
@@ -263,30 +279,42 @@ export function buildInitScript(options?: {
return jsonRpcResult(message.id, {});
case "_goose/sources/list": {
const sourceType = message.params?.type;
if (sourceType === "agent") {
return jsonRpcResult(message.id, { sources: PERSONAS.map(personaToSourceEntry) });
}
if (sourceType === "project") {
return jsonRpcResult(message.id, { sources: PROJECTS.map(projectToSourceEntry) });
}
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
}
case "_goose/sources/create":
case "_goose/sources/create": {
const sourceType = message.params?.type ?? "skill";
const name = message.params?.name ?? (sourceType === "agent" ? "New Agent" : "new-skill");
return jsonRpcResult(message.id, {
source: {
name: message.params?.name ?? "new-skill",
type: "skill",
name,
type: sourceType,
description: message.params?.description ?? "",
content: message.params?.content ?? "",
path: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"),
path: "/mock/.agents/" + (sourceType === "agent" ? "agents" : "skills") + "/" + name,
global: message.params?.global ?? true,
writable: true,
supportingFiles: [],
properties: message.params?.properties ?? {},
},
});
}
case "_goose/sources/update":
case "goose/sources/update": {
const path = message.params?.path ?? "/mock/.agents/skills/updated-skill";
const sourceType = message.params?.type ?? "skill";
const path =
message.params?.path ??
(sourceType === "agent" ? "/mock/.agents/agents/updated-agent" : "/mock/.agents/skills/updated-skill");
const nextName = message.params?.name;
const name =
typeof nextName === "string" && nextName.length > 0
? nextName
: String(path).split("/").filter(Boolean).at(-1) ?? "updated-skill";
: String(path).split("/").filter(Boolean).at(-1) ?? (sourceType === "agent" ? "updated-agent" : "updated-skill");
const segments = String(path).split("/").filter(Boolean);
if (segments.length > 0) {
segments[segments.length - 1] = name;
@@ -295,12 +323,14 @@ export function buildInitScript(options?: {
return jsonRpcResult(message.id, {
source: {
name,
type: "skill",
type: sourceType,
description: message.params?.description ?? "",
content: message.params?.content ?? "",
path: updatedPath,
global: message.params?.global ?? true,
writable: true,
supportingFiles: [],
properties: message.params?.properties ?? {},
},
});
}
+5
View File
@@ -797,6 +797,11 @@ export type SourceEntry = {
* when it lives inside a specific project.
*/
global: boolean;
/**
* True when this source can be modified through source CRUD methods.
* Client-provided bundled sources are returned as read-only.
*/
writable?: boolean;
/**
* Paths (absolute) of additional files that live alongside the source.
* Only skills currently populate this; empty for other source types.
+1
View File
@@ -791,6 +791,7 @@ export const zSourceEntry = z.object({
content: z.string(),
path: z.string(),
global: z.boolean(),
writable: z.boolean().optional().default(false),
supportingFiles: z.array(z.string()).optional(),
properties: z.record(z.unknown()).optional()
});