diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 76aeee9950..653d395cfe 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -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, + + #[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, }, /// 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) -> Result<()> { +async fn handle_serve_command( + host: String, + port: u16, + builtins: Vec, + agent_roots: Vec, +) -> 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) -> 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, diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 44b6cabe3a..26b7594a09 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -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")] diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 72ce35e0e6..d13d03c6a9 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -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": { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 51a53c914c..cd51c884bd 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -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, } /// 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, ) -> Result { 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) -> 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?; diff --git a/crates/goose/src/acp/server/sources.rs b/crates/goose/src/acp/server/sources.rs index 6ea9f96783..24f87b192a 100644 --- a/crates/goose/src/acp/server/sources.rs +++ b/crates/goose/src/acp/server/sources.rs @@ -34,10 +34,11 @@ impl GooseAcpAgent { &self, req: ListSourcesRequest, ) -> Result { - 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 { - 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 { - 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 { - 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 }) } diff --git a/crates/goose/src/acp/server_factory.rs b/crates/goose/src/acp/server_factory.rs index 2e9686a708..59fddbea16 100644 --- a/crates/goose/src/acp/server_factory.rs +++ b/crates/goose/src/acp/server_factory.rs @@ -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, } 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"); diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 129589ce45..cf4f0404a8 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -127,6 +127,7 @@ fn parse_agent_content(content: &str, path: &Path) -> Option { 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, diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 98e6c0512a..412205cb11 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -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; diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index 5dab2fd75f..f45894a5d8 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -284,6 +284,7 @@ fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option Self { + Self { + path, + writable: false, + } + } +} diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index fb8878af24..151369eddd 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -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 { 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, + 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 { +fn agent_source_entry(path: &Path, global: bool, writable: bool) -> Result { 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 { 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 { +fn resolve_agent_file_with_roots( + path: &str, + additional_roots: &[SourceRoot], +) -> Result { 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 { 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 { 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 { 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 { +fn list_agent_sources( + project_dir: Option<&str>, + additional_roots: &[SourceRoot], +) -> Vec { 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 { 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 { 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, + additional_roots: &[SourceRoot], ) -> Result { 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, properties: Option>, +) -> Result { + 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, + properties: Option>, + additional_roots: &[SourceRoot], ) -> Result { 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, project_dir: Option<&str>, include_project_sources: bool, +) -> Result, Error> { + list_sources_with_roots(source_type, project_dir, include_project_sources, &[]) +} + +pub fn list_sources_with_roots( + source_type: Option, + project_dir: Option<&str>, + include_project_sources: bool, + additional_roots: &[SourceRoot], ) -> Result, 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(); diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index ae8a112d00..f87319b904 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -194,6 +194,7 @@ pub async fn spawn_acp_server_in_process( goose_mode, disable_session_naming, GoosePlatform::GooseCli, + Vec::new(), ) .await .unwrap(); diff --git a/ui/goose2/builtin-sources/agents/ralph.md b/ui/goose2/builtin-sources/agents/ralph.md new file mode 100644 index 0000000000..9eb29c37b5 --- /dev/null +++ b/ui/goose2/builtin-sources/agents/ralph.md @@ -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. diff --git a/ui/goose2/builtin-sources/agents/scout.md b/ui/goose2/builtin-sources/agents/scout.md new file mode 100644 index 0000000000..482a129a87 --- /dev/null +++ b/ui/goose2/builtin-sources/agents/scout.md @@ -0,0 +1,6 @@ +--- +name: Scout +description: Research assistant +--- + +You are a research assistant that finds information. diff --git a/ui/goose2/builtin-sources/agents/solo.md b/ui/goose2/builtin-sources/agents/solo.md new file mode 100644 index 0000000000..50d005614d --- /dev/null +++ b/ui/goose2/builtin-sources/agents/solo.md @@ -0,0 +1,6 @@ +--- +name: Solo +description: General-purpose assistant +--- + +You are a general-purpose assistant. diff --git a/ui/goose2/src-tauri/src/services/acp/goose_serve.rs b/ui/goose2/src-tauri/src/services/acp/goose_serve.rs index 62e1043f74..9b6315c68e 100644 --- a/ui/goose2/src-tauri/src/services/acp/goose_serve.rs +++ b/ui/goose2/src-tauri/src/services/acp/goose_serve.rs @@ -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(manager: &impl Manager, 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 { if let Ok(override_path) = std::env::var("GOOSE_BIN") { Ok(Command::new(override_path)) diff --git a/ui/goose2/src-tauri/tauri.conf.json b/ui/goose2/src-tauri/tauri.conf.json index a5abbfd893..4c45e0204b 100644 --- a/ui/goose2/src-tauri/tauri.conf.json +++ b/ui/goose2/src-tauri/tauri.conf.json @@ -47,7 +47,8 @@ "icons/icon.ico" ], "resources": { - "../distro": "distro" + "../distro": "distro", + "../builtin-sources": "builtin-sources" }, "externalBin": ["../../../target/release/goose"], "linux": { diff --git a/ui/goose2/src/features/agents/lib/personaPresentation.ts b/ui/goose2/src/features/agents/lib/personaPresentation.ts index ee44c3585b..879d31890e 100644 --- a/ui/goose2/src/features/agents/lib/personaPresentation.ts +++ b/ui/goose2/src/features/agents/lib/personaPresentation.ts @@ -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"; } diff --git a/ui/goose2/src/features/agents/stores/agentStore.ts b/ui/goose2/src/features/agents/stores/agentStore.ts index 7efee9df40..72b8cedb69 100644 --- a/ui/goose2/src/features/agents/stores/agentStore.ts +++ b/ui/goose2/src/features/agents/stores/agentStore.ts @@ -219,5 +219,6 @@ export const useAgentStore = create((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), })); diff --git a/ui/goose2/src/features/agents/ui/AgentsView.tsx b/ui/goose2/src/features/agents/ui/AgentsView.tsx index 23abe723a2..7cac120ab0 100644 --- a/ui/goose2/src/features/agents/ui/AgentsView.tsx +++ b/ui/goose2/src/features/agents/ui/AgentsView.tsx @@ -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); }, []); diff --git a/ui/goose2/src/features/agents/ui/PersonaCard.tsx b/ui/goose2/src/features/agents/ui/PersonaCard.tsx index 83e2c9cb5f..ef8cdf5a8d 100644 --- a/ui/goose2/src/features/agents/ui/PersonaCard.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaCard.tsx @@ -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(" / "); diff --git a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx index e2734e9fcf..013a43c476 100644 --- a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx @@ -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( diff --git a/ui/goose2/src/features/agents/ui/PersonaGallery.tsx b/ui/goose2/src/features/agents/ui/PersonaGallery.tsx index b37863eca3..88c65c1288 100644 --- a/ui/goose2/src/features/agents/ui/PersonaGallery.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaGallery.tsx @@ -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]); diff --git a/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx b/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx index 23ff5db0b2..9fb0e6c691 100644 --- a/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx +++ b/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx @@ -246,12 +246,12 @@ function MentionAvatar({ persona }: { persona: Persona }) {
- {persona.isBuiltin ? ( + {persona.isBuiltin || persona.writable === false ? ( ) : ( diff --git a/ui/goose2/src/features/chat/ui/PersonaPicker.tsx b/ui/goose2/src/features/chat/ui/PersonaPicker.tsx index 6435f0a40d..09675f8d89 100644 --- a/ui/goose2/src/features/chat/ui/PersonaPicker.tsx +++ b/ui/goose2/src/features/chat/ui/PersonaPicker.tsx @@ -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 (
{ provider: "goose", model: "claude-sonnet-4", isBuiltin: false, - isFromDisk: false, + isFromDisk: true, + writable: true, createdAt: "", updatedAt: "", }, diff --git a/ui/goose2/src/shared/api/agents.ts b/ui/goose2/src/shared/api/agents.ts index fd97faacaf..51c1293107 100644 --- a/ui/goose2/src/shared/api/agents.ts +++ b/ui/goose2/src/shared/api/agents.ts @@ -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: "", }; diff --git a/ui/goose2/src/shared/types/agents.ts b/ui/goose2/src/shared/types/agents.ts index 1e71f0a5b4..99caa0241f 100644 --- a/ui/goose2/src/shared/types/agents.ts +++ b/ui/goose2/src/shared/types/agents.ts @@ -34,6 +34,7 @@ export interface Persona { model?: string; isBuiltin: boolean; isFromDisk?: boolean; + writable?: boolean; createdAt: string; updatedAt: string; } diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index bb9a585edd..fd110c9eeb 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -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. diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 7b915753e1..daf10f6976 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -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()