Agents crud (#9084)

This commit is contained in:
Jack Amadeo
2026-05-11 13:33:32 -04:00
committed by GitHub
parent 40d4b118bb
commit a38922d95e
43 changed files with 1463 additions and 1458 deletions
+14
View File
@@ -7,6 +7,7 @@ use goose::config::{Config, GooseMode};
#[cfg(feature = "telemetry")]
use goose::posthog::get_telemetry_choice;
use goose::recipe::Recipe;
use goose::source_roots::SourceRoot;
use goose_mcp::mcp_server_runner::{serve, McpCommand};
use goose_mcp::{AutoVisualiserRouter, ComputerControllerServer, MemoryServer, TutorialServer};
@@ -1123,11 +1124,24 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec<String>) ->
builtins
};
let additional_source_roots = Config::global()
.get_param::<String>("ADDITIONAL_AGENT_SOURCE_ROOTS")
.ok()
.map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.map(|path| {
let path = path.canonicalize().unwrap_or(path);
SourceRoot::read_only(path)
})
.collect();
let server = Arc::new(AcpServer::new(AcpServerFactoryConfig {
builtins,
data_dir: Paths::data_dir(),
config_dir: Paths::config_dir(),
goose_platform: GoosePlatform::GooseCli,
additional_source_roots,
}));
let secret_key = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV)
.ok()
+4
View File
@@ -852,6 +852,10 @@ pub struct SourceEntry {
/// True when the source lives in the user's global sources directory; false
/// when it lives inside a specific project.
pub global: bool,
/// True when this source can be modified through source CRUD methods.
/// Client-provided bundled sources are returned as read-only.
#[serde(default)]
pub writable: bool,
/// Paths (absolute) of additional files that live alongside the source.
/// Only skills currently populate this; empty for other source types.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
+5
View File
@@ -1957,6 +1957,11 @@
"type": "boolean",
"description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project."
},
"writable": {
"type": "boolean",
"description": "True when this source can be modified through source CRUD methods.\nClient-provided bundled sources are returned as read-only.",
"default": false
},
"supportingFiles": {
"type": "array",
"items": {
+24 -17
View File
@@ -23,6 +23,7 @@ use crate::providers::inventory::{
};
use crate::session::session_manager::SessionType;
use crate::session::{EnabledExtensionsState, Session, SessionManager};
use crate::source_roots::SourceRoot;
use crate::utils::sanitize_unicode_tags;
use agent_client_protocol::schema::{
AgentCapabilities, Annotations, AuthMethod, AuthMethodAgent, AuthenticateRequest,
@@ -216,6 +217,17 @@ struct AgentSetupRequest {
prebuilt_provider: Option<Arc<dyn Provider>>,
}
pub struct GooseAcpAgentOptions {
pub provider_factory: AcpProviderFactory,
pub builtins: Vec<String>,
pub data_dir: std::path::PathBuf,
pub config_dir: std::path::PathBuf,
pub goose_mode: GooseMode,
pub disable_session_naming: bool,
pub goose_platform: GoosePlatform,
pub additional_source_roots: Vec<SourceRoot>,
}
pub struct GooseAcpAgent {
sessions: Arc<Mutex<HashMap<String, GooseAcpSession>>>,
provider_factory: AcpProviderFactory,
@@ -230,6 +242,7 @@ pub struct GooseAcpAgent {
disable_session_naming: bool,
provider_inventory: ProviderInventoryService,
goose_platform: GoosePlatform,
additional_source_roots: Vec<SourceRoot>,
}
/// Shorten a session/thread id for perf log correlation.
@@ -1036,16 +1049,8 @@ impl GooseAcpAgent {
}
// TODO: goose reads Paths::in_state_dir globally (e.g. RequestLog), ignoring this data_dir.
pub async fn new(
provider_factory: AcpProviderFactory,
builtins: Vec<String>,
data_dir: std::path::PathBuf,
config_dir: std::path::PathBuf,
goose_mode: GooseMode,
disable_session_naming: bool,
goose_platform: GoosePlatform,
) -> Result<Self> {
let session_manager = Arc::new(SessionManager::new(data_dir));
pub async fn new(options: GooseAcpAgentOptions) -> Result<Self> {
let session_manager = Arc::new(SessionManager::new(options.data_dir));
// Eagerly initialize the SQLite pool so it's ready when providers/sessions need it.
let storage_clone = session_manager.storage().clone();
@@ -1053,23 +1058,24 @@ impl GooseAcpAgent {
let _ = storage_clone.pool().await;
});
let permission_manager = Arc::new(PermissionManager::new(config_dir.clone()));
let permission_manager = Arc::new(PermissionManager::new(options.config_dir.clone()));
let provider_inventory = ProviderInventoryService::new(session_manager.storage().clone());
Ok(Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
provider_factory,
builtins,
provider_factory: options.provider_factory,
builtins: options.builtins,
client_fs_capabilities: OnceCell::new(),
client_terminal: OnceCell::new(),
client_mcp_host_info: OnceCell::new(),
config_dir,
config_dir: options.config_dir,
session_manager,
permission_manager,
goose_mode,
disable_session_naming,
goose_mode: options.goose_mode,
disable_session_naming: options.disable_session_naming,
provider_inventory,
goose_platform,
goose_platform: options.goose_platform,
additional_source_roots: options.additional_source_roots,
})
}
@@ -3315,6 +3321,7 @@ pub async fn run(builtins: Vec<String>) -> Result<()> {
data_dir: Paths::data_dir(),
config_dir: Paths::config_dir(),
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
},
);
let agent = server.create_agent().await?;
+17 -5
View File
@@ -33,10 +33,11 @@ impl GooseAcpAgent {
&self,
req: ListSourcesRequest,
) -> Result<ListSourcesResponse, agent_client_protocol::Error> {
let sources = crate::sources::list_sources(
let sources = crate::sources::list_sources_with_roots(
req.source_type,
req.project_dir.as_deref(),
req.include_project_sources,
&self.additional_source_roots,
)?;
Ok(ListSourcesResponse { sources })
}
@@ -45,13 +46,16 @@ impl GooseAcpAgent {
&self,
req: UpdateSourceRequest,
) -> Result<UpdateSourceResponse, agent_client_protocol::Error> {
let source = crate::sources::update_source(
let source = crate::sources::update_source_with_roots(
req.source_type,
&req.path,
&req.name,
&req.description,
&req.content,
req.properties,
crate::sources::UpdateSourceOptions {
properties: req.properties,
additional_roots: &self.additional_source_roots,
},
)?;
Ok(UpdateSourceResponse { source })
}
@@ -60,7 +64,11 @@ impl GooseAcpAgent {
&self,
req: DeleteSourceRequest,
) -> Result<EmptyResponse, agent_client_protocol::Error> {
crate::sources::delete_source(req.source_type, &req.path)?;
crate::sources::delete_source_with_roots(
req.source_type,
&req.path,
&self.additional_source_roots,
)?;
Ok(EmptyResponse {})
}
@@ -68,7 +76,11 @@ impl GooseAcpAgent {
&self,
req: ExportSourceRequest,
) -> Result<ExportSourceResponse, agent_client_protocol::Error> {
let (json, filename) = crate::sources::export_source(req.source_type, &req.path)?;
let (json, filename) = crate::sources::export_source_with_roots(
req.source_type,
&req.path,
&self.additional_source_roots,
)?;
Ok(ExportSourceResponse { json, filename })
}
+10 -7
View File
@@ -1,5 +1,6 @@
use crate::acp::server::{AcpProviderFactory, GooseAcpAgent};
use crate::acp::server::{AcpProviderFactory, GooseAcpAgent, GooseAcpAgentOptions};
use crate::agents::GoosePlatform;
use crate::source_roots::SourceRoot;
use anyhow::Result;
use std::sync::Arc;
use tracing::info;
@@ -9,6 +10,7 @@ pub struct AcpServerFactoryConfig {
pub data_dir: std::path::PathBuf,
pub config_dir: std::path::PathBuf,
pub goose_platform: GoosePlatform,
pub additional_source_roots: Vec<SourceRoot>,
}
pub struct AcpServer {
@@ -39,15 +41,16 @@ impl AcpServer {
})
});
let agent = GooseAcpAgent::new(
let agent = GooseAcpAgent::new(GooseAcpAgentOptions {
provider_factory,
self.config.builtins.clone(),
self.config.data_dir.clone(),
self.config.config_dir.clone(),
builtins: self.config.builtins.clone(),
data_dir: self.config.data_dir.clone(),
config_dir: self.config.config_dir.clone(),
goose_mode,
disable_session_naming,
self.config.goose_platform.clone(),
)
goose_platform: self.config.goose_platform.clone(),
additional_source_roots: self.config.additional_source_roots.clone(),
})
.await?;
info!("Created new ACP agent");
@@ -127,6 +127,7 @@ fn parse_agent_content(content: &str, path: &Path) -> Option<SourceEntry> {
content: body,
path: path.to_string_lossy().into_owned(),
global: false,
writable: true,
supporting_files: Vec::new(),
properties: std::collections::HashMap::new(),
})
@@ -174,6 +175,7 @@ fn scan_recipes_from_dir(
content: recipe.instructions.clone().unwrap_or_default(),
path: path.to_string_lossy().into_owned(),
global: false,
writable: true,
supporting_files: Vec::new(),
properties: std::collections::HashMap::new(),
});
@@ -602,6 +604,7 @@ impl SummonClient {
content: String::new(),
path: sr.path.clone(),
global: false,
writable: true,
supporting_files: Vec::new(),
properties: std::collections::HashMap::new(),
});
+7
View File
@@ -12,6 +12,7 @@ impl Paths {
DirType::Data => base.join("data"),
DirType::State => base.join("state"),
DirType::Plugins => base.join(".agents").join("plugins"),
DirType::Agents => base.join(".agents").join("agents"),
}
} else {
// NOTE: "Block" is kept here for backwards compatibility with existing
@@ -29,6 +30,7 @@ impl Paths {
DirType::Data => strategy.data_dir(),
DirType::State => strategy.state_dir().unwrap_or(strategy.data_dir()),
DirType::Plugins => strategy.home_dir().join(".agents").join("plugins"),
DirType::Agents => strategy.home_dir().join(".agents").join("agents"),
}
}
}
@@ -49,6 +51,10 @@ impl Paths {
Self::get_dir(DirType::Plugins)
}
pub fn agents_dir() -> PathBuf {
Self::get_dir(DirType::Agents)
}
pub fn in_state_dir(subpath: &str) -> PathBuf {
Self::state_dir().join(subpath)
}
@@ -67,4 +73,5 @@ enum DirType {
Data,
State,
Plugins,
Agents,
}
+1
View File
@@ -42,6 +42,7 @@ pub mod session;
pub mod session_context;
pub mod skills;
pub mod slash_commands;
pub mod source_roots;
pub mod sources;
pub mod subprocess;
pub mod token_counter;
+1
View File
@@ -284,6 +284,7 @@ fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option<Sourc
content: body,
path: path.to_string_lossy().into_owned(),
global,
writable: true,
supporting_files: Vec::new(),
properties: metadata.metadata,
})
+16
View File
@@ -0,0 +1,16 @@
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceRoot {
pub path: PathBuf,
pub writable: bool,
}
impl SourceRoot {
pub fn read_only(path: PathBuf) -> Self {
Self {
path,
writable: false,
}
}
}
+543 -44
View File
@@ -8,12 +8,14 @@ use crate::skills::{
parse_skill_frontmatter, resolve_discoverable_skill_dir, resolve_skill_dir, skill_base_dir,
validate_skill_name,
};
use crate::source_roots::SourceRoot;
use agent_client_protocol::Error;
use fs_err as fs;
use goose_sdk::custom_requests::{SourceEntry, SourceType};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::warn;
pub fn parse_frontmatter<T: for<'de> Deserialize<'de>>(
content: &str,
@@ -32,7 +34,7 @@ pub fn parse_frontmatter<T: for<'de> Deserialize<'de>>(
fn require_mutable_type(source_type: SourceType) -> Result<(), Error> {
match source_type {
SourceType::Skill | SourceType::Project => Ok(()),
SourceType::Skill | SourceType::Project | SourceType::Agent => Ok(()),
other => Err(Error::invalid_params().data(format!(
"Source type '{other}' is not supported for mutation."
))),
@@ -44,6 +46,7 @@ fn require_listable_type(source_type: Option<SourceType>) -> Result<SourceType,
SourceType::Skill => Ok(SourceType::Skill),
SourceType::BuiltinSkill => Ok(SourceType::BuiltinSkill),
SourceType::Project => Ok(SourceType::Project),
SourceType::Agent => Ok(SourceType::Agent),
other => Err(Error::invalid_params().data(format!(
"Source type '{}' is not supported for listing.",
other
@@ -54,7 +57,7 @@ fn require_listable_type(source_type: Option<SourceType>) -> Result<SourceType,
// --- Project helpers ---
#[derive(Deserialize)]
struct ProjectFront {
struct MarkdownSourceFrontmatter {
#[serde(default)]
name: String,
#[serde(default)]
@@ -71,37 +74,39 @@ fn project_file_path(slug: &str) -> PathBuf {
projects_dir().join(format!("{slug}.md"))
}
fn build_project_md(
fn build_source_markdown(
name: &str,
description: &str,
content: &str,
properties: &HashMap<String, serde_json::Value>,
) -> String {
let mut fm = serde_yaml::Mapping::new();
fm.insert(
) -> Result<String, Error> {
let mut frontmatter = serde_yaml::Mapping::new();
frontmatter.insert(
serde_yaml::Value::String("name".into()),
serde_yaml::Value::String(name.into()),
);
fm.insert(
frontmatter.insert(
serde_yaml::Value::String("description".into()),
serde_yaml::Value::String(description.into()),
);
for (k, v) in properties {
if k == "name" || k == "description" {
for (key, value) in properties {
if key == "name" || key == "description" {
continue;
}
if let Ok(yv) = serde_yaml::to_value(v) {
fm.insert(serde_yaml::Value::String(k.clone()), yv);
}
let value = serde_yaml::to_value(value).map_err(|e| {
Error::internal_error().data(format!("Failed to serialize source property: {e}"))
})?;
frontmatter.insert(serde_yaml::Value::String(key.clone()), value);
}
let yaml = serde_yaml::to_string(&fm).unwrap_or_default();
let yaml = serde_yaml::to_string(&frontmatter)
.map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?;
let mut md = format!("---\n{yaml}---\n");
if !content.is_empty() {
md.push('\n');
md.push_str(content);
md.push('\n');
}
md
Ok(md)
}
/// Returns (display_name, description, body, properties).
@@ -116,7 +121,7 @@ fn parse_project_frontmatter(
HashMap::new(),
);
}
match parse_frontmatter::<ProjectFront>(raw) {
match parse_frontmatter::<MarkdownSourceFrontmatter>(raw) {
Ok(Some((meta, body))) => (meta.name, meta.description, body, meta.properties),
_ => (
String::new(),
@@ -155,6 +160,18 @@ fn read_existing_project_properties(file: &Path) -> HashMap<String, serde_json::
properties
}
/// Read the properties bag out of an existing agent file.
fn read_existing_agent_properties(file: &Path) -> HashMap<String, serde_json::Value> {
let raw = match fs::read_to_string(file) {
Ok(s) => s,
Err(_) => return HashMap::new(),
};
match parse_agent_frontmatter(&raw) {
Ok((frontmatter, _)) => frontmatter.properties,
Err(_) => HashMap::new(),
}
}
fn project_entry_from_file(file: &Path) -> Option<SourceEntry> {
let slug = file.file_stem().and_then(|s| s.to_str())?.to_string();
if slug.is_empty() {
@@ -182,6 +199,7 @@ fn project_entry_from_file(file: &Path) -> Option<SourceEntry> {
content,
path: file.to_string_lossy().into_owned(),
global: true,
writable: true,
supporting_files: Vec::new(),
properties,
})
@@ -277,6 +295,7 @@ fn skill_source_entry(
content: content.to_string(),
path: dir.to_string_lossy().to_string(),
global,
writable: true,
supporting_files: Vec::new(),
properties,
}
@@ -290,6 +309,322 @@ fn builtin_skill_entry(mut source: SourceEntry) -> SourceEntry {
source
}
fn agent_base_dir(global: bool, project_dir: Option<&str>) -> Result<PathBuf, Error> {
if global {
Ok(Paths::agents_dir())
} else {
let project_dir = project_dir.ok_or_else(|| {
Error::invalid_params().data("projectDir is required when global is false")
})?;
if project_dir.trim().is_empty() {
return Err(
Error::invalid_params().data("projectDir must not be empty when global is false")
);
}
Ok(Path::new(project_dir).join(".agents").join("agents"))
}
}
fn validate_agent_name(name: &str) -> Result<(), Error> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(Error::invalid_params().data("Agent name must not be empty"));
}
if trimmed.len() > 80 {
return Err(Error::invalid_params().data(format!(
"Invalid agent name \"{}\". Names must be at most 80 characters.",
name
)));
}
if trimmed.chars().any(|ch| matches!(ch, '/' | '\\')) {
return Err(Error::invalid_params().data(format!(
"Invalid agent name \"{}\". Names must not contain path separators.",
name
)));
}
Ok(())
}
fn slugify_agent_name(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect();
let mut collapsed = String::with_capacity(slug.len());
let mut previous_hyphen = false;
for ch in slug.chars() {
if ch == '-' {
if !previous_hyphen {
collapsed.push('-');
}
previous_hyphen = true;
} else {
collapsed.push(ch);
previous_hyphen = false;
}
}
let trimmed = collapsed.trim_matches('-');
if trimmed.is_empty() {
"agent".to_string()
} else {
trimmed
.chars()
.take(64)
.collect::<String>()
.trim_end_matches('-')
.to_string()
}
}
fn parse_agent_frontmatter(raw: &str) -> Result<(MarkdownSourceFrontmatter, String), Error> {
parse_frontmatter::<MarkdownSourceFrontmatter>(raw)
.map_err(|e| Error::invalid_params().data(format!("Invalid agent frontmatter: {e}")))?
.ok_or_else(|| Error::invalid_params().data("Agent file is missing frontmatter"))
}
fn agent_source_entry(path: &Path, global: bool, writable: bool) -> Result<SourceEntry, Error> {
let raw = fs::read_to_string(path)
.map_err(|e| Error::internal_error().data(format!("Failed to read agent file: {e}")))?;
let (frontmatter, content) = parse_agent_frontmatter(&raw)?;
Ok({
SourceEntry {
source_type: SourceType::Agent,
name: frontmatter.name,
description: frontmatter.description,
content,
path: path.to_string_lossy().to_string(),
global,
writable,
supporting_files: Vec::new(),
properties: frontmatter.properties,
}
})
}
fn canonicalize_or_original(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn is_under_root(path: &Path, root: &Path) -> bool {
canonicalize_or_original(path).starts_with(canonicalize_or_original(root))
}
fn is_read_only_agent_file(path: &Path, additional_roots: &[SourceRoot]) -> bool {
additional_roots
.iter()
.filter(|root| !root.writable)
.any(|root| is_under_root(path, &root.path))
}
fn reject_read_only_agent_file(path: &Path, additional_roots: &[SourceRoot]) -> Result<(), Error> {
if is_read_only_agent_file(path, additional_roots) {
return Err(Error::invalid_params().data("Source is read-only"));
}
Ok(())
}
fn is_global_agent_file(path: &Path) -> bool {
let canonical_path = canonicalize_or_original(path);
let mut global_roots = Vec::new();
global_roots.push(Paths::agents_dir());
if let Some(home) = dirs::home_dir() {
global_roots.push(home.join(".agents").join("agents"));
global_roots.push(home.join(".goose").join("agents"));
global_roots.push(home.join(".claude").join("agents"));
}
global_roots.push(Paths::config_dir().join("agents"));
global_roots
.into_iter()
.any(|root| canonical_path.starts_with(canonicalize_or_original(&root)))
}
fn resolve_agent_file_with_roots(
path: &str,
additional_roots: &[SourceRoot],
) -> Result<PathBuf, Error> {
if path.is_empty() {
return Err(Error::invalid_params().data("Source path must not be empty"));
}
let canonical_file = Path::new(path)
.canonicalize()
.map_err(|_| Error::invalid_params().data(format!("Source \"{}\" not found", path)))?;
let parent_name = canonical_file
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str());
let grandparent_name = canonical_file
.parent()
.and_then(Path::parent)
.and_then(Path::file_name)
.and_then(|name| name.to_str());
let in_agent_dir = parent_name == Some("agents")
&& matches!(
grandparent_name,
Some(".goose") | Some(".claude") | Some(".agents")
);
let in_additional_root = additional_roots
.iter()
.any(|root| is_under_root(&canonical_file, &root.path));
if !canonical_file.is_file()
|| canonical_file.extension().and_then(|ext| ext.to_str()) != Some("md")
|| (!in_agent_dir && !is_global_agent_file(&canonical_file) && !in_additional_root)
{
return Err(Error::invalid_params().data(format!("Source \"{}\" not found", path)));
}
Ok(canonical_file)
}
fn list_agent_dirs(working_dir: Option<&Path>, additional_roots: &[SourceRoot]) -> Vec<SourceRoot> {
let mut dirs = Vec::new();
if let Some(working_dir) = working_dir {
dirs.push(SourceRoot {
path: working_dir.join(".agents").join("agents"),
writable: true,
});
dirs.push(SourceRoot {
path: working_dir.join(".goose").join("agents"),
writable: true,
});
dirs.push(SourceRoot {
path: working_dir.join(".claude").join("agents"),
writable: true,
});
}
dirs.push(SourceRoot {
path: Paths::agents_dir(),
writable: true,
});
if let Some(home) = dirs::home_dir() {
dirs.push(SourceRoot {
path: home.join(".agents").join("agents"),
writable: true,
});
dirs.push(SourceRoot {
path: home.join(".goose").join("agents"),
writable: true,
});
dirs.push(SourceRoot {
path: home.join(".claude").join("agents"),
writable: true,
});
}
dirs.push(SourceRoot {
path: Paths::config_dir().join("agents"),
writable: true,
});
dirs.extend(additional_roots.iter().cloned());
dirs
}
fn is_project_agent_file(path: &Path, working_dir: &Path) -> bool {
[".agents", ".goose", ".claude"]
.into_iter()
.map(|dir| working_dir.join(dir).join("agents"))
.any(|root| is_under_root(path, &root))
}
fn list_agent_sources(
project_dir: Option<&str>,
additional_roots: &[SourceRoot],
) -> Vec<SourceEntry> {
let working_dir = project_dir
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from);
let mut seen = std::collections::HashSet::new();
let mut sources = Vec::new();
for root in list_agent_dirs(working_dir.as_deref(), additional_roots) {
let entries = match fs::read_dir(&root.path) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
continue;
}
let global = working_dir
.as_deref()
.is_none_or(|working_dir| !is_project_agent_file(&path, working_dir));
match agent_source_entry(&path, global, root.writable) {
Ok(source) => {
let key = source.name.to_lowercase();
if seen.insert(key) {
sources.push(source);
}
}
Err(err) => warn!("Skipping agent source {}: {:?}", path.display(), err),
}
}
}
sources
}
fn create_agent_source(
name: &str,
description: &str,
content: &str,
properties: HashMap<String, serde_json::Value>,
global: bool,
project_dir: Option<&str>,
) -> Result<SourceEntry, Error> {
validate_agent_name(name)?;
let base = agent_base_dir(global, project_dir)?;
let slug = slugify_agent_name(name);
let mut file_path = base.join(format!("{slug}.md"));
if file_path.exists() {
let mut counter = 2u32;
loop {
file_path = base.join(format!("{slug}-{counter}.md"));
if !file_path.exists() {
break;
}
counter += 1;
}
}
fs::create_dir_all(&base).map_err(|e| {
Error::internal_error().data(format!("Failed to create source directory: {e}"))
})?;
let md = build_source_markdown(name, description, content, &properties)?;
fs::write(&file_path, md)
.map_err(|e| Error::internal_error().data(format!("Failed to write agent file: {e}")))?;
agent_source_entry(&file_path, global, true)
}
fn update_agent_source(
path: &str,
name: &str,
description: &str,
content: &str,
properties: Option<HashMap<String, serde_json::Value>>,
additional_roots: &[SourceRoot],
) -> Result<SourceEntry, Error> {
validate_agent_name(name)?;
let file_path = resolve_agent_file_with_roots(path, additional_roots)?;
reject_read_only_agent_file(&file_path, additional_roots)?;
let global = is_global_agent_file(&file_path);
let resolved_properties = match properties {
Some(p) => p,
None => read_existing_agent_properties(&file_path),
};
let md = build_source_markdown(name, description, content, &resolved_properties)?;
fs::write(&file_path, md)
.map_err(|e| Error::internal_error().data(format!("Failed to write agent file: {e}")))?;
agent_source_entry(&file_path, global, true)
}
// --- Public CRUD ---
pub fn create_source(
@@ -302,6 +637,9 @@ pub fn create_source(
properties: HashMap<String, serde_json::Value>,
) -> Result<SourceEntry, Error> {
require_mutable_type(source_type)?;
if source_type == SourceType::Agent {
return create_agent_source(name, description, content, properties, global, project_dir);
}
match source_type {
SourceType::Skill => {
@@ -348,7 +686,7 @@ pub fn create_source(
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(name);
let md = build_project_md(display_name, description, content, &properties);
let md = build_source_markdown(display_name, description, content, &properties)?;
fs::write(&file, md).map_err(|e| {
Error::internal_error().data(format!("Failed to write project file: {e}"))
})?;
@@ -359,15 +697,30 @@ pub fn create_source(
}
}
pub fn update_source(
pub struct UpdateSourceOptions<'a> {
pub properties: Option<HashMap<String, serde_json::Value>>,
pub additional_roots: &'a [SourceRoot],
}
pub fn update_source_with_roots(
source_type: SourceType,
path: &str,
name: &str,
description: &str,
content: &str,
properties: Option<HashMap<String, serde_json::Value>>,
options: UpdateSourceOptions<'_>,
) -> Result<SourceEntry, Error> {
require_mutable_type(source_type)?;
if source_type == SourceType::Agent {
return update_agent_source(
path,
name,
description,
content,
options.properties,
options.additional_roots,
);
}
match source_type {
SourceType::Skill => {
@@ -381,10 +734,7 @@ pub fn update_source(
Error::internal_error().data("Failed to resolve source directory name")
})?;
// When the caller doesn't supply properties, preserve whatever
// is already on disk so per-skill frontmatter metadata isn't
// silently erased on edit.
let resolved_properties = match properties {
let resolved_properties = match options.properties {
Some(p) => p,
None => read_existing_skill_properties(&dir),
};
@@ -428,9 +778,6 @@ pub fn update_source(
validate_project_slug(name)?;
let file = resolve_project_path(path)?;
// We don't currently support renaming a project (it would change
// the slug used as the stable thread.project_id). Reject mismatches
// to surface this clearly.
let current_slug = file
.file_stem()
.and_then(|s| s.to_str())
@@ -442,8 +789,7 @@ pub fn update_source(
)));
}
// Same preserve-on-None semantics as skills.
let resolved_properties = match properties {
let resolved_properties = match options.properties {
Some(p) => p,
None => read_existing_project_properties(&file),
};
@@ -452,7 +798,8 @@ pub fn update_source(
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(name);
let md = build_project_md(display_name, description, content, &resolved_properties);
let md =
build_source_markdown(display_name, description, content, &resolved_properties)?;
fs::write(&file, md).map_err(|e| {
Error::internal_error().data(format!("Failed to write project file: {e}"))
})?;
@@ -464,6 +811,14 @@ pub fn update_source(
}
pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> {
delete_source_with_roots(source_type, path, &[])
}
pub fn delete_source_with_roots(
source_type: SourceType,
path: &str,
additional_roots: &[SourceRoot],
) -> Result<(), Error> {
require_mutable_type(source_type)?;
match source_type {
@@ -479,6 +834,13 @@ pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> {
Error::internal_error().data(format!("Failed to delete project: {e}"))
})?;
}
SourceType::Agent => {
let file_path = resolve_agent_file_with_roots(path, additional_roots)?;
reject_read_only_agent_file(&file_path, additional_roots)?;
fs::remove_file(&file_path).map_err(|e| {
Error::internal_error().data(format!("Failed to delete source: {e}"))
})?;
}
_ => unreachable!("guarded by require_mutable_type"),
}
Ok(())
@@ -488,6 +850,15 @@ pub fn list_sources(
source_type: Option<SourceType>,
project_dir: Option<&str>,
include_project_sources: bool,
) -> Result<Vec<SourceEntry>, Error> {
list_sources_with_roots(source_type, project_dir, include_project_sources, &[])
}
pub fn list_sources_with_roots(
source_type: Option<SourceType>,
project_dir: Option<&str>,
include_project_sources: bool,
additional_roots: &[SourceRoot],
) -> Result<Vec<SourceEntry>, Error> {
if let Some(t) = source_type {
require_listable_type(Some(t))?;
@@ -564,7 +935,10 @@ pub fn list_sources(
SourceType::Project => {
sources.extend(read_project_dir()?);
}
SourceType::Recipe | SourceType::Subrecipe | SourceType::Agent => {
SourceType::Agent => {
sources.extend(list_agent_sources(project_dir, additional_roots));
}
SourceType::Recipe | SourceType::Subrecipe => {
return Err(Error::invalid_params()
.data(format!("Source type '{}' listing is not supported.", kind)));
}
@@ -576,6 +950,14 @@ pub fn list_sources(
}
pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, String), Error> {
export_source_with_roots(source_type, path, &[])
}
pub fn export_source_with_roots(
source_type: SourceType,
path: &str,
additional_roots: &[SourceRoot],
) -> Result<(String, String), Error> {
match source_type {
SourceType::Skill => {
let dir = resolve_discoverable_skill_dir(path)?;
@@ -601,6 +983,27 @@ pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, Str
let filename = format!("{}.skill.json", name);
Ok((json, filename))
}
SourceType::Agent => {
let file_path = resolve_agent_file_with_roots(path, additional_roots)?;
let writable = !is_read_only_agent_file(&file_path, additional_roots);
let source = agent_source_entry(
&file_path,
is_global_agent_file(&file_path) || !writable,
writable,
)?;
let export = serde_json::json!({
"version": 1,
"type": "agent",
"name": source.name,
"description": source.description,
"content": source.content,
});
let json = serde_json::to_string_pretty(&export).map_err(|e| {
Error::internal_error().data(format!("Failed to serialize source: {e}"))
})?;
let filename = format!("{}.agent.json", slugify_agent_name(&source.name));
Ok((json, filename))
}
SourceType::Project => {
let file = resolve_project_path(path)?;
let raw = fs::read_to_string(&file).map_err(|e| {
@@ -667,6 +1070,7 @@ pub fn import_sources(
let source_type = match type_str {
"skill" => SourceType::Skill,
"project" => SourceType::Project,
"agent" => SourceType::Agent,
other => {
return Err(Error::invalid_params()
.data(format!("Source type '{}' import is not supported.", other)));
@@ -713,6 +1117,25 @@ pub fn import_sources(
}
}
if source_type == SourceType::Agent {
if let Some(legacy_metadata) = value.get("metadata").and_then(|v| v.as_object()) {
for (key, value) in legacy_metadata {
properties
.entry(key.clone())
.or_insert_with(|| value.clone());
}
}
return create_agent_source(
&name,
&description,
&content,
properties,
global,
project_dir,
)
.map(|source| vec![source]);
}
match source_type {
SourceType::Skill => {
validate_skill_name(&name)?;
@@ -753,7 +1176,7 @@ pub fn import_sources(
&final_name,
&description,
&content,
true, // projects are always global
true,
None,
properties,
)
@@ -782,6 +1205,54 @@ mod tests {
assert!(validate_skill_name(&"a".repeat(65)).is_err());
}
#[test]
fn lists_additional_read_only_agent_roots() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("builtin").join("agents");
std::fs::create_dir_all(&root).unwrap();
let agent_path = root.join("solo.md");
std::fs::write(
&agent_path,
"---\nname: Solo\ndescription: Built in\n---\n\nYou are Solo.",
)
.unwrap();
let sources = list_sources_with_roots(
Some(SourceType::Agent),
None,
false,
&[SourceRoot::read_only(root.clone())],
)
.unwrap();
let solo = sources.iter().find(|source| source.name == "Solo").unwrap();
assert!(!solo.writable);
assert!(solo.global);
assert_eq!(solo.path, agent_path.to_string_lossy());
let err = update_source_with_roots(
SourceType::Agent,
&solo.path,
"Solo",
"Built in",
"Updated",
UpdateSourceOptions {
properties: None,
additional_roots: &[SourceRoot::read_only(root.canonicalize().unwrap())],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("read-only"));
let err = delete_source_with_roots(
SourceType::Agent,
&solo.path,
&[SourceRoot::read_only(root.canonicalize().unwrap())],
)
.unwrap_err();
assert!(format!("{:?}", err).contains("read-only"));
}
#[test]
fn create_list_update_delete_project_skill() {
let tmp = TempDir::new().unwrap();
@@ -805,13 +1276,16 @@ mod tests {
let listed = list_sources(Some(SourceType::Skill), Some(project), false).unwrap();
assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global));
let updated = update_source(
let updated = update_source_with_roots(
SourceType::Skill,
created.path.as_str(),
"my-skill",
"now does a different thing",
"step three",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap();
assert_eq!(updated.description, "now does a different thing");
@@ -946,13 +1420,16 @@ mod tests {
)
.unwrap();
let updated = update_source(
let updated = update_source_with_roots(
SourceType::Skill,
claude_skill_dir.to_str().unwrap(),
"portable",
"updated description",
"updated body",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap();
@@ -1001,13 +1478,16 @@ mod tests {
.join(".goose")
.join("skills")
.join("no-such-skill");
let err = update_source(
let err = update_source_with_roots(
SourceType::Skill,
missing_dir.to_str().unwrap(),
"no-such-skill",
"d",
"c",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("not found"));
@@ -1109,19 +1589,32 @@ mod tests {
.unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = update_source(
let err = update_source_with_roots(
SourceType::BuiltinSkill,
"builtin://skills/x",
"x",
"d",
"c",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = update_source(SourceType::Recipe, "x", "x", "d", "c", Some(HashMap::new()))
.unwrap_err();
let err = update_source_with_roots(
SourceType::Recipe,
"x",
"x",
"d",
"c",
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("not supported"));
let err = delete_source(SourceType::BuiltinSkill, "builtin://skills/x").unwrap_err();
@@ -1173,13 +1666,16 @@ mod tests {
.unwrap();
let skill_dir = tmp.path().join(".agents").join("skills").join("my-dir");
let updated = update_source(
let updated = update_source_with_roots(
SourceType::Skill,
skill_dir.to_str().unwrap(),
"my-dir",
"new description",
"new body",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap();
// Name is derived from the frontmatter written by create_source
@@ -1268,13 +1764,16 @@ mod tests {
.unwrap();
let attempted_escape = project.join(".goose").join("escaped");
let err = update_source(
let err = update_source_with_roots(
SourceType::Skill,
attempted_escape.to_str().unwrap(),
"escaped",
"new description",
"new content",
Some(HashMap::new()),
UpdateSourceOptions {
properties: Some(HashMap::new()),
additional_roots: &[],
},
)
.unwrap_err();
assert!(format!("{:?}", err).contains("not found"));
+8 -7
View File
@@ -10,7 +10,7 @@ use agent_client_protocol::schema::{
};
use async_trait::async_trait;
use fs_err as fs;
use goose::acp::server::{serve, AcpProviderFactory, GooseAcpAgent};
use goose::acp::server::{serve, AcpProviderFactory, GooseAcpAgent, GooseAcpAgentOptions};
pub use goose::acp::{map_permission_response, PermissionDecision};
use goose::agents::GoosePlatform;
use goose::builtin_extension::register_builtin_extensions;
@@ -186,15 +186,16 @@ pub async fn spawn_acp_server_in_process(
})
});
let agent = GooseAcpAgent::new(
let agent = GooseAcpAgent::new(GooseAcpAgentOptions {
provider_factory,
builtins.to_vec(),
data_root.to_path_buf(),
data_root.to_path_buf(),
builtins: builtins.to_vec(),
data_dir: data_root.to_path_buf(),
config_dir: data_root.to_path_buf(),
goose_mode,
disable_session_naming,
GoosePlatform::GooseCli,
)
goose_platform: GoosePlatform::GooseCli,
additional_source_roots: Vec::new(),
})
.await
.unwrap();
let agent = Arc::new(agent);