Allow builtins

This commit is contained in:
Jack Amadeo
2026-05-07 11:52:38 -04:00
parent 5dcba56bfb
commit ee6261b9a0
30 changed files with 340 additions and 56 deletions
+26 -2
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};
@@ -791,6 +792,14 @@ enum Command {
action = clap::ArgAction::Append
)]
builtins: Vec<String>,
#[arg(
long = "agent-root",
value_name = "PATH",
help = "Add a read-only directory containing bundled agent markdown files",
action = clap::ArgAction::Append
)]
agent_roots: Vec<std::path::PathBuf>,
},
/// Start or resume interactive chat sessions
@@ -1109,7 +1118,12 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> {
Ok(())
}
async fn handle_serve_command(host: String, port: u16, builtins: Vec<String>) -> Result<()> {
async fn handle_serve_command(
host: String,
port: u16,
builtins: Vec<String>,
agent_roots: Vec<std::path::PathBuf>,
) -> Result<()> {
use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig};
use goose::acp::transport::create_router;
use goose::config::paths::Paths;
@@ -1123,11 +1137,20 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec<String>) ->
builtins
};
let additional_source_roots = agent_roots
.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()
@@ -1835,7 +1858,8 @@ pub async fn cli() -> anyhow::Result<()> {
host,
port,
builtins,
}) => handle_serve_command(host, port, builtins).await,
agent_roots,
}) => handle_serve_command(host, port, builtins, agent_roots).await,
Some(Command::Session {
command: Some(cmd), ..
}) => handle_session_subcommand(cmd).await,
+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
@@ -1677,6 +1677,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": {
+5
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,
@@ -239,6 +240,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.
@@ -1054,6 +1056,7 @@ impl GooseAcpAgent {
goose_mode: GooseMode,
disable_session_naming: bool,
goose_platform: GoosePlatform,
additional_source_roots: Vec<SourceRoot>,
) -> Result<Self> {
let session_manager = Arc::new(SessionManager::new(data_dir));
@@ -1084,6 +1087,7 @@ impl GooseAcpAgent {
disable_session_naming,
provider_inventory,
goose_platform,
additional_source_roots,
})
}
@@ -3478,6 +3482,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?;
+14 -4
View File
@@ -34,10 +34,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 })
}
@@ -46,7 +47,7 @@ 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,
@@ -54,6 +55,7 @@ impl GooseAcpAgent {
&req.content,
req.metadata,
req.properties,
&self.additional_source_roots,
)?;
Ok(UpdateSourceResponse { source })
}
@@ -62,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 {})
}
@@ -70,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 })
}
+3
View File
@@ -1,5 +1,6 @@
use crate::acp::server::{AcpProviderFactory, GooseAcpAgent};
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 {
@@ -47,6 +49,7 @@ impl AcpServer {
goose_mode,
disable_session_naming,
self.config.goose_platform.clone(),
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(),
metadata: None,
@@ -175,6 +176,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(),
metadata: None,
@@ -604,6 +606,7 @@ impl SummonClient {
content: String::new(),
path: sr.path.clone(),
global: false,
writable: true,
supporting_files: Vec::new(),
properties: std::collections::HashMap::new(),
metadata: None,
+1
View File
@@ -41,6 +41,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,
metadata: None,
+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,
}
}
}
+178 -30
View File
@@ -8,6 +8,7 @@ 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 base64::Engine;
use fs_err as fs;
@@ -186,6 +187,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,
metadata: None,
@@ -282,6 +284,7 @@ fn skill_source_entry(
content: content.to_string(),
path: dir.to_string_lossy().to_string(),
global,
writable: true,
supporting_files: Vec::new(),
properties,
metadata: None,
@@ -305,6 +308,7 @@ fn source_entry(
path: &Path,
global: bool,
metadata: Option<Value>,
writable: bool,
) -> SourceEntry {
SourceEntry {
source_type,
@@ -313,6 +317,7 @@ fn source_entry(
content: content.to_string(),
path: path.to_string_lossy().to_string(),
global,
writable,
supporting_files: Vec::new(),
properties: HashMap::new(),
metadata,
@@ -556,7 +561,7 @@ fn parse_agent_frontmatter(raw: &str) -> Result<(AgentFrontmatter, String), Erro
.ok_or_else(|| Error::invalid_params().data("Agent file is missing frontmatter"))
}
fn agent_source_entry(path: &Path, global: bool) -> Result<SourceEntry, Error> {
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)?;
@@ -568,6 +573,7 @@ fn agent_source_entry(path: &Path, global: bool) -> Result<SourceEntry, Error> {
path,
global,
agent_metadata_json(frontmatter.provider, frontmatter.model, frontmatter.avatar),
writable,
))
}
@@ -575,6 +581,24 @@ 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();
@@ -590,7 +614,10 @@ fn is_global_agent_file(path: &Path) -> bool {
.any(|root| canonical_path.starts_with(canonicalize_or_original(&root)))
}
fn resolve_agent_file(path: &str) -> Result<PathBuf, Error> {
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"));
}
@@ -613,10 +640,13 @@ fn resolve_agent_file(path: &str) -> Result<PathBuf, Error> {
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_agent_dir && !is_global_agent_file(&canonical_file) && !in_additional_root)
{
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", path)));
}
@@ -624,24 +654,49 @@ fn resolve_agent_file(path: &str) -> Result<PathBuf, Error> {
Ok(canonical_file)
}
fn list_agent_dirs(working_dir: Option<&Path>) -> Vec<(PathBuf, bool)> {
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((working_dir.join(".agents").join("agents"), false));
dirs.push((working_dir.join(".goose").join("agents"), false));
dirs.push((working_dir.join(".claude").join("agents"), false));
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,
});
}
if let Some(home) = dirs::home_dir() {
dirs.push((home.join(".agents").join("agents"), true));
dirs.push((home.join(".goose").join("agents"), true));
dirs.push((home.join(".claude").join("agents"), true));
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((Paths::config_dir().join("agents"), true));
dirs.push(SourceRoot {
path: Paths::config_dir().join("agents"),
writable: true,
});
dirs.extend(additional_roots.iter().cloned());
dirs
}
fn list_agent_sources(project_dir: Option<&str>) -> Vec<SourceEntry> {
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())
@@ -649,8 +704,8 @@ fn list_agent_sources(project_dir: Option<&str>) -> Vec<SourceEntry> {
let mut seen = std::collections::HashSet::new();
let mut sources = Vec::new();
for (dir, global) in list_agent_dirs(working_dir.as_deref()) {
let entries = match fs::read_dir(&dir) {
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,
};
@@ -659,7 +714,7 @@ fn list_agent_sources(project_dir: Option<&str>) -> Vec<SourceEntry> {
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
match agent_source_entry(&path, global) {
match agent_source_entry(&path, !root.writable, root.writable) {
Ok(source) => {
let key = source.name.to_lowercase();
if seen.insert(key) {
@@ -704,7 +759,7 @@ fn create_agent_source(
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)
agent_source_entry(&file_path, global, true)
}
fn update_agent_source(
@@ -713,15 +768,17 @@ fn update_agent_source(
description: &str,
content: &str,
metadata: Option<Value>,
additional_roots: &[SourceRoot],
) -> Result<SourceEntry, Error> {
validate_agent_name(name)?;
let file_path = resolve_agent_file(path)?;
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 md = build_agent_md(name, description, content, metadata)?;
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)
agent_source_entry(&file_path, global, true)
}
// --- Public CRUD ---
@@ -805,10 +862,32 @@ pub fn update_source(
content: &str,
metadata: Option<Value>,
properties: Option<HashMap<String, serde_json::Value>>,
) -> Result<SourceEntry, Error> {
update_source_with_roots(
source_type,
path,
name,
description,
content,
metadata,
properties,
&[],
)
}
pub fn update_source_with_roots(
source_type: SourceType,
path: &str,
name: &str,
description: &str,
content: &str,
metadata: Option<Value>,
properties: Option<HashMap<String, serde_json::Value>>,
additional_roots: &[SourceRoot],
) -> Result<SourceEntry, Error> {
require_mutable_type(source_type)?;
if source_type == SourceType::Agent {
return update_agent_source(path, name, description, content, metadata);
return update_agent_source(path, name, description, content, metadata, additional_roots);
}
match source_type {
@@ -823,9 +902,6 @@ 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 {
Some(p) => p,
None => read_existing_skill_properties(&dir),
@@ -870,9 +946,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())
@@ -884,7 +957,6 @@ pub fn update_source(
)));
}
// Same preserve-on-None semantics as skills.
let resolved_properties = match properties {
Some(p) => p,
None => read_existing_project_properties(&file),
@@ -906,6 +978,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 {
@@ -922,7 +1002,8 @@ pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> {
})?;
}
SourceType::Agent => {
let file_path = resolve_agent_file(path)?;
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}"))
})?;
@@ -936,6 +1017,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))?;
@@ -1013,7 +1103,7 @@ pub fn list_sources(
sources.extend(read_project_dir()?);
}
SourceType::Agent => {
sources.extend(list_agent_sources(project_dir));
sources.extend(list_agent_sources(project_dir, additional_roots));
}
SourceType::Recipe | SourceType::Subrecipe => {
return Err(Error::invalid_params()
@@ -1027,6 +1117,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)?;
@@ -1053,8 +1151,13 @@ pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, Str
Ok((json, filename))
}
SourceType::Agent => {
let file_path = resolve_agent_file(path)?;
let source = agent_source_entry(&file_path, is_global_agent_file(&file_path))?;
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",
@@ -1259,6 +1362,51 @@ 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,
&[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.directory, agent_path.to_string_lossy());
let err = update_source_with_roots(
SourceType::Agent,
&solo.directory,
"Solo",
"Built in",
"Updated",
None,
&[SourceRoot::read_only(root.canonicalize().unwrap())],
)
.unwrap_err();
assert!(format!("{:?}", err).contains("read-only"));
let err = delete_source_with_roots(
SourceType::Agent,
&solo.directory,
&[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();
+1
View File
@@ -194,6 +194,7 @@ pub async fn spawn_acp_server_in_process(
goose_mode,
disable_session_naming,
GoosePlatform::GooseCli,
Vec::new(),
)
.await
.unwrap();
@@ -0,0 +1,6 @@
---
name: Ralph
description: Iterative development assistant
---
You are an iterative development assistant that works through a task, checks the result, and keeps improving until the work is ready to ship.
@@ -0,0 +1,6 @@
---
name: Scout
description: Research assistant
---
You are a research assistant that finds information.
+6
View File
@@ -0,0 +1,6 @@
---
name: Solo
description: General-purpose assistant
---
You are a general-purpose assistant.
@@ -1,4 +1,4 @@
use tauri::Manager;
use tauri::{Manager, Runtime};
use tauri_plugin_shell::ShellExt;
use std::ffi::OsString;
@@ -13,6 +13,7 @@ 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 BUNDLED_AGENT_ROOT_DIR: &str = "builtin-sources/agents";
// ---------------------------------------------------------------------------
// GooseServeProcess — singleton that owns the long-lived `goose serve` child
// ---------------------------------------------------------------------------
@@ -84,6 +85,8 @@ impl GooseServeProcess {
}
}
add_bundled_agent_root(&app_handle, &mut command);
command
.arg("serve")
.arg("--host")
@@ -121,6 +124,27 @@ impl GooseServeProcess {
}
}
fn add_bundled_agent_root<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;
}
command.arg("--agent-root").arg(&root);
}
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))
+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": {
@@ -13,5 +13,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);
}, []);
@@ -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(
@@ -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
@@ -41,6 +41,7 @@ const source = {
content: "Research carefully.",
directory: "/Users/test/.agents/agents/scout.md",
global: true,
writable: true,
metadata: {
provider: "goose",
model: "claude-sonnet-4",
@@ -68,7 +69,8 @@ describe("agents API", () => {
provider: "goose",
model: "claude-sonnet-4",
isBuiltin: false,
isFromDisk: false,
isFromDisk: true,
writable: true,
createdAt: "",
updatedAt: "",
},
+4 -2
View File
@@ -50,6 +50,7 @@ function personaMetadata(
}
function toPersona(source: AgentSourceEntry): Persona {
const writable = source.writable !== false;
return {
id: source.directory,
displayName: source.name,
@@ -57,8 +58,9 @@ function toPersona(source: AgentSourceEntry): Persona {
systemPrompt: source.content,
provider: source.metadata?.provider,
model: source.metadata?.model,
isBuiltin: false,
isFromDisk: false,
isBuiltin: !writable,
isFromDisk: writable,
writable,
createdAt: "",
updatedAt: "",
};
+1
View File
@@ -34,6 +34,7 @@ export interface Persona {
model?: string;
isBuiltin: boolean;
isFromDisk?: boolean;
writable?: boolean;
createdAt: string;
updatedAt: string;
}
+5
View File
@@ -802,6 +802,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
@@ -792,6 +792,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(),
metadata: z.unknown().optional()