Agents crud (#9084)
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};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user