mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Allow builtins
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
|
||||
@@ -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: "",
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface Persona {
|
||||
model?: string;
|
||||
isBuiltin: boolean;
|
||||
isFromDisk?: boolean;
|
||||
writable?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user