diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 96ddf465c6dd..989923d8bc9e 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -597,6 +597,7 @@ pub enum SourceType { Recipe, Subrecipe, Agent, + Project, } impl std::fmt::Display for SourceType { @@ -607,6 +608,7 @@ impl std::fmt::Display for SourceType { SourceType::Recipe => write!(f, "recipe"), SourceType::Subrecipe => write!(f, "subrecipe"), SourceType::Agent => write!(f, "agent"), + SourceType::Project => write!(f, "project"), } } } @@ -621,9 +623,11 @@ pub struct SourceEntry { pub name: String, pub description: String, pub content: String, - /// Absolute path to the source on disk. A directory for skills, a file for - /// recipes and agents. - pub directory: String, + /// Stable on-disk path identifying this source. Pass it back to + /// update/delete/export to operate on this entry. The shape varies by + /// source type: skills use the directory containing `SKILL.md`, projects + /// and recipes use the markdown file path itself. + pub path: String, /// True when the source lives in the user's global sources directory; false /// when it lives inside a specific project. pub global: bool, @@ -631,6 +635,10 @@ pub struct SourceEntry { /// Only skills currently populate this; empty for other source types. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub supporting_files: Vec, + /// Arbitrary key/value pairs for type-specific metadata (e.g. icon, color, + /// preferredProvider for projects). Stored in the frontmatter. + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub properties: std::collections::HashMap, } impl SourceEntry { @@ -659,6 +667,14 @@ pub struct CreateSourceRequest { /// Absolute path to the project root. Required when `global` is false. #[serde(default, skip_serializing_if = "Option::is_none")] pub project_dir: Option, + /// Project source ID. When set with `global: false`, the backend resolves + /// the project's first working directory automatically. Takes precedence + /// over `project_dir`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_id: Option, + /// Arbitrary key/value metadata. + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub properties: std::collections::HashMap, } #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] @@ -680,6 +696,10 @@ pub struct ListSourcesRequest { pub source_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub project_dir: Option, + /// When true, also scan the working directories of all known projects for + /// project-scoped sources (e.g. skills stored under `{workingDir}/.agents/skills/`). + #[serde(default)] + pub include_project_sources: bool, } #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] @@ -699,6 +719,8 @@ pub struct UpdateSourceRequest { pub name: String, pub description: String, pub content: String, + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub properties: std::collections::HashMap, } #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 1c27c87a1eca..df2ab20738a2 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -1480,6 +1480,18 @@ "null" ], "description": "Absolute path to the project root. Required when `global` is false." + }, + "projectId": { + "type": [ + "string", + "null" + ], + "description": "Project source ID. When set with `global: false`, the backend resolves\nthe project's first working directory automatically. Takes precedence\nover `project_dir`." + }, + "properties": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary key/value metadata." } }, "required": [ @@ -1500,7 +1512,8 @@ "builtinSkill", "recipe", "subrecipe", - "agent" + "agent", + "project" ], "description": "The type of source entity." }, @@ -1532,9 +1545,9 @@ "content": { "type": "string" }, - "directory": { + "path": { "type": "string", - "description": "Absolute path to the source on disk. A directory for skills, a file for\nrecipes and agents." + "description": "Stable on-disk path identifying this source. Pass it back to\nupdate/delete/export to operate on this entry. The shape varies by\nsource type: skills use the directory containing `SKILL.md`, projects\nand recipes use the markdown file path itself." }, "global": { "type": "boolean", @@ -1546,6 +1559,11 @@ "type": "string" }, "description": "Paths (absolute) of additional files that live alongside the source.\nOnly skills currently populate this; empty for other source types." + }, + "properties": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary key/value pairs for type-specific metadata (e.g. icon, color,\npreferredProvider for projects). Stored in the frontmatter." } }, "required": [ @@ -1553,7 +1571,7 @@ "name", "description", "content", - "directory", + "path", "global" ], "description": "A source discovered by Goose and backed by an on-disk path. Sources may be\neither `global` (shared across all projects) or project-specific." @@ -1576,6 +1594,11 @@ "string", "null" ] + }, + "includeProjectSources": { + "type": "boolean", + "description": "When true, also scan the working directories of all known projects for\nproject-scoped sources (e.g. skills stored under `{workingDir}/.agents/skills/`).", + "default": false } }, "description": "List discovered sources.\n\nToday this endpoint only returns skills. If `type` is omitted, it defaults\nto listing skill sources. Both global and project-scoped skills are included\nwhen `project_dir` is set.", @@ -1615,6 +1638,11 @@ }, "content": { "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary key/value metadata. When non-empty, replaces all existing\nproperties on the source. Used for type-specific fields like project\nicon/color/workingDirs." } }, "required": [ diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 64af66d7f796..9a931ef65f45 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -2502,7 +2502,7 @@ impl GooseAcpAgent { }) } - async fn update_thread_metadata( + pub(super) async fn update_thread_metadata( &self, thread_id: &str, f: impl FnOnce(&mut crate::session::ThreadMetadata), diff --git a/crates/goose/src/acp/server/sources.rs b/crates/goose/src/acp/server/sources.rs index 9dbfd738dae2..36ebb5f0eb04 100644 --- a/crates/goose/src/acp/server/sources.rs +++ b/crates/goose/src/acp/server/sources.rs @@ -5,13 +5,26 @@ impl GooseAcpAgent { &self, req: CreateSourceRequest, ) -> Result { + let project_dir = match (&req.project_id, &req.project_dir) { + (Some(pid), _) if !req.global => { + let dirs = crate::sources::project_working_dirs(pid); + Some(dirs.into_iter().next().ok_or_else(|| { + sacp::Error::invalid_params().data(format!( + "Project \"{pid}\" has no working directories configured" + )) + })?) + } + (_, Some(pd)) => Some(pd.clone()), + _ => None, + }; let source = crate::sources::create_source( req.source_type, &req.name, &req.description, &req.content, req.global, - req.project_dir.as_deref(), + project_dir.as_deref(), + req.properties, )?; Ok(CreateSourceResponse { source }) } @@ -20,7 +33,11 @@ impl GooseAcpAgent { &self, req: ListSourcesRequest, ) -> Result { - let sources = crate::sources::list_sources(req.source_type, req.project_dir.as_deref())?; + let sources = crate::sources::list_sources( + req.source_type, + req.project_dir.as_deref(), + req.include_project_sources, + )?; Ok(ListSourcesResponse { sources }) } @@ -34,6 +51,7 @@ impl GooseAcpAgent { &req.name, &req.description, &req.content, + req.properties, )?; Ok(UpdateSourceResponse { source }) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 57d8da542c91..bc67fc4d5991 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -356,6 +356,24 @@ impl Agent { messages } + async fn load_project_instructions(&self, session: &Session) -> Option { + let thread_id = session.thread_id.as_deref()?; + let thread_mgr = + crate::session::ThreadManager::new(self.config.session_manager.storage().clone()); + let thread = thread_mgr.get_thread(thread_id).await.ok()?; + let project_id = thread.metadata.project_id.as_deref()?; + let entry = crate::sources::read_project(project_id).ok()?; + let mut parts = Vec::new(); + parts.push(format!("# Project: {}", entry.name)); + if !entry.description.is_empty() { + parts.push(entry.description.clone()); + } + if !entry.content.is_empty() { + parts.push(entry.content.clone()); + } + Some(parts.join("\n\n")) + } + async fn prepare_reply_context( &self, session_id: &str, @@ -1247,6 +1265,11 @@ impl Agent { goose_mode, initial_messages, } = context; + + if let Some(project_addendum) = self.load_project_instructions(&session).await { + system_prompt = format!("{system_prompt}\n\n{project_addendum}"); + } + self.reset_retry_attempts().await; let provider = self.provider().await?; diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index 68eb229e90ee..9ef3536800fc 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -125,9 +125,10 @@ fn parse_agent_content(content: &str, path: &Path) -> Option { name: metadata.name, description, content: body, - directory: path.to_string_lossy().into_owned(), + path: path.to_string_lossy().into_owned(), global: false, supporting_files: Vec::new(), + properties: std::collections::HashMap::new(), }) } @@ -171,9 +172,10 @@ fn scan_recipes_from_dir( name, description: recipe.description.clone(), content: recipe.instructions.clone().unwrap_or_default(), - directory: path.to_string_lossy().into_owned(), + path: path.to_string_lossy().into_owned(), global: false, supporting_files: Vec::new(), + properties: std::collections::HashMap::new(), }); } Err(e) => { @@ -598,9 +600,10 @@ impl SummonClient { name: sr.name.clone(), description, content: String::new(), - directory: sr.path.clone(), + path: sr.path.clone(), global: false, supporting_files: Vec::new(), + properties: std::collections::HashMap::new(), }); } } @@ -1160,7 +1163,7 @@ impl SummonClient { } } - let recipe_file = load_local_recipe_file(&source.directory) + let recipe_file = load_local_recipe_file(&source.path) .map_err(|e| format!("Failed to load recipe '{}': {}", source.name, e))?; let param_values: Vec<(String, String)> = params @@ -1193,10 +1196,10 @@ impl SummonClient { source: &SourceEntry, params: &DelegateParams, ) -> Result { - let agent_content = if source.directory.is_empty() { + let agent_content = if source.path.is_empty() { return Err("Agent source has no path".to_string()); } else { - std::fs::read_to_string(&source.directory) + std::fs::read_to_string(&source.path) .map_err(|e| format!("Failed to read agent file: {}", e))? }; diff --git a/crates/goose/src/skills/client.rs b/crates/goose/src/skills/client.rs index c7f82af310a6..888dfd45d875 100644 --- a/crates/goose/src/skills/client.rs +++ b/crates/goose/src/skills/client.rs @@ -36,7 +36,7 @@ impl SkillsClient { s.source_type == SourceType::Skill || s.source_type == SourceType::BuiltinSkill }) .collect(); - skills.sort_by(|a, b| (&a.name, &a.directory).cmp(&(&b.name, &b.directory))); + skills.sort_by(|a, b| (&a.name, &a.path).cmp(&(&b.name, &b.path))); if !skills.is_empty() { instructions.push_str( @@ -131,10 +131,10 @@ impl McpClientTrait for SkillsClient { ); if !skill.supporting_files.is_empty() { - let skill_dir = Path::new(&skill.directory); + let skill_dir = Path::new(&skill.path); output.push_str(&format!( "\n## Supporting Files\n\nSkill directory: {}\n\n", - skill.directory + skill.path )); for file in &skill.supporting_files { if let Ok(relative) = Path::new(file).strip_prefix(skill_dir) { @@ -157,7 +157,7 @@ impl McpClientTrait for SkillsClient { s.name == parent_skill_name && matches!(s.source_type, SourceType::Skill | SourceType::BuiltinSkill) }) { - let skill_dir = PathBuf::from(&skill.directory); + let skill_dir = PathBuf::from(&skill.path); let canonical_skill_dir = skill_dir .canonicalize() .unwrap_or_else(|_| skill_dir.clone()); diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index b337b4e4cbd3..1d7b68400cae 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -245,9 +245,10 @@ fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option/.agents/skills/`). Projects live in `/projects/.md`. +use crate::config::paths::Paths; use crate::skills::{ build_skill_md, discover_skills, infer_skill_name, is_global_skill_dir, parse_skill_frontmatter, resolve_discoverable_skill_dir, resolve_skill_dir, skill_base_dir, @@ -9,7 +12,8 @@ use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use sacp::Error; use serde::Deserialize; -use std::path::PathBuf; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; pub fn parse_frontmatter Deserialize<'de>>( content: &str, @@ -26,35 +30,226 @@ pub fn parse_frontmatter Deserialize<'de>>( Ok(Some((metadata, body))) } -fn require_skill_type(source_type: SourceType) -> Result<(), Error> { - if source_type != SourceType::Skill { +fn require_mutable_type(source_type: SourceType) -> Result<(), Error> { + match source_type { + SourceType::Skill | SourceType::Project => Ok(()), + other => Err(Error::invalid_params().data(format!( + "Source type '{other}' is not supported for mutation." + ))), + } +} + +// --- Project helpers --- + +#[derive(Deserialize)] +struct ProjectFront { + #[serde(default)] + name: String, + #[serde(default)] + description: String, + #[serde(default, flatten)] + properties: HashMap, +} + +fn projects_dir() -> PathBuf { + Paths::data_dir().join("projects") +} + +fn project_file_path(slug: &str) -> PathBuf { + projects_dir().join(format!("{slug}.md")) +} + +fn build_project_md( + name: &str, + description: &str, + content: &str, + properties: &HashMap, +) -> String { + let mut fm = serde_yaml::Mapping::new(); + fm.insert( + serde_yaml::Value::String("name".into()), + serde_yaml::Value::String(name.into()), + ); + fm.insert( + serde_yaml::Value::String("description".into()), + serde_yaml::Value::String(description.into()), + ); + for (k, v) in properties { + if k == "name" || k == "description" { + continue; + } + if let Ok(yv) = serde_yaml::to_value(v) { + fm.insert(serde_yaml::Value::String(k.clone()), yv); + } + } + let yaml = serde_yaml::to_string(&fm).unwrap_or_default(); + let mut md = format!("---\n{yaml}---\n"); + if !content.is_empty() { + md.push('\n'); + md.push_str(content); + md.push('\n'); + } + md +} + +/// Returns (display_name, description, body, properties). +fn parse_project_frontmatter( + raw: &str, +) -> (String, String, String, HashMap) { + if !raw.trim_start().starts_with("---") { + return ( + String::new(), + String::new(), + raw.to_string(), + HashMap::new(), + ); + } + match parse_frontmatter::(raw) { + Ok(Some((meta, body))) => (meta.name, meta.description, body, meta.properties), + _ => ( + String::new(), + String::new(), + raw.to_string(), + HashMap::new(), + ), + } +} + +/// Validate a project slug. Same shape as a skill name (kebab-case, ASCII). +fn validate_project_slug(slug: &str) -> Result<(), Error> { + validate_skill_name(slug) +} + +fn project_entry_from_file(file: &Path) -> Option { + let slug = file.file_stem().and_then(|s| s.to_str())?.to_string(); + if slug.is_empty() { + return None; + } + let raw = fs::read_to_string(file).ok()?; + let (title, description, content, mut properties) = parse_project_frontmatter(&raw); + let display_name = if title.is_empty() { + slug.clone() + } else { + title + }; + if display_name != slug { + // Preserve the user-facing display name so the frontend doesn't have + // to special-case slug vs title. + properties.insert( + "title".into(), + serde_json::Value::String(display_name.clone()), + ); + } + Some(SourceEntry { + source_type: SourceType::Project, + name: slug, + description, + content, + path: file.to_string_lossy().into_owned(), + global: true, + supporting_files: Vec::new(), + properties, + }) +} + +/// Read all projects from `/projects/`. +fn read_project_dir() -> Result, Error> { + let dir = projects_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + let entries = fs::read_dir(&dir) + .map_err(|e| Error::internal_error().data(format!("Failed to read projects dir: {e}")))?; + + let mut out = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + if let Some(entry) = project_entry_from_file(&path) { + out.push(entry); + } + } + Ok(out) +} + +/// Read a single project source by slug. +pub fn read_project(slug: &str) -> Result { + validate_project_slug(slug)?; + let file = project_file_path(slug); + if !file.exists() { + return Err(Error::invalid_params().data(format!("Project \"{}\" not found", slug))); + } + project_entry_from_file(&file) + .ok_or_else(|| Error::internal_error().data("Failed to read project file")) +} + +/// Get the working directories configured for a project, if any. +/// Returns an empty Vec when the project doesn't exist or has none configured. +pub fn project_working_dirs(slug: &str) -> Vec { + let entry = match read_project(slug) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + entry + .properties + .get("workingDirs") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default() +} + +/// Validate that the given path is a project file we manage and the file +/// exists. Returns the canonical path on success. +fn resolve_project_path(path: &str) -> Result { + let canonical_path = Path::new(path).canonicalize().map_err(|_| { + Error::invalid_params().data(format!("Project source \"{}\" not found", path)) + })?; + let canonical_root = projects_dir() + .canonicalize() + .unwrap_or_else(|_| projects_dir()); + if !canonical_path.starts_with(&canonical_root) { return Err(Error::invalid_params().data(format!( - "Source type '{}' is not supported. Only 'skill' is currently supported.", - source_type + "Path \"{}\" is not a project source", + canonical_path.display() ))); } - Ok(()) + if canonical_path.extension().and_then(|e| e.to_str()) != Some("md") { + return Err( + Error::invalid_params().data(format!("Path \"{}\" is not a markdown file", path)) + ); + } + if !canonical_path.is_file() { + return Err( + Error::invalid_params().data(format!("Project source \"{}\" not found", path)) + ); + } + Ok(canonical_path) } -fn source_entry( - source_type: SourceType, +// --- SourceEntry construction --- + +fn skill_source_entry( name: &str, description: &str, content: &str, - dir: &std::path::Path, + dir: &Path, global: bool, ) -> SourceEntry { SourceEntry { - source_type, + source_type: SourceType::Skill, name: name.to_string(), description: description.to_string(), content: content.to_string(), - directory: dir.to_string_lossy().to_string(), + path: dir.to_string_lossy().to_string(), global, supporting_files: Vec::new(), + properties: HashMap::new(), } } +// --- Public CRUD --- + pub fn create_source( source_type: SourceType, name: &str, @@ -62,33 +257,57 @@ pub fn create_source( content: &str, global: bool, project_dir: Option<&str>, + properties: HashMap, ) -> Result { - require_skill_type(source_type)?; - validate_skill_name(name)?; - let dir = skill_base_dir(global, project_dir)?.join(name); - - if dir.exists() { - return Err( - Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) - ); + require_mutable_type(source_type)?; + + match source_type { + SourceType::Skill => { + validate_skill_name(name)?; + let dir = skill_base_dir(global, project_dir)?.join(name); + + if dir.exists() { + return Err(Error::invalid_params() + .data(format!("A source named \"{}\" already exists", name))); + } + + fs::create_dir_all(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to create source directory: {e}")) + })?; + let file_path = dir.join("SKILL.md"); + let md = build_skill_md(name, description, content); + fs::write(&file_path, md).map_err(|e| { + Error::internal_error().data(format!("Failed to write SKILL.md: {e}")) + })?; + + Ok(skill_source_entry(name, description, content, &dir, global)) + } + SourceType::Project => { + validate_project_slug(name)?; + let base = projects_dir(); + fs::create_dir_all(&base).map_err(|e| { + Error::internal_error().data(format!("Failed to create projects dir: {e}")) + })?; + let file = project_file_path(name); + if file.exists() { + return Err(Error::invalid_params() + .data(format!("A source named \"{}\" already exists", name))); + } + // The display name comes from `properties.title`; if absent, the + // file's frontmatter `name:` is the slug itself. + let display_name = properties + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(name); + let md = build_project_md(display_name, description, content, &properties); + fs::write(&file, md).map_err(|e| { + Error::internal_error().data(format!("Failed to write project file: {e}")) + })?; + project_entry_from_file(&file) + .ok_or_else(|| Error::internal_error().data("Failed to read newly created project")) + } + _ => unreachable!("guarded by require_mutable_type"), } - - fs::create_dir_all(&dir).map_err(|e| { - Error::internal_error().data(format!("Failed to create source directory: {e}")) - })?; - let file_path = dir.join("SKILL.md"); - let md = build_skill_md(name, description, content); - fs::write(&file_path, md) - .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; - - Ok(source_entry( - source_type, - name, - description, - content, - &dir, - global, - )) } pub fn update_source( @@ -97,104 +316,266 @@ pub fn update_source( name: &str, description: &str, content: &str, + properties: HashMap, ) -> Result { - require_skill_type(source_type)?; - validate_skill_name(name)?; - - let dir = resolve_discoverable_skill_dir(path)?; - let current_dir_name = dir - .file_name() - .and_then(|value| value.to_str()) - .ok_or_else(|| Error::internal_error().data("Failed to resolve source directory name"))?; - - let target_dir = if name == current_dir_name { - dir.clone() - } else { - let base_dir = dir.parent().ok_or_else(|| { - Error::internal_error().data("Failed to resolve source base directory") - })?; - let target_dir = base_dir.join(name); - - if target_dir.exists() { - return Err( - Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) - ); + require_mutable_type(source_type)?; + + match source_type { + SourceType::Skill => { + validate_skill_name(name)?; + + let dir = resolve_discoverable_skill_dir(path)?; + let current_dir_name = dir.file_name().and_then(|value| value.to_str()).ok_or_else( + || Error::internal_error().data("Failed to resolve source directory name"), + )?; + + let target_dir = if name == current_dir_name { + dir.clone() + } else { + let base_dir = dir.parent().ok_or_else(|| { + Error::internal_error().data("Failed to resolve source base directory") + })?; + let target_dir = base_dir.join(name); + + if target_dir.exists() { + return Err(Error::invalid_params() + .data(format!("A source named \"{}\" already exists", name))); + } + + fs::rename(&dir, &target_dir).map_err(|e| { + Error::internal_error() + .data(format!("Failed to rename source directory: {e}")) + })?; + + target_dir + }; + + let file_path = target_dir.join("SKILL.md"); + let md = build_skill_md(name, description, content); + fs::write(&file_path, md).map_err(|e| { + Error::internal_error().data(format!("Failed to write SKILL.md: {e}")) + })?; + + // Skills don't carry user-defined properties yet; ignore the + // incoming bag rather than silently dropping it elsewhere. + let _ = properties; + + Ok(skill_source_entry( + name, + description, + content, + &target_dir, + is_global_skill_dir(&target_dir), + )) } - - fs::rename(&dir, &target_dir).map_err(|e| { - Error::internal_error().data(format!("Failed to rename source directory: {e}")) - })?; - - target_dir - }; - - let file_path = target_dir.join("SKILL.md"); - let md = build_skill_md(name, description, content); - fs::write(&file_path, md) - .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; - - Ok(source_entry( - source_type, - name, - description, - content, - &target_dir, - is_global_skill_dir(&target_dir), - )) + SourceType::Project => { + 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()) + .ok_or_else(|| Error::internal_error().data("Bad project filename"))?; + if current_slug != name { + return Err(Error::invalid_params().data(format!( + "Project slug cannot be changed (current: \"{}\", requested: \"{}\")", + current_slug, name + ))); + } + + let display_name = properties + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(name); + let md = build_project_md(display_name, description, content, &properties); + fs::write(&file, md).map_err(|e| { + Error::internal_error().data(format!("Failed to write project file: {e}")) + })?; + project_entry_from_file(&file) + .ok_or_else(|| Error::internal_error().data("Failed to read updated project")) + } + _ => unreachable!("guarded by require_mutable_type"), + } } pub fn delete_source(source_type: SourceType, path: &str) -> Result<(), Error> { - require_skill_type(source_type)?; - let dir = resolve_skill_dir(path)?; - fs::remove_dir_all(&dir) - .map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?; + require_mutable_type(source_type)?; + + match source_type { + SourceType::Skill => { + let dir = resolve_skill_dir(path)?; + fs::remove_dir_all(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to delete source: {e}")) + })?; + } + SourceType::Project => { + let file = resolve_project_path(path)?; + fs::remove_file(&file).map_err(|e| { + Error::internal_error().data(format!("Failed to delete project: {e}")) + })?; + } + _ => unreachable!("guarded by require_mutable_type"), + } Ok(()) } pub fn list_sources( source_type: Option, project_dir: Option<&str>, + include_project_sources: bool, ) -> Result, Error> { - if let Some(t) = source_type { - require_skill_type(t)?; - } - - let working_dir = project_dir - .map(str::trim) - .filter(|p| !p.is_empty()) - .map(PathBuf::from); + let kinds: Vec = match source_type { + Some(t) => vec![t], + None => vec![SourceType::Skill, SourceType::Project], + }; - let mut sources: Vec = discover_skills(working_dir.as_deref()) - .into_iter() - .filter(|s| s.source_type == SourceType::Skill) - .collect(); + let mut sources = Vec::new(); + for kind in kinds { + match kind { + SourceType::Skill => { + let working_dir = project_dir + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(PathBuf::from); + sources.extend( + discover_skills(working_dir.as_deref()) + .into_iter() + .filter(|s| s.source_type == SourceType::Skill), + ); + + if include_project_sources { + let projects = read_project_dir()?; + let already_scanned = working_dir.as_deref(); + for proj in &projects { + let dirs = proj + .properties + .get("workingDirs") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default(); + let project_name = proj + .properties + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(&proj.name); + for wd in &dirs { + let wd_path = PathBuf::from(wd); + if Some(wd_path.as_path()) == already_scanned { + continue; + } + for skill in discover_skills(Some(&wd_path)) { + if skill.source_type != SourceType::Skill || skill.global { + continue; + } + let mut tagged = skill; + tagged.properties.insert( + "projectName".into(), + serde_json::Value::String(project_name.to_string()), + ); + tagged.properties.insert( + "projectDir".into(), + serde_json::Value::String(wd.clone()), + ); + sources.push(tagged); + } + } + } + } + } + SourceType::BuiltinSkill => { + let working_dir = project_dir + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(PathBuf::from); + sources.extend( + discover_skills(working_dir.as_deref()) + .into_iter() + .filter(|s| s.source_type == SourceType::BuiltinSkill), + ); + } + SourceType::Project => { + sources.extend(read_project_dir()?); + } + SourceType::Recipe | SourceType::Subrecipe | SourceType::Agent => { + return Err(Error::invalid_params().data(format!( + "Source type '{}' listing is not supported.", + kind + ))); + } + } + } sources.sort_by(|a, b| a.name.cmp(&b.name)); Ok(sources) } pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, String), Error> { - require_skill_type(source_type)?; - let dir = resolve_discoverable_skill_dir(path)?; - - let md = dir.join("SKILL.md"); - let raw = fs::read_to_string(&md) - .map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?; - let (description, content) = parse_skill_frontmatter(&raw); - - let name = infer_skill_name(&dir); - - let export = serde_json::json!({ - "version": 1, - "type": "skill", - "name": name, - "description": description, - "content": 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!("{}.skill.json", name); - Ok((json, filename)) + match source_type { + SourceType::Skill => { + let dir = resolve_discoverable_skill_dir(path)?; + + let md = dir.join("SKILL.md"); + let raw = fs::read_to_string(&md).map_err(|e| { + Error::internal_error().data(format!("Failed to read SKILL.md: {e}")) + })?; + let (description, content) = parse_skill_frontmatter(&raw); + + let name = infer_skill_name(&dir); + + let export = serde_json::json!({ + "version": 1, + "type": "skill", + "name": name, + "description": description, + "content": 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!("{}.skill.json", name); + Ok((json, filename)) + } + SourceType::Project => { + let file = resolve_project_path(path)?; + let raw = fs::read_to_string(&file).map_err(|e| { + Error::internal_error().data(format!("Failed to read project file: {e}")) + })?; + let (title, description, content, properties) = parse_project_frontmatter(&raw); + let slug = file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let display_name = if title.is_empty() { + slug.clone() + } else { + title + }; + + let mut export = serde_json::json!({ + "version": 1, + "type": "project", + "name": slug, + "title": display_name, + "description": description, + "content": content, + }); + if !properties.is_empty() { + export["properties"] = serde_json::to_value(&properties).unwrap_or_default(); + } + let json = serde_json::to_string_pretty(&export).map_err(|e| { + Error::internal_error().data(format!("Failed to serialize project: {e}")) + })?; + let filename = format!("{}.project.json", slug); + Ok((json, filename)) + } + _ => Err(Error::invalid_params().data(format!( + "Source type '{}' export is not supported.", + source_type + ))), + } } pub fn import_sources( @@ -215,15 +596,16 @@ pub fn import_sources( ); } - match value + let type_str = value .get("type") .and_then(|v| v.as_str()) - .unwrap_or("skill") - { - "skill" => {} + .unwrap_or("skill"); + let source_type = match type_str { + "skill" => SourceType::Skill, + "project" => SourceType::Project, other => { return Err(Error::invalid_params().data(format!( - "Source type '{}' is not supported. Only 'skill' is currently supported.", + "Source type '{}' import is not supported.", other ))); } @@ -238,12 +620,13 @@ pub fn import_sources( return Err(Error::invalid_params().data("Source name must not be empty")); } + // Skills require a description; projects can omit it. let description = value .get("description") .and_then(|v| v.as_str()) - .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"description\" field"))? + .unwrap_or("") .to_string(); - if description.is_empty() { + if source_type == SourceType::Skill && description.is_empty() { return Err(Error::invalid_params().data("Source description must not be empty")); } @@ -254,43 +637,88 @@ pub fn import_sources( .unwrap_or("") .to_string(); - validate_skill_name(&name)?; - - let base = skill_base_dir(global, project_dir)?; - let mut final_name = name.clone(); - if base.join(&final_name).exists() { - final_name = format!("{}-imported", name); - let mut counter = 2u32; - while base.join(&final_name).exists() { - final_name = format!("{}-imported-{}", name, counter); - counter += 1; + let mut properties: HashMap = value + .get("properties") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + // The export's top-level "title" wins over a properties.title if both + // exist. + if source_type == SourceType::Project { + if let Some(title) = value.get("title").and_then(|v| v.as_str()) { + if !title.is_empty() { + properties.insert("title".into(), serde_json::Value::String(title.into())); + } } } - let dir = base.join(&final_name); - fs::create_dir_all(&dir).map_err(|e| { - Error::internal_error().data(format!("Failed to create source directory: {e}")) - })?; - let file_path = dir.join("SKILL.md"); - let md = build_skill_md(&final_name, &description, &content); - fs::write(&file_path, md) - .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; - - Ok(vec![source_entry( - SourceType::Skill, - &final_name, - &description, - &content, - &dir, - global, - )]) + match source_type { + SourceType::Skill => { + validate_skill_name(&name)?; + let base = skill_base_dir(global, project_dir)?; + let mut final_name = name.clone(); + if base.join(&final_name).exists() { + final_name = format!("{}-imported", name); + let mut counter = 2u32; + while base.join(&final_name).exists() { + final_name = format!("{}-imported-{}", name, counter); + counter += 1; + } + } + create_source( + SourceType::Skill, + &final_name, + &description, + &content, + global, + project_dir, + HashMap::new(), + ) + .map(|entry| vec![entry]) + } + SourceType::Project => { + validate_project_slug(&name)?; + let mut final_name = name.clone(); + if project_file_path(&final_name).exists() { + final_name = format!("{}-imported", name); + let mut counter = 2u32; + while project_file_path(&final_name).exists() { + final_name = format!("{}-imported-{}", name, counter); + counter += 1; + } + } + create_source( + SourceType::Project, + &final_name, + &description, + &content, + true, // projects are always global + None, + properties, + ) + .map(|entry| vec![entry]) + } + _ => unreachable!(), + } } #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; use tempfile::TempDir; + // Tests that set GOOSE_PATH_ROOT must run serially because it's a global + // env var. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_temp_root(f: impl FnOnce(&Path)) { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + unsafe { std::env::set_var("GOOSE_PATH_ROOT", tmp.path()) }; + f(tmp.path()); + unsafe { std::env::remove_var("GOOSE_PATH_ROOT") }; + } + #[test] fn skill_name_validation() { assert!(validate_skill_name("my-skill").is_ok()); @@ -317,28 +745,30 @@ mod tests { "step one\nstep two", false, Some(project), + HashMap::new(), ) .unwrap(); assert_eq!(created.name, "my-skill"); assert!(!created.global); - let dir = PathBuf::from(&created.directory); + let dir = PathBuf::from(&created.path); assert!(dir.join("SKILL.md").exists()); - let listed = list_sources(Some(SourceType::Skill), Some(project)).unwrap(); + 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( SourceType::Skill, - created.directory.as_str(), + created.path.as_str(), "my-skill", "now does a different thing", "step three", + HashMap::new(), ) .unwrap(); assert_eq!(updated.description, "now does a different thing"); assert_eq!(updated.name, "my-skill"); - delete_source(SourceType::Skill, created.directory.as_str()).unwrap(); + delete_source(SourceType::Skill, created.path.as_str()).unwrap(); assert!(!dir.exists()); } @@ -347,15 +777,41 @@ mod tests { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); - create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap(); - let err = - create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap_err(); + create_source( + SourceType::Skill, + "dup", + "d", + "c", + false, + Some(project), + HashMap::new(), + ) + .unwrap(); + let err = create_source( + SourceType::Skill, + "dup", + "d", + "c", + false, + Some(project), + HashMap::new(), + ) + .unwrap_err(); assert!(format!("{:?}", err).contains("already exists")); } #[test] fn project_scope_requires_project_dir() { - let err = create_source(SourceType::Skill, "x", "d", "c", false, None).unwrap_err(); + let err = create_source( + SourceType::Skill, + "x", + "d", + "c", + false, + None, + HashMap::new(), + ) + .unwrap_err(); assert!(format!("{:?}", err).contains("projectDir")); } @@ -374,6 +830,7 @@ mod tests { "body goes here", false, Some(project_a.to_str().unwrap()), + HashMap::new(), ) .unwrap(); @@ -401,15 +858,18 @@ mod tests { ) .unwrap(); - let listed = - list_sources(Some(SourceType::Skill), Some(project.to_str().unwrap())).unwrap(); + let listed = list_sources( + Some(SourceType::Skill), + Some(project.to_str().unwrap()), + false, + ) + .unwrap(); let exported_skill = listed .iter() .find(|skill| skill.name == "portable") .expect("expected listed skill"); - let (json, filename) = - export_source(SourceType::Skill, exported_skill.directory.as_str()).unwrap(); + let (json, filename) = export_source(SourceType::Skill, exported_skill.path.as_str()).unwrap(); assert_eq!(filename, "portable.skill.json"); assert!(json.contains("\"name\": \"portable\"")); } @@ -432,6 +892,7 @@ mod tests { "portable", "updated description", "updated body", + HashMap::new(), ) .unwrap(); @@ -449,7 +910,16 @@ mod tests { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); - create_source(SourceType::Skill, "busy", "d", "c", false, Some(project)).unwrap(); + create_source( + SourceType::Skill, + "busy", + "d", + "c", + false, + Some(project), + HashMap::new(), + ) + .unwrap(); let payload = serde_json::json!({ "version": 1, @@ -477,6 +947,7 @@ mod tests { "no-such-skill", "d", "c", + HashMap::new(), ) .unwrap_err(); assert!(format!("{:?}", err).contains("not found")); @@ -495,7 +966,7 @@ mod tests { } #[test] - fn rejects_non_skill_source_type() { + fn rejects_unsupported_source_type_for_mutation() { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); @@ -506,19 +977,18 @@ mod tests { "c", false, Some(project), + HashMap::new(), ) .unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); - let err = update_source(SourceType::Recipe, "x", "x", "d", "c").unwrap_err(); + let err = + update_source(SourceType::Recipe, "x", "x", "d", "c", HashMap::new()).unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); let err = delete_source(SourceType::Subrecipe, "x").unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); - let err = list_sources(Some(SourceType::BuiltinSkill), Some(project)).unwrap_err(); - assert!(format!("{:?}", err).contains("not supported")); - let err = export_source(SourceType::Recipe, "x").unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); } @@ -535,6 +1005,7 @@ mod tests { "body", false, Some(project), + HashMap::new(), ) .unwrap(); @@ -545,6 +1016,7 @@ mod tests { "my-dir", "new description", "new body", + HashMap::new(), ) .unwrap(); // Name is derived from the frontmatter written by create_source @@ -562,13 +1034,17 @@ mod tests { ) .unwrap(); - let listed = - list_sources(Some(SourceType::Skill), Some(tmp.path().to_str().unwrap())).unwrap(); + let listed = list_sources( + Some(SourceType::Skill), + Some(tmp.path().to_str().unwrap()), + false, + ) + .unwrap(); let skill = listed .iter() .find(|source| source.name == "test-skill" && !source.global) .unwrap(); - assert!(skill.directory.contains(".agents/skills")); + assert!(skill.path.contains(".agents/skills")); assert_eq!(skill.description, "from agents"); } @@ -598,17 +1074,21 @@ mod tests { ) .unwrap(); - let listed = - list_sources(Some(SourceType::Skill), Some(tmp.path().to_str().unwrap())).unwrap(); + let listed = list_sources( + Some(SourceType::Skill), + Some(tmp.path().to_str().unwrap()), + false, + ) + .unwrap(); let matching: Vec<_> = listed .iter() .filter(|source| source.name == "shared-skill" && !source.global) .collect(); assert_eq!(matching.len(), 1); - assert!(matching[0].directory.contains(".agents/skills")); + assert!(matching[0].path.contains(".agents/skills")); assert_eq!(matching[0].description, "preferred"); - let exported = export_source(SourceType::Skill, matching[0].directory.as_str()).unwrap(); + let exported = export_source(SourceType::Skill, matching[0].path.as_str()).unwrap(); assert!(exported.0.contains("preferred")); } @@ -631,8 +1111,99 @@ mod tests { "escaped", "new description", "new content", + HashMap::new(), ) .unwrap_err(); assert!(format!("{:?}", err).contains("not found")); } + + #[test] + fn project_create_read_update_delete_roundtrip() { + with_temp_root(|_| { + let mut props = HashMap::new(); + props.insert( + "title".into(), + serde_json::Value::String("My Web App".into()), + ); + props.insert( + "icon".into(), + serde_json::Value::String("\u{1F4C1}".into()), + ); + props.insert( + "workingDirs".into(), + serde_json::json!(["/Users/me/code/web-app"]), + ); + + let created = create_source( + SourceType::Project, + "web-app", + "frontend monorepo", + "Use pnpm. Prefer Vitest.", + true, + None, + props.clone(), + ) + .unwrap(); + assert_eq!(created.name, "web-app"); + assert_eq!(created.source_type, SourceType::Project); + assert!(created.global); + assert_eq!( + created.properties.get("title").and_then(|v| v.as_str()), + Some("My Web App") + ); + + let read = read_project("web-app").unwrap(); + assert_eq!(read.description, "frontend monorepo"); + assert_eq!(read.content, "Use pnpm. Prefer Vitest."); + + let dirs = project_working_dirs("web-app"); + assert_eq!(dirs, vec!["/Users/me/code/web-app".to_string()]); + + let mut new_props = props.clone(); + new_props.insert("color".into(), serde_json::Value::String("#3b82f6".into())); + let updated = update_source( + SourceType::Project, + created.path.as_str(), + "web-app", + "frontend monorepo", + "Updated body", + new_props, + ) + .unwrap(); + assert_eq!(updated.content, "Updated body"); + assert_eq!( + updated.properties.get("color").and_then(|v| v.as_str()), + Some("#3b82f6") + ); + + delete_source(SourceType::Project, created.path.as_str()).unwrap(); + assert!(read_project("web-app").is_err()); + }); + } + + #[test] + fn project_update_rejects_slug_change() { + with_temp_root(|_| { + let created = create_source( + SourceType::Project, + "old-slug", + "d", + "c", + true, + None, + HashMap::new(), + ) + .unwrap(); + let err = update_source( + SourceType::Project, + created.path.as_str(), + "new-slug", + "d", + "c", + HashMap::new(), + ) + .unwrap_err(); + assert!(format!("{:?}", err).contains("slug cannot be changed")); + }); + } } diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index bc6f75bcba08..8365559f6dcf 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -41,9 +41,9 @@ const EXCEPTIONS = { "Shell still coordinates ACP session loading, replay-buffer cleanup on load failure, project reassignment, home-session restoration, app-level chat routing, restored project-draft reuse, and app-level compaction settings deep links. Includes gated [perf:load]/[perf:newtab] logging via perfLog (dev-only by default).", }, "src/features/chat/hooks/useChatSessionController.ts": { - limit: 840, + limit: 850, justification: - "Controller now centralizes home-to-chat pending state transfer, workspace/project preparation, provider/model/persona handoff, Goose cross-provider model selection sequencing with rollback, context-usage readiness resets, queued-target compaction gating, and auto-compaction-aware send orchestration pending a later decomposition pass.", + "Controller now centralizes home-to-chat pending state transfer, workspace/project preparation (including ACP _goose/session/update_project sync for backend system-prompt injection), provider/model/persona handoff, Goose cross-provider model selection sequencing with rollback, context-usage readiness resets, queued-target compaction gating, and auto-compaction-aware send orchestration pending a later decomposition pass.", }, "src/features/chat/hooks/__tests__/useChatSessionController.test.ts": { limit: 520, diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index fec8ccaceb7b..c989d7d829d2 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -7,5 +7,4 @@ pub mod git_changes; pub mod model_setup; pub mod path_resolver; pub mod project_icons; -pub mod projects; pub mod system; diff --git a/ui/goose2/src-tauri/src/commands/projects.rs b/ui/goose2/src-tauri/src/commands/projects.rs deleted file mode 100644 index 31c129c16f91..000000000000 --- a/ui/goose2/src-tauri/src/commands/projects.rs +++ /dev/null @@ -1,508 +0,0 @@ -use serde::Deserialize; -use std::fs; -use std::path::{Path, PathBuf}; - -fn projects_dir() -> Result { - let home = dirs::home_dir().ok_or("Could not determine home directory")?; - Ok(home.join(".goose").join("projects")) -} - -fn generate_id() -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - use std::time::{SystemTime, UNIX_EPOCH}; - - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let mut hasher = DefaultHasher::new(); - nanos.hash(&mut hasher); - std::process::id().hash(&mut hasher); - let h1 = hasher.finish(); - // Hash again with a different seed for more bits - h1.hash(&mut hasher); - let h2 = hasher.finish(); - format!( - "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", - (h1 >> 32) as u32, - (h1 >> 16) as u16, - h1 as u16, - (h2 >> 48) as u16, - h2 & 0xffffffffffff - ) -} - -fn now_timestamp() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - millis.to_string() -} - -fn slugify(name: &str) -> String { - let slug: String = name - .to_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - if slug.is_empty() { - "project".to_string() - } else { - slug - } -} - -/// Scan all project directories and find the one whose project.json has the given id. -/// Returns (dir_path, ProjectInfo). -fn find_project_by_id(id: &str) -> Result<(PathBuf, StoredProjectInfo), String> { - let base = projects_dir()?; - if !base.exists() { - return Err(format!("Project with id \"{}\" not found", id)); - } - - let entries = fs::read_dir(&base).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - let raw = match fs::read_to_string(&project_json) { - Ok(r) => r, - Err(_) => continue, - }; - let info: StoredProjectInfo = match serde_json::from_str(&raw) { - Ok(i) => i, - Err(_) => continue, - }; - if info.id == id { - return Ok((path, info)); - } - } - - Err(format!("Project with id \"{}\" not found", id)) -} - -fn deserialize_working_dirs<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum WorkingDirsField { - Many(Vec), - One(String), - Null, - } - - let value = Option::::deserialize(deserializer)?; - let dirs = match value { - Some(WorkingDirsField::Many(dirs)) => dirs, - Some(WorkingDirsField::One(dir)) => vec![dir], - Some(WorkingDirsField::Null) | None => Vec::new(), - }; - - Ok(dirs - .into_iter() - .map(|dir| dir.trim().to_string()) - .filter(|dir| !dir.is_empty()) - .collect()) -} - -fn project_artifacts_dir(project_dir: &Path) -> String { - project_dir.join("artifacts").to_string_lossy().into_owned() -} - -#[derive(serde::Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -struct StoredProjectInfo { - pub id: String, - pub name: String, - pub description: String, - pub prompt: String, - pub icon: String, - pub color: String, - pub preferred_provider: Option, - pub preferred_model: Option, - #[serde( - default, - alias = "workingDir", - deserialize_with = "deserialize_working_dirs" - )] - pub working_dirs: Vec, - pub use_worktrees: bool, - #[serde(default)] - pub order: i32, - #[serde(default)] - pub archived_at: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(serde::Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ProjectInfo { - pub id: String, - pub name: String, - pub description: String, - pub prompt: String, - pub icon: String, - pub color: String, - pub preferred_provider: Option, - pub preferred_model: Option, - #[serde(default)] - pub working_dirs: Vec, - pub use_worktrees: bool, - #[serde(default)] - pub order: i32, - #[serde(default)] - pub archived_at: Option, - pub created_at: String, - pub updated_at: String, - pub artifacts_dir: String, -} - -fn project_info_from_stored(project_dir: &Path, stored: StoredProjectInfo) -> ProjectInfo { - ProjectInfo { - id: stored.id, - name: stored.name, - description: stored.description, - prompt: stored.prompt, - icon: stored.icon, - color: stored.color, - preferred_provider: stored.preferred_provider, - preferred_model: stored.preferred_model, - working_dirs: stored.working_dirs, - use_worktrees: stored.use_worktrees, - order: stored.order, - archived_at: stored.archived_at, - created_at: stored.created_at, - updated_at: stored.updated_at, - artifacts_dir: project_artifacts_dir(project_dir), - } -} - -#[tauri::command] -pub fn list_projects() -> Result, String> { - let dir = projects_dir()?; - - if !dir.exists() { - return Ok(vec![]); - } - - let mut projects = Vec::new(); - let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - - let raw = match fs::read_to_string(&project_json) { - Ok(r) => r, - Err(_) => continue, - }; - let info: StoredProjectInfo = match serde_json::from_str(&raw) { - Ok(i) => i, - Err(_) => continue, - }; - - projects.push(project_info_from_stored(&path, info)); - } - - projects.sort_by_key(|p| p.order); - projects.retain(|p| p.archived_at.is_none()); - Ok(projects) -} - -#[tauri::command] -pub fn list_archived_projects() -> Result, String> { - let dir = projects_dir()?; - if !dir.exists() { - return Ok(vec![]); - } - - let mut projects = Vec::new(); - let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - let raw = fs::read_to_string(&project_json).unwrap_or_default(); - if let Ok(info) = serde_json::from_str::(&raw) { - if info.archived_at.is_some() { - projects.push(project_info_from_stored(&path, info)); - } - } - } - - projects.sort_by_key(|p| p.order); - Ok(projects) -} - -#[allow(clippy::too_many_arguments)] -#[tauri::command] -pub fn create_project( - name: String, - description: String, - prompt: String, - icon: String, - color: String, - preferred_provider: Option, - preferred_model: Option, - working_dirs: Vec, - use_worktrees: bool, -) -> Result { - if name.trim().is_empty() { - return Err("Project name must not be empty".to_string()); - } - - let base = projects_dir()?; - let slug = slugify(&name); - - // Determine final directory name, avoiding collisions - let mut dir_name = slug.clone(); - if base.join(&dir_name).exists() { - let mut counter = 2u32; - loop { - dir_name = format!("{}-{}", slug, counter); - if !base.join(&dir_name).exists() { - break; - } - counter += 1; - } - } - - let existing_count = if base.exists() { - fs::read_dir(&base) - .map(|entries| entries.flatten().filter(|e| e.path().is_dir()).count()) - .unwrap_or(0) - } else { - 0 - }; - - let dir = base.join(&dir_name); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create project directory: {}", e))?; - - let now = now_timestamp(); - let stored = StoredProjectInfo { - id: generate_id(), - name, - description, - prompt, - icon, - color, - preferred_provider, - preferred_model, - working_dirs, - use_worktrees, - order: existing_count as i32, - archived_at: None, - created_at: now.clone(), - updated_at: now, - }; - - let project_path = dir.join("project.json"); - let json = serde_json::to_string_pretty(&stored) - .map_err(|e| format!("Failed to serialize project: {}", e))?; - fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?; - - Ok(project_info_from_stored(&dir, stored)) -} - -#[allow(clippy::too_many_arguments)] -#[tauri::command] -pub fn update_project( - id: String, - name: String, - description: String, - prompt: String, - icon: String, - color: String, - preferred_provider: Option, - preferred_model: Option, - working_dirs: Vec, - use_worktrees: bool, -) -> Result { - if name.trim().is_empty() { - return Err("Project name must not be empty".to_string()); - } - - let (dir, existing) = find_project_by_id(&id)?; - - let stored = StoredProjectInfo { - id: existing.id, - name, - description, - prompt, - icon, - color, - preferred_provider, - preferred_model, - working_dirs, - use_worktrees, - order: existing.order, - archived_at: existing.archived_at, - created_at: existing.created_at, - updated_at: now_timestamp(), - }; - - let project_path = dir.join("project.json"); - let json = serde_json::to_string_pretty(&stored) - .map_err(|e| format!("Failed to serialize project: {}", e))?; - fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?; - - Ok(project_info_from_stored(&dir, stored)) -} - -#[tauri::command] -pub fn delete_project(id: String) -> Result<(), String> { - let (dir, _) = find_project_by_id(&id)?; - fs::remove_dir_all(&dir).map_err(|e| format!("Failed to delete project: {}", e))?; - Ok(()) -} - -#[tauri::command] -pub fn reorder_projects(order: Vec<(String, i32)>) -> Result<(), String> { - for (id, new_order) in order { - let (dir, mut stored) = find_project_by_id(&id)?; - stored.order = new_order; - let project_path = dir.join("project.json"); - let json = serde_json::to_string_pretty(&stored) - .map_err(|e| format!("Failed to serialize project: {}", e))?; - fs::write(&project_path, json) - .map_err(|e| format!("Failed to write project.json: {}", e))?; - } - Ok(()) -} - -#[tauri::command] -pub fn get_project(id: String) -> Result { - let (dir, info) = find_project_by_id(&id)?; - Ok(project_info_from_stored(&dir, info)) -} - -#[tauri::command] -pub fn archive_project(id: String) -> Result<(), String> { - let base = projects_dir()?; - if !base.exists() { - return Err("Projects directory not found".to_string()); - } - - let entries = fs::read_dir(&base).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - let raw = fs::read_to_string(&project_json).unwrap_or_default(); - if let Ok(mut info) = serde_json::from_str::(&raw) { - if info.id == id { - info.archived_at = Some(now_timestamp()); - let json = serde_json::to_string_pretty(&info) - .map_err(|e| format!("Failed to serialize: {}", e))?; - fs::write(&project_json, json).map_err(|e| format!("Failed to write: {}", e))?; - return Ok(()); - } - } - } - - Err(format!("Project with id \"{}\" not found", id)) -} - -#[tauri::command] -pub fn restore_project(id: String) -> Result<(), String> { - let base = projects_dir()?; - if !base.exists() { - return Err("Projects directory not found".to_string()); - } - - let entries = fs::read_dir(&base).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - let raw = fs::read_to_string(&project_json).unwrap_or_default(); - if let Ok(mut info) = serde_json::from_str::(&raw) { - if info.id == id { - info.archived_at = None; - let json = serde_json::to_string_pretty(&info) - .map_err(|e| format!("Failed to serialize: {}", e))?; - fs::write(&project_json, json).map_err(|e| format!("Failed to write: {}", e))?; - return Ok(()); - } - } - } - - Err(format!("Project with id \"{}\" not found", id)) -} - -#[cfg(test)] -mod tests { - use super::{project_artifacts_dir, StoredProjectInfo}; - use std::path::Path; - - #[test] - fn deserializes_legacy_single_working_dir() { - let project: StoredProjectInfo = serde_json::from_str( - r##"{ - "id": "project-1", - "name": "Legacy", - "description": "", - "prompt": "", - "icon": "📁", - "color": "#000000", - "preferredProvider": null, - "preferredModel": null, - "workingDir": "/tmp/legacy", - "useWorktrees": false, - "createdAt": "now", - "updatedAt": "now" - }"##, - ) - .expect("legacy project"); - - assert_eq!(project.working_dirs, vec!["/tmp/legacy"]); - } - - #[test] - fn builds_project_artifacts_dir_inside_project_storage() { - assert_eq!( - project_artifacts_dir(Path::new("/Users/test/.goose/projects/sample-project")), - "/Users/test/.goose/projects/sample-project/artifacts" - ); - } -} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 921841453866..16be8cfbd169 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -43,15 +43,6 @@ pub fn run() { commands::agents::save_persona_avatar_bytes, commands::agents::get_avatars_dir, commands::acp::get_goose_serve_url, - commands::projects::list_projects, - commands::projects::create_project, - commands::projects::update_project, - commands::projects::delete_project, - commands::projects::get_project, - commands::projects::reorder_projects, - commands::projects::list_archived_projects, - commands::projects::archive_project, - commands::projects::restore_project, commands::project_icons::scan_project_icons, commands::project_icons::read_project_icon, commands::doctor::run_doctor, diff --git a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts index efd2ff102c78..1ce3892b884e 100644 --- a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts +++ b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts @@ -12,7 +12,6 @@ import { useProviderSelection } from "@/features/agents/hooks/useProviderSelecti import { useProjectStore } from "@/features/projects/stores/projectStore"; import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/providerCatalog"; import { - buildProjectSystemPrompt, composeSystemPrompt, getProjectArtifactRoots, resolveProjectDefaultArtifactRoot, @@ -29,6 +28,7 @@ import { useResolvedAgentModelPicker, type PreferredModelSelection, } from "./useResolvedAgentModelPicker"; +import { updateSessionProject } from "@/shared/api/acpApi"; interface UseChatSessionControllerOptions { sessionId: string | null; @@ -139,22 +139,14 @@ export function useChatSessionController({ })), [projects], ); - const projectSystemPrompt = useMemo( - () => buildProjectSystemPrompt(project), - [project], - ); const workingContextPrompt = useMemo(() => { if (!activeWorkspace?.branch) return undefined; return `\nActive branch: ${activeWorkspace.branch}\nWorking directory: ${activeWorkspace.path}\n`; }, [activeWorkspace?.branch, activeWorkspace?.path]); const effectiveSystemPrompt = useMemo( () => - composeSystemPrompt( - selectedPersona?.systemPrompt, - projectSystemPrompt, - workingContextPrompt, - ), - [projectSystemPrompt, selectedPersona?.systemPrompt, workingContextPrompt], + composeSystemPrompt(selectedPersona?.systemPrompt, workingContextPrompt), + [selectedPersona?.systemPrompt, workingContextPrompt], ); const prepareCurrentSession = useCallback( @@ -172,7 +164,10 @@ export function useChatSessionController({ nextProject, nextWorkspacePath, ); - await acpPrepareSession(sessionId, providerId, workingDir, { personaId }); + await acpPrepareSession(sessionId, providerId, workingDir, { + personaId, + projectId: nextProject?.id, + }); if (!modelSelection?.id) { return; } @@ -321,6 +316,9 @@ export function useChatSessionController({ null); useChatSessionStore.getState().updateSession(sessionId, { projectId }); + + void updateSessionProject(sessionId, projectId).catch(console.error); + if (!selectedProvider) { return; } @@ -715,6 +713,9 @@ export function useChatSessionController({ } if (hasPendingProject) { patch.projectId = nextProjectId ?? null; + void updateSessionProject(sessionId, nextProjectId ?? null).catch( + console.error, + ); } useChatSessionStore.getState().updateSession(sessionId, patch); diff --git a/ui/goose2/src/features/projects/api/projects.ts b/ui/goose2/src/features/projects/api/projects.ts index 621e4288127b..4bfd1fac7c35 100644 --- a/ui/goose2/src/features/projects/api/projects.ts +++ b/ui/goose2/src/features/projects/api/projects.ts @@ -1,7 +1,10 @@ import { invoke } from "@tauri-apps/api/core"; +import { getClient } from "@/shared/api/acpConnection"; export interface ProjectInfo { id: string; + /** Stable on-disk path of the project source. Pass back to update/delete. */ + path: string; name: string; description: string; prompt: string; @@ -13,9 +16,70 @@ export interface ProjectInfo { useWorktrees: boolean; order: number; archivedAt: string | null; - createdAt: string; - updatedAt: string; - artifactsDir: string; +} + +// Shape returned by _goose/sources/*. Narrowed to project-type sources here. +interface SourceEntry { + type: "project"; + name: string; + description: string; + content: string; + path: string; + global: boolean; + properties: Record; +} + +function toProjectInfo(source: SourceEntry): ProjectInfo { + const p = source.properties ?? {}; + return { + id: source.name, + path: source.path, + name: (p.title as string) ?? source.name, + description: source.description, + prompt: source.content, + icon: (p.icon as string) ?? "", + color: (p.color as string) ?? "", + preferredProvider: (p.preferredProvider as string) ?? null, + preferredModel: (p.preferredModel as string) ?? null, + workingDirs: (p.workingDirs as string[]) ?? [], + useWorktrees: (p.useWorktrees as boolean) ?? false, + order: (p.order as number) ?? 0, + archivedAt: (p.archivedAt as string) ?? null, + }; +} + +interface ProjectMetadataFields { + name: string; + icon: string; + color: string; + preferredProvider: string | null; + preferredModel: string | null; + workingDirs: string[]; + useWorktrees: boolean; + order: number; + archivedAt: string | null; +} + +function toProperties(info: ProjectMetadataFields): Record { + const props: Record = {}; + if (info.name) props.title = info.name; + if (info.icon) props.icon = info.icon; + if (info.color) props.color = info.color; + if (info.preferredProvider) props.preferredProvider = info.preferredProvider; + if (info.preferredModel) props.preferredModel = info.preferredModel; + if (info.workingDirs?.length) props.workingDirs = info.workingDirs; + if (info.useWorktrees) props.useWorktrees = info.useWorktrees; + if (typeof info.order === "number") props.order = info.order; + if (info.archivedAt) props.archivedAt = info.archivedAt; + return props; +} + +function slugify(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "project"; } export interface ProjectIconCandidate { @@ -30,7 +94,15 @@ export interface ProjectIconData { } export async function listProjects(): Promise { - return invoke("list_projects"); + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/list", { + type: "project", + }); + const sources = (raw.sources ?? []) as SourceEntry[]; + return sources + .map(toProjectInfo) + .filter((p) => p.archivedAt === null) + .sort((a, b) => a.order - b.order); } export async function scanProjectIcons( @@ -54,67 +126,111 @@ export async function createProject( workingDirs: string[], useWorktrees: boolean, ): Promise { - return invoke("create_project", { - name, + const client = await getClient(); + const id = slugify(name); + const raw = await client.extMethod("_goose/sources/create", { + type: "project", + name: id, description, - prompt, - icon, - color, - preferredProvider, - preferredModel, - workingDirs, - useWorktrees, + content: prompt, + global: true, + properties: toProperties({ + name, + icon, + color, + preferredProvider, + preferredModel, + workingDirs, + useWorktrees, + order: 0, + archivedAt: null, + }), }); + return toProjectInfo(raw.source as SourceEntry); } export async function updateProject( - id: string, - name: string, - description: string, - prompt: string, - icon: string, - color: string, - preferredProvider: string | null, - preferredModel: string | null, - workingDirs: string[], - useWorktrees: boolean, + existing: ProjectInfo, + updates: Partial>, ): Promise { - return invoke("update_project", { - id, - name, - description, - prompt, - icon, - color, - preferredProvider, - preferredModel, - workingDirs, - useWorktrees, + const merged = { ...existing, ...updates }; + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/update", { + type: "project", + path: existing.path, + name: existing.id, + description: merged.description, + content: merged.prompt, + properties: toProperties({ + name: merged.name, + icon: merged.icon, + color: merged.color, + preferredProvider: merged.preferredProvider, + preferredModel: merged.preferredModel, + workingDirs: merged.workingDirs, + useWorktrees: merged.useWorktrees, + order: merged.order, + archivedAt: merged.archivedAt, + }), }); + return toProjectInfo(raw.source as SourceEntry); } -export async function deleteProject(id: string): Promise { - return invoke("delete_project", { id }); +export async function deleteProject( + idOrProject: string | ProjectInfo, +): Promise { + const client = await getClient(); + const path = + typeof idOrProject === "string" + ? (await getProject(idOrProject)).path + : idOrProject.path; + await client.extMethod("_goose/sources/delete", { + type: "project", + path, + }); } export async function getProject(id: string): Promise { - return invoke("get_project", { id }); + const all = await listAllProjects(); + const match = all.find((p) => p.id === id); + if (!match) throw new Error(`Project "${id}" not found`); + return match; } -export async function listArchivedProjects(): Promise { - return invoke("list_archived_projects"); +/** List both archived and active projects. */ +async function listAllProjects(): Promise { + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/list", { + type: "project", + }); + const sources = (raw.sources ?? []) as SourceEntry[]; + return sources.map(toProjectInfo); } export async function archiveProject(id: string): Promise { - return invoke("archive_project", { id }); + const project = await getProject(id); + await updateProject(project, { + archivedAt: new Date().toISOString(), + }); +} + +export async function restoreProject(id: string): Promise { + const project = await getProject(id); + await updateProject(project, { archivedAt: null }); } export async function reorderProjects( order: [string, number][], ): Promise { - return invoke("reorder_projects", { order }); + const all = await listAllProjects(); + for (const [id, orderValue] of order) { + const existing = all.find((p) => p.id === id); + if (!existing) continue; + await updateProject(existing, { order: orderValue }); + } } -export async function restoreProject(id: string): Promise { - return invoke("restore_project", { id }); +export async function listArchivedProjects(): Promise { + const all = await listAllProjects(); + return all.filter((p) => p.archivedAt !== null); } diff --git a/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts b/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts index 9420a9dea066..526ee8f695ce 100644 --- a/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts +++ b/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - buildProjectSystemPrompt, composeSystemPrompt, getProjectArtifactRoots, getProjectFolderName, @@ -8,45 +7,6 @@ import { } from "./chatProjectContext"; describe("chatProjectContext", () => { - it("builds project instructions from stored project settings", () => { - const systemPrompt = buildProjectSystemPrompt({ - id: "project-1", - name: "Goose2", - description: "Desktop app", - prompt: "Always read AGENTS.md before editing.", - icon: "folder", - color: "#000000", - preferredProvider: "goose", - preferredModel: "claude-sonnet-4", - workingDirs: ["/Users/wesb/dev/goose2"], - artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts", - useWorktrees: true, - order: 0, - archivedAt: null, - createdAt: "now", - updatedAt: "now", - }); - - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain("Project name: Goose2"); - expect(systemPrompt).toContain( - "Working directories: /Users/wesb/dev/goose2", - ); - expect(systemPrompt).toContain( - "Artifact directory: /Users/wesb/dev/goose2/artifacts", - ); - expect(systemPrompt).toContain("Preferred provider: goose"); - expect(systemPrompt).toContain( - "Use git worktrees for branch isolation: yes", - ); - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain( - "Write newly generated files to /Users/wesb/dev/goose2/artifacts by default.", - ); - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain("Always read AGENTS.md before editing."); - }); - it("combines persona and project prompts without empty sections", () => { expect( composeSystemPrompt("Persona prompt", undefined, "Project prompt"), @@ -62,7 +22,6 @@ describe("chatProjectContext", () => { expect( getProjectFolderOption({ workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"], - artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts", }), ).toEqual([ { @@ -79,18 +38,7 @@ describe("chatProjectContext", () => { }); it("returns an empty array when workingDirs is empty", () => { - expect( - getProjectFolderOption({ - workingDirs: [], - artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts", - }), - ).toEqual([ - { - id: "/Users/wesb/.goose/projects/sample-project/artifacts", - name: "artifacts", - path: "/Users/wesb/.goose/projects/sample-project/artifacts", - }, - ]); + expect(getProjectFolderOption({ workingDirs: [] })).toEqual([]); }); it("returns an empty array when project is null", () => { @@ -101,7 +49,6 @@ describe("chatProjectContext", () => { expect( getProjectArtifactRoots({ workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"], - artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts", }), ).toEqual([ "/Users/wesb/dev/goose2/artifacts", diff --git a/ui/goose2/src/features/projects/lib/chatProjectContext.ts b/ui/goose2/src/features/projects/lib/chatProjectContext.ts index 5b1069725036..66ed8b44b570 100644 --- a/ui/goose2/src/features/projects/lib/chatProjectContext.ts +++ b/ui/goose2/src/features/projects/lib/chatProjectContext.ts @@ -1,5 +1,6 @@ import type { ProjectInfo } from "../api/projects"; import { resolvePath } from "@/shared/api/pathResolver"; + export interface ProjectFolderOption { id: string; name: string; @@ -26,28 +27,23 @@ function appendArtifactsSegment(path: string): string { } function resolveProjectArtifactRoots( - project: Pick | null | undefined, + project: Pick | null | undefined, ): string[] { const workingDirs = (project?.workingDirs ?? []) .map((directory) => trimValue(directory)) .filter((directory): directory is string => directory !== null); - if (workingDirs.length > 0) { - return workingDirs.map(appendArtifactsSegment); - } - - const artifactsDir = trimValue(project?.artifactsDir); - return artifactsDir ? [artifactsDir] : []; + return workingDirs.map(appendArtifactsSegment); } export function getProjectArtifactRoots( - project: Pick | null | undefined, + project: Pick | null | undefined, ): string[] { return resolveProjectArtifactRoots(project); } export function resolveProjectDefaultArtifactRoot( - project: ProjectInfo | null | undefined, + project: Pick | null | undefined, ): string | undefined { const workingDirs = (project?.workingDirs ?? []) .map((directory) => trimValue(directory)) @@ -57,7 +53,7 @@ export function resolveProjectDefaultArtifactRoot( return appendArtifactsSegment(workingDirs[0]); } - return trimValue(project?.artifactsDir) ?? undefined; + return undefined; } export async function defaultGlobalArtifactRoot(): Promise { @@ -65,7 +61,7 @@ export async function defaultGlobalArtifactRoot(): Promise { } export function getProjectFolderOption( - project: Pick | null | undefined, + project: Pick | null | undefined, ): ProjectFolderOption[] { return resolveProjectArtifactRoots(project).map((d) => ({ id: d, @@ -74,64 +70,6 @@ export function getProjectFolderOption( })); } -export function buildProjectSystemPrompt( - project: ProjectInfo | null | undefined, -): string | undefined { - if (!project) { - return undefined; - } - - const artifactDir = resolveProjectDefaultArtifactRoot(project); - const settings: string[] = [`Project name: ${project.name}`]; - const description = trimValue(project.description); - const workingDirs = (project.workingDirs ?? []) - .map((d) => trimValue(d)) - .filter((d): d is string => d !== null); - const prompt = trimValue(project.prompt); - - if (description) { - settings.push(`Project description: ${description}`); - } - if (workingDirs.length > 0) { - settings.push(`Working directories: ${workingDirs.join(", ")}`); - } - if (artifactDir) { - settings.push(`Artifact directory: ${artifactDir}`); - } - if (project.preferredProvider) { - settings.push(`Preferred provider: ${project.preferredProvider}`); - } - if (project.preferredModel) { - settings.push(`Preferred model: ${project.preferredModel}`); - } - settings.push( - `Use git worktrees for branch isolation: ${ - project.useWorktrees ? "yes" : "no" - }`, - ); - - const sections = [ - `\n${settings.join("\n")}\n`, - ]; - - if (artifactDir) { - sections.push( - `\n` + - `Write newly generated files to ${artifactDir} by default.\n` + - `When creating translations, variants, summaries, or derived documents from existing project files, save the new file in ${artifactDir} instead of the project root.\n` + - `Only write outside ${artifactDir} when the user explicitly asks you to edit or create a file at a specific path.\n` + - `If you need to read existing files elsewhere in the project, that is fine, but generated outputs should stay in ${artifactDir} unless the user says otherwise.\n` + - ``, - ); - } - - if (prompt) { - sections.push(`\n${prompt}\n`); - } - - return sections.join("\n\n"); -} - export function composeSystemPrompt( ...parts: Array ): string | undefined { diff --git a/ui/goose2/src/features/projects/lib/sessionCwdSelection.test.ts b/ui/goose2/src/features/projects/lib/sessionCwdSelection.test.ts index f25f8da10ce6..a0e431f0f201 100644 --- a/ui/goose2/src/features/projects/lib/sessionCwdSelection.test.ts +++ b/ui/goose2/src/features/projects/lib/sessionCwdSelection.test.ts @@ -14,6 +14,7 @@ vi.mock("@/shared/api/pathResolver", () => ({ function makeProject(overrides: Partial = {}): ProjectInfo { return { id: "project-1", + path: "/tmp/projects/project-1.md", name: "Project", description: "", prompt: "", @@ -25,9 +26,6 @@ function makeProject(overrides: Partial = {}): ProjectInfo { useWorktrees: false, order: 0, archivedAt: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - artifactsDir: "", ...overrides, }; } @@ -42,46 +40,24 @@ describe("sessionCwdSelection", () => { resolveProjectDefaultArtifactRoot( makeProject({ workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"], - artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts", }), ), ).toBe("/Users/wesb/dev/goose2/artifacts"); }); - it("falls back to the stored project artifact root when no workspace roots exist", () => { + it("returns undefined when no workspace roots exist", () => { expect( - resolveProjectDefaultArtifactRoot( - makeProject({ - workingDirs: [], - artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts", - }), - ), - ).toBe("/Users/wesb/.goose/projects/sample-project/artifacts"); - }); - - it("returns undefined for a pathless project artifact root", () => { - expect( - resolveProjectDefaultArtifactRoot( - makeProject({ - workingDirs: [], - artifactsDir: " ", - }), - ), + resolveProjectDefaultArtifactRoot(makeProject({ workingDirs: [] })), ).toBeUndefined(); }); - it("falls back to global artifacts for a pathless project session cwd", async () => { + it("falls back to global artifacts for a project without working dirs", async () => { vi.mocked(resolvePath).mockResolvedValue({ path: "/Users/wesb/.goose/artifacts", }); await expect( - resolveSessionCwd( - makeProject({ - workingDirs: [], - artifactsDir: " ", - }), - ), + resolveSessionCwd(makeProject({ workingDirs: [] })), ).resolves.toBe("/Users/wesb/.goose/artifacts"); expect(resolvePath).toHaveBeenCalledWith({ diff --git a/ui/goose2/src/features/projects/lib/sessionCwdSelection.ts b/ui/goose2/src/features/projects/lib/sessionCwdSelection.ts index 064585d82e12..e5e3e659d9ab 100644 --- a/ui/goose2/src/features/projects/lib/sessionCwdSelection.ts +++ b/ui/goose2/src/features/projects/lib/sessionCwdSelection.ts @@ -22,11 +22,6 @@ function buildSessionCwdParts( return [workingDirs[0], "artifacts"]; } - const artifactRoot = trimValue(project?.artifactsDir); - if (artifactRoot) { - return [artifactRoot]; - } - return ["~", ".goose", "artifacts"]; } diff --git a/ui/goose2/src/features/projects/stores/projectStore.ts b/ui/goose2/src/features/projects/stores/projectStore.ts index 931b7a02f3d8..a4e4a01cc68c 100644 --- a/ui/goose2/src/features/projects/stores/projectStore.ts +++ b/ui/goose2/src/features/projects/stores/projectStore.ts @@ -125,8 +125,9 @@ export const useProjectStore = create((set, get) => ({ workingDirs, useWorktrees, ) => { - const project = await updateProject( - id, + const existing = get().projects.find((p) => p.id === id); + if (!existing) throw new Error(`Project ${id} not found`); + const project = await updateProject(existing, { name, description, prompt, @@ -136,7 +137,7 @@ export const useProjectStore = create((set, get) => ({ preferredModel, workingDirs, useWorktrees, - ); + }); set((state) => ({ projects: state.projects.map((p) => (p.id === id ? project : p)), })); diff --git a/ui/goose2/src/features/projects/ui/CreateProjectDialog.tsx b/ui/goose2/src/features/projects/ui/CreateProjectDialog.tsx index bf1687444ed8..05ace40aa338 100644 --- a/ui/goose2/src/features/projects/ui/CreateProjectDialog.tsx +++ b/ui/goose2/src/features/projects/ui/CreateProjectDialog.tsx @@ -188,18 +188,17 @@ export function CreateProjectDialog({ try { let savedProject: ProjectInfo; if (isEditing) { - savedProject = await updateProject( - editingProject.id, - name.trim(), - "", - parsedPrompt, + savedProject = await updateProject(editingProject, { + name: name.trim(), + description: "", + prompt: parsedPrompt, icon, color, - preferredProvider || null, + preferredProvider: preferredProvider || null, preferredModel, workingDirs, useWorktrees, - ); + }); } else { savedProject = await createProject( name.trim(), diff --git a/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx b/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx index 89156b8671bf..5930ff03f8a6 100644 --- a/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx +++ b/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx @@ -93,6 +93,7 @@ vi.mock("../PromptEditor", () => ({ function makeEditingProject(overrides: Partial = {}): ProjectInfo { return { id: "proj-1", + path: "/tmp/projects/proj-1.md", name: "My Project", description: "A test project", prompt: "Do the thing", @@ -104,9 +105,6 @@ function makeEditingProject(overrides: Partial = {}): ProjectInfo { useWorktrees: false, order: 0, archivedAt: null, - createdAt: "2024-01-01", - updatedAt: "2024-01-01", - artifactsDir: "/home/user/code/.goose", ...overrides, }; } diff --git a/ui/goose2/src/features/skills/api/skills.test.ts b/ui/goose2/src/features/skills/api/skills.test.ts index eb29b13fa06c..0fcf0b40939b 100644 --- a/ui/goose2/src/features/skills/api/skills.test.ts +++ b/ui/goose2/src/features/skills/api/skills.test.ts @@ -25,7 +25,7 @@ describe("listSkills", () => { name: "code-review", description: "Reviews code", content: "Review carefully", - directory: "/Users/test/.agents/skills/code-review", + path: "/Users/test/.agents/skills/code-review", global: true, }, ], @@ -37,7 +37,7 @@ describe("listSkills", () => { name: "code-review", description: "Reviews code", content: "Review carefully", - directory: "/Users/test/.agents/skills/code-review", + path: "/Users/test/.agents/skills/code-review", global: true, }, { @@ -45,7 +45,7 @@ describe("listSkills", () => { name: "test-writer", description: "Writes tests", content: "Write tests", - directory: "/tmp/alpha/.agents/skills/test-writer", + path: "/tmp/alpha/.agents/skills/test-writer", global: false, }, ], @@ -92,7 +92,7 @@ describe("listSkills", () => { name: "legacy-writer", description: "Legacy project skill", content: "Legacy instructions", - directory: "/tmp/beta/.goose/skills/legacy-writer", + path: "/tmp/beta/.goose/skills/legacy-writer", global: false, }, ], @@ -128,7 +128,7 @@ describe("listSkills", () => { name: "code-review", description: "Reviews code", content: "Review carefully", - directory: "/Users/test/.agents/skills/code-review", + path: "/Users/test/.agents/skills/code-review", global: true, }, ], @@ -141,7 +141,7 @@ describe("listSkills", () => { name: "test-writer", description: "Writes tests", content: "Write tests", - directory: "/tmp/beta/.agents/skills/test-writer", + path: "/tmp/beta/.agents/skills/test-writer", global: false, }, ], diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index c5be04042f45..89b04a784c56 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -41,10 +41,21 @@ function isSkillSource(source: SourceEntry): source is SkillSourceEntry { function toSkillInfo(source: SkillSourceEntry): SkillInfo { const sourceKind: SkillSourceKind = source.global ? "global" : "project"; + const props = (source.properties ?? {}) as Record; + + // Backend tags project-scoped skills with these when listing via + // include_project_sources. Prefer them over path-derived values so badges + // show the user-visible project title. + const taggedProjectDir = + typeof props.projectDir === "string" ? props.projectDir : null; + const taggedProjectName = + typeof props.projectName === "string" ? props.projectName : null; + const projectRoot = source.global ? null - : deriveProjectRoot(source.directory); - const projectName = projectRoot ? basename(projectRoot) : ""; + : (taggedProjectDir ?? deriveProjectRoot(source.path)); + const projectName = + taggedProjectName ?? (projectRoot ? basename(projectRoot) : ""); const projectLinks: SkillProjectLink[] = projectRoot ? [ @@ -57,12 +68,12 @@ function toSkillInfo(source: SkillSourceEntry): SkillInfo { : []; return { - id: `${sourceKind}:${source.directory}`, + id: `${sourceKind}:${source.path}`, name: source.name, description: source.description, instructions: source.content, - path: source.directory, - fileLocation: getSkillFileLocation(source.directory), + path: source.path, + fileLocation: getSkillFileLocation(source.path), sourceKind, sourceLabel: sourceKind === "global" ? "Personal" : projectName || "Project", @@ -74,10 +85,17 @@ function uniqueProjectDirs(projectDirs: string[]) { return [...new Set(projectDirs.map((dir) => dir.trim()).filter(Boolean))]; } +export interface CreateSkillOptions { + /** Project source ID (kebab slug). When set, the skill is created under + * that project's first working directory. */ + projectId?: string; +} + export async function createSkill( name: string, description: string, instructions: string, + options: CreateSkillOptions = {}, ): Promise { const client = await getClient(); await client.goose.GooseSourcesCreate({ @@ -85,7 +103,8 @@ export async function createSkill( name, description, content: instructions, - global: true, + global: !options.projectId, + ...(options.projectId ? { projectId: options.projectId } : {}), }); } @@ -122,7 +141,7 @@ export async function listSkills( continue; } - const key = `${source.global ? "global" : "project"}:${source.directory}`; + const key = `${source.global ? "global" : "project"}:${source.path}`; if (seen.has(key)) { continue; } diff --git a/ui/goose2/src/features/skills/ui/SkillEditor.tsx b/ui/goose2/src/features/skills/ui/SkillEditor.tsx index de289d20025f..08d16c41ab0e 100644 --- a/ui/goose2/src/features/skills/ui/SkillEditor.tsx +++ b/ui/goose2/src/features/skills/ui/SkillEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; @@ -11,6 +11,14 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui/select"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; import { createSkill, updateSkill, @@ -20,6 +28,9 @@ import { import { formatSkillName, isValidSkillName } from "../lib/skillsHelpers"; import { getRenamedSkillFileLocation } from "../lib/skillsPath"; +/** Sentinel value for the "Global" option in the save-location picker. */ +const GLOBAL_VALUE = "__global__"; + interface SkillEditorProps { isOpen: boolean; onClose: () => void; @@ -37,9 +48,18 @@ export function SkillEditor({ const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [instructions, setInstructions] = useState(""); + const [saveLocation, setSaveLocation] = useState(GLOBAL_VALUE); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const projects = useProjectStore((s) => s.projects); + + // Only projects with working directories can hold skills + const projectsWithDirs = useMemo( + () => projects.filter((p) => p.workingDirs.length > 0), + [projects], + ); + const isEditing = !!editingSkill; // Pre-fill fields when editing @@ -48,11 +68,13 @@ export function SkillEditor({ setName(editingSkill.name); setDescription(editingSkill.description); setInstructions(editingSkill.instructions); + setSaveLocation(GLOBAL_VALUE); // location is fixed for existing skills setError(null); } else if (isOpen) { setName(""); setDescription(""); setInstructions(""); + setSaveLocation(GLOBAL_VALUE); setError(null); } }, [isOpen, editingSkill]); @@ -69,6 +91,7 @@ export function SkillEditor({ setName(""); setDescription(""); setInstructions(""); + setSaveLocation(GLOBAL_VALUE); setError(null); onClose(); }; @@ -88,11 +111,16 @@ export function SkillEditor({ instructions, ); } else { - await createSkill(name, description.trim(), instructions); + const projectId = + saveLocation !== GLOBAL_VALUE ? saveLocation : undefined; + await createSkill(name, description.trim(), instructions, { + projectId, + }); } setName(""); setDescription(""); setInstructions(""); + setSaveLocation(GLOBAL_VALUE); await onSaved?.(savedSkill); onClose(); } catch (err) { @@ -133,6 +161,35 @@ export function SkillEditor({ )} + {/* Save location — only shown when creating */} + {!isEditing && projectsWithDirs.length > 0 && ( +
+ + +

+ {saveLocation === GLOBAL_VALUE + ? t("dialog.globalHint") + : t("dialog.projectHint")} +

+
+ )} + {/* Description */}