From ff421719037fcea15ddcb038033085f2abcf0f2a Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Thu, 7 May 2026 10:30:15 -0400 Subject: [PATCH] feat: projects as backend sources with system prompt injection (#8739) Signed-off-by: Douwe Osinga Co-authored-by: Douwe Osinga Co-authored-by: Claude Sonnet 4.6 --- crates/goose-sdk/src/custom_requests.rs | 34 +- crates/goose/acp-schema.json | 39 +- crates/goose/src/acp/server.rs | 2 +- crates/goose/src/acp/server/sources.rs | 22 +- crates/goose/src/agents/agent.rs | 23 + .../src/agents/platform_extensions/summon.rs | 15 +- crates/goose/src/skills/client.rs | 8 +- crates/goose/src/skills/mod.rs | 42 +- crates/goose/src/sources.rs | 897 ++++++++++++++---- .../goose/tests/acp_custom_requests_test.rs | 2 +- ui/goose2/src-tauri/src/commands/mod.rs | 1 - ui/goose2/src-tauri/src/commands/projects.rs | 493 ---------- ui/goose2/src-tauri/src/lib.rs | 9 - .../chat/hooks/useChatSessionController.ts | 20 +- .../src/features/projects/api/projects.ts | 222 ++++- .../projects/lib/chatProjectContext.test.ts | 39 - .../projects/lib/chatProjectContext.ts | 58 +- .../projects/lib/sessionCwdSelection.test.ts | 3 +- .../features/projects/stores/projectStore.ts | 7 +- .../projects/ui/CreateProjectDialog.tsx | 13 +- .../ui/__tests__/CreateProjectDialog.test.tsx | 9 +- .../src/features/skills/api/skills.test.ts | 22 +- ui/goose2/src/features/skills/api/skills.ts | 37 +- .../src/features/skills/ui/SkillEditor.tsx | 61 +- .../skills/ui/__tests__/SkillEditor.test.tsx | 1 + .../src/shared/i18n/locales/en/skills.json | 4 + ui/goose2/tests/e2e/fixtures/tauri-mock.ts | 8 +- ui/sdk/src/generated/types.gen.ts | 45 +- ui/sdk/src/generated/zod.gen.ts | 24 +- 29 files changed, 1241 insertions(+), 919 deletions(-) delete mode 100644 ui/goose2/src-tauri/src/commands/projects.rs diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 8863e4022e..f664d3fb2c 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -817,6 +817,7 @@ pub enum SourceType { Recipe, Subrecipe, Agent, + Project, } impl std::fmt::Display for SourceType { @@ -827,6 +828,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"), } } } @@ -842,10 +844,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. Built-in skills use read-only synthetic - /// `builtin://skills/` paths. - pub directory: String, + /// Stable on-disk path identifying this source. Pass it back to + /// update/delete/export to operate on this entry. Skills use the directory + /// containing `SKILL.md`; projects use the project file path; built-in + /// skills use `builtin://skills/` synthetic paths. + 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, @@ -853,6 +856,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 { @@ -881,6 +888,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)] @@ -903,6 +918,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)] @@ -922,6 +941,13 @@ pub struct UpdateSourceRequest { pub name: String, pub description: String, pub content: String, + /// When `Some`, replaces all stored properties on the source. When + /// `None` (or omitted), the source's existing properties are + /// preserved. Callers that don't model the full property bag (e.g. + /// the skills editor, which only edits name/description/content) + /// should omit this so per-skill metadata isn't silently erased. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub properties: Option>, } #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 87c80ff9b7..e9a510731d 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -1884,6 +1884,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": [ @@ -1904,7 +1916,8 @@ "builtinSkill", "recipe", "subrecipe", - "agent" + "agent", + "project" ], "description": "The type of source entity." }, @@ -1936,9 +1949,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. Built-in skills use read-only synthetic\n`builtin://skills/` paths." + "description": "Stable on-disk path identifying this source. Pass it back to\nupdate/delete/export to operate on this entry. Skills use the directory\ncontaining `SKILL.md`; projects use the project file path; built-in\nskills use `builtin://skills/` synthetic paths." }, "global": { "type": "boolean", @@ -1950,6 +1963,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": [ @@ -1957,7 +1975,7 @@ "name", "description", "content", - "directory", + "path", "global" ], "description": "A source discovered by Goose. Filesystem sources use an on-disk path;\nbuilt-in sources use a stable synthetic path. Sources may be either\n`global` (shared across all projects) or project-specific." @@ -1980,6 +1998,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\nIf `type` is omitted or `skill`, this lists filesystem/plugin skills only.\nBoth global and project-scoped skills are included when `project_dir` is\nset. If `type` is `builtinSkill`, this lists shipped read-only built-in\nskills.", @@ -2019,6 +2042,14 @@ }, "content": { "type": "string" + }, + "properties": { + "type": [ + "object", + "null" + ], + "additionalProperties": {}, + "description": "When `Some`, replaces all stored properties on the source. When\n`None` (or omitted), the source's existing properties are\npreserved. Callers that don't model the full property bag (e.g.\nthe skills editor, which only edits name/description/content)\nshould omit this so per-skill metadata isn't silently erased." } }, "required": [ diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 60c5c70d7e..51a53c914c 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -3150,7 +3150,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 d86be10281..6a9bae833f 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(|| { + agent_client_protocol::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 9a4359535d..50c20c140d 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -366,6 +366,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, @@ -1251,6 +1269,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 68eb229e90..9ef3536800 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 c7f82af310..888dfd45d8 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 cac8f884c4..aab8edfb7b 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -13,7 +13,8 @@ use crate::sources::parse_frontmatter; use agent_client_protocol::Error; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use serde::Deserialize; -use std::collections::HashSet; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use tracing::warn; @@ -23,6 +24,12 @@ pub struct SkillFrontmatter { pub name: Option, #[serde(default)] pub description: String, + /// Free-form bag for caller-defined fields. Per the agentskills.io spec + /// (), arbitrary + /// metadata lives in this nested mapping so it doesn't collide with + /// reserved frontmatter fields. + #[serde(default)] + pub metadata: HashMap, } /// Canonical writable location for global user skills: `~/.agents/skills`. @@ -169,9 +176,31 @@ pub(crate) fn infer_skill_name(dir: &Path) -> String { .to_string() } -pub(crate) fn build_skill_md(name: &str, description: &str, content: &str) -> String { +pub(crate) fn build_skill_md( + name: &str, + description: &str, + content: &str, + metadata: &HashMap, +) -> String { let safe_desc = description.replace('\'', "''"); - let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc); + let mut md = String::from("---\n"); + md.push_str(&format!("name: {}\n", name)); + md.push_str(&format!("description: '{}'\n", safe_desc)); + if !metadata.is_empty() { + md.push_str("metadata:\n"); + // Use YAML for the nested metadata block. We render it with serde_yaml + // and indent every line by two spaces so it nests under `metadata:`. + let yaml = serde_yaml::to_string(metadata).unwrap_or_default(); + for line in yaml.lines() { + if line.is_empty() { + continue; + } + md.push_str(" "); + md.push_str(line); + md.push('\n'); + } + } + md.push_str("---\n"); if !content.is_empty() { md.push('\n'); md.push_str(content); @@ -252,9 +281,10 @@ fn parse_skill_content(content: &str, path: &Path, global: bool) -> Option) -> Vec { if let Some(source) = parse_skill_content(content, &PathBuf::new(), true) { if !seen.contains(&source.name) { seen.insert(source.name.clone()); - let directory = format!("builtin://skills/{}", source.name); + let path = format!("builtin://skills/{}", source.name); sources.push(SourceEntry { source_type: SourceType::BuiltinSkill, - directory, + path, ..source }); } diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index 5baa5e8a85..78e230a3fa 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -1,5 +1,8 @@ //! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom +//! methods. Skills live in `~/.agents/skills/` (or per-project under +//! `/.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 agent_client_protocol::Error; use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; 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,54 +30,268 @@ pub fn parse_frontmatter Deserialize<'de>>( Ok(Some((metadata, body))) } -fn require_skill_type(source_type: SourceType) -> Result<(), Error> { - if source_type != SourceType::Skill { - return Err(Error::invalid_params().data(format!( - "Source type '{}' is not supported. Only 'skill' is currently supported.", - source_type - ))); +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." + ))), } - Ok(()) } fn require_listable_type(source_type: Option) -> Result { match source_type.unwrap_or(SourceType::Skill) { SourceType::Skill => Ok(SourceType::Skill), SourceType::BuiltinSkill => Ok(SourceType::BuiltinSkill), + SourceType::Project => Ok(SourceType::Project), other => Err(Error::invalid_params().data(format!( - "Source type '{}' is not supported. Only 'skill' and 'builtinSkill' are currently supported for listing.", + "Source type '{}' is not supported for listing.", other ))), } } -fn source_entry( - source_type: SourceType, +// --- 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, - dir: &std::path::Path, + 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) +} + +/// Read the `metadata:` field out of an existing SKILL.md, returning an +/// empty map if the file is missing, malformed, or carries no metadata. +fn read_existing_skill_properties(skill_dir: &Path) -> HashMap { + let raw = match fs::read_to_string(skill_dir.join("SKILL.md")) { + Ok(s) => s, + Err(_) => return HashMap::new(), + }; + match parse_frontmatter::(&raw) { + Ok(Some((meta, _))) => meta.metadata, + _ => HashMap::new(), + } +} + +/// Read the properties bag out of an existing project file. +fn read_existing_project_properties(file: &Path) -> HashMap { + let raw = match fs::read_to_string(file) { + Ok(s) => s, + Err(_) => return HashMap::new(), + }; + let (_, _, _, properties) = parse_project_frontmatter(&raw); + properties +} + +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!( + "Path \"{}\" is not a project source", + canonical_path.display() + ))); + } + 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) +} + +// --- SourceEntry construction --- + +fn skill_source_entry( + name: &str, + description: &str, + content: &str, + dir: &Path, global: bool, + properties: HashMap, ) -> 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, } } fn builtin_skill_entry(mut source: SourceEntry) -> SourceEntry { source.source_type = SourceType::BuiltinSkill; - source.directory = format!("builtin://skills/{}", source.name); + source.path = format!("builtin://skills/{}", source.name); source.global = true; source.supporting_files.clear(); source } +// --- Public CRUD --- + pub fn create_source( source_type: SourceType, name: &str, @@ -81,33 +299,64 @@ 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); + require_mutable_type(source_type)?; - if dir.exists() { - return Err( - Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) - ); + 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, &properties); + 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, + properties, + )) + } + 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( @@ -116,109 +365,281 @@ pub fn update_source( name: &str, description: &str, content: &str, + properties: Option>, ) -> Result { - require_skill_type(source_type)?; - validate_skill_name(name)?; + require_mutable_type(source_type)?; - 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"))?; + match source_type { + SourceType::Skill => { + validate_skill_name(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); + 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") + })?; - if target_dir.exists() { - return Err( - Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) - ); + // When the caller doesn't supply properties, preserve whatever + // is already on disk so per-skill frontmatter metadata isn't + // silently erased on edit. + let resolved_properties = match properties { + Some(p) => p, + None => read_existing_skill_properties(&dir), + }; + + 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, &resolved_properties); + 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, + &target_dir, + is_global_skill_dir(&target_dir), + resolved_properties, + )) } + SourceType::Project => { + validate_project_slug(name)?; + let file = resolve_project_path(path)?; - fs::rename(&dir, &target_dir).map_err(|e| { - Error::internal_error().data(format!("Failed to rename source directory: {e}")) - })?; + // 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 + ))); + } - target_dir - }; + // Same preserve-on-None semantics as skills. + let resolved_properties = match properties { + Some(p) => p, + None => read_existing_project_properties(&file), + }; - 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), - )) + let display_name = resolved_properties + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(name); + let md = build_project_md(display_name, description, content, &resolved_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> { - let listed_type = require_listable_type(source_type)?; + if let Some(t) = source_type { + require_listable_type(Some(t))?; + } + let kinds: Vec = match source_type { + Some(t) => vec![t], + None => vec![SourceType::Skill, SourceType::Project], + }; - let working_dir = project_dir - .map(str::trim) - .filter(|p| !p.is_empty()) - .map(PathBuf::from); + 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), + ); - let mut sources: Vec = discover_skills(working_dir.as_deref()) - .into_iter() - .filter(|s| s.source_type == listed_type) - .map(|s| { - if listed_type == SourceType::BuiltinSkill { - builtin_skill_entry(s) - } else { - s + 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); + } + } + } + } } - }) - .collect(); + 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) + .map(builtin_skill_entry), + ); + } + 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)?; + 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 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 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)) + 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( @@ -239,17 +660,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.", - other - ))); + return Err(Error::invalid_params() + .data(format!("Source type '{}' import is not supported.", other))); } }; @@ -262,12 +682,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")); } @@ -278,36 +699,68 @@ 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, + properties, + ) + .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)] @@ -341,28 +794,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", + Some(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()); } @@ -371,15 +826,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")); } @@ -398,6 +879,7 @@ mod tests { "body goes here", false, Some(project_a.to_str().unwrap()), + HashMap::new(), ) .unwrap(); @@ -421,19 +903,28 @@ mod tests { std::fs::create_dir_all(&claude_skill_dir).unwrap(); std::fs::write( claude_skill_dir.join("SKILL.md"), - build_skill_md("portable", "describes itself", "body goes here"), + build_skill_md( + "portable", + "describes itself", + "body goes here", + &HashMap::new(), + ), ) .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(); + export_source(SourceType::Skill, exported_skill.path.as_str()).unwrap(); assert_eq!(filename, "portable.skill.json"); assert!(json.contains("\"name\": \"portable\"")); } @@ -446,7 +937,12 @@ mod tests { std::fs::create_dir_all(&claude_skill_dir).unwrap(); std::fs::write( claude_skill_dir.join("SKILL.md"), - build_skill_md("portable", "describes itself", "body goes here"), + build_skill_md( + "portable", + "describes itself", + "body goes here", + &HashMap::new(), + ), ) .unwrap(); @@ -456,6 +952,7 @@ mod tests { "portable", "updated description", "updated body", + Some(HashMap::new()), ) .unwrap(); @@ -473,7 +970,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, @@ -501,6 +1007,7 @@ mod tests { "no-such-skill", "d", "c", + Some(HashMap::new()), ) .unwrap_err(); assert!(format!("{:?}", err).contains("not found")); @@ -520,7 +1027,7 @@ mod tests { #[test] fn list_sources_lists_builtin_skills() { - let listed = list_sources(Some(SourceType::BuiltinSkill), None).unwrap(); + let listed = list_sources(Some(SourceType::BuiltinSkill), None, false).unwrap(); let builtin = listed .iter() .find(|source| source.name == "goose-doc-guide") @@ -528,14 +1035,14 @@ mod tests { assert_eq!(builtin.source_type, SourceType::BuiltinSkill); assert!(builtin.global); - assert_eq!(builtin.directory, "builtin://skills/goose-doc-guide"); + assert_eq!(builtin.path, "builtin://skills/goose-doc-guide"); assert!(builtin.supporting_files.is_empty()); assert!(!builtin.content.is_empty()); } #[test] fn list_skill_excludes_builtin_skills() { - let listed = list_sources(Some(SourceType::Skill), None).unwrap(); + let listed = list_sources(Some(SourceType::Skill), None, false).unwrap(); assert!(!listed .iter() .any(|source| source.source_type == SourceType::BuiltinSkill)); @@ -552,21 +1059,31 @@ mod tests { std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), - build_skill_md("goose-doc-guide", "project override", "Use project docs"), + build_skill_md( + "goose-doc-guide", + "project override", + "Use project docs", + &HashMap::new(), + ), ) .unwrap(); let builtins = list_sources( Some(SourceType::BuiltinSkill), Some(project.to_str().unwrap()), + false, ) .unwrap(); assert!(!builtins .iter() .any(|source| source.name == "goose-doc-guide")); - let skills = - list_sources(Some(SourceType::Skill), Some(project.to_str().unwrap())).unwrap(); + let skills = list_sources( + Some(SourceType::Skill), + Some(project.to_str().unwrap()), + false, + ) + .unwrap(); let project_skill = skills .iter() .find(|source| source.name == "goose-doc-guide") @@ -576,7 +1093,7 @@ mod tests { } #[test] - fn mutations_reject_non_writable_source_types() { + fn rejects_unsupported_source_type_for_mutation() { let tmp = TempDir::new().unwrap(); let project = tmp.path().to_str().unwrap(); @@ -587,6 +1104,7 @@ mod tests { "c", false, Some(project), + HashMap::new(), ) .unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); @@ -597,11 +1115,13 @@ mod tests { "x", "d", "c", + Some(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", Some(HashMap::new())) + .unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); let err = delete_source(SourceType::BuiltinSkill, "builtin://skills/x").unwrap_err(); @@ -610,12 +1130,12 @@ mod tests { let err = delete_source(SourceType::Subrecipe, "x").unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); - let listed = list_sources(Some(SourceType::BuiltinSkill), Some(project)).unwrap(); + let listed = list_sources(Some(SourceType::BuiltinSkill), Some(project), false).unwrap(); assert!(listed .iter() .any(|source| source.source_type == SourceType::BuiltinSkill)); - let err = list_sources(Some(SourceType::Recipe), Some(project)).unwrap_err(); + let err = list_sources(Some(SourceType::Recipe), Some(project), false).unwrap_err(); assert!(format!("{:?}", err).contains("not supported")); let err = export_source(SourceType::BuiltinSkill, "builtin://skills/x").unwrap_err(); @@ -648,6 +1168,7 @@ mod tests { "body", false, Some(project), + HashMap::new(), ) .unwrap(); @@ -658,6 +1179,7 @@ mod tests { "my-dir", "new description", "new body", + Some(HashMap::new()), ) .unwrap(); // Name is derived from the frontmatter written by create_source @@ -671,17 +1193,21 @@ mod tests { std::fs::create_dir_all(&skill_dir).unwrap(); std::fs::write( skill_dir.join("SKILL.md"), - build_skill_md("test-skill", "from agents", "Body"), + build_skill_md("test-skill", "from agents", "Body", &HashMap::new()), ) .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"); } @@ -702,26 +1228,30 @@ mod tests { std::fs::create_dir_all(&legacy_skill_dir).unwrap(); std::fs::write( agents_skill_dir.join("SKILL.md"), - build_skill_md("shared-skill", "preferred", "Agents"), + build_skill_md("shared-skill", "preferred", "Agents", &HashMap::new()), ) .unwrap(); std::fs::write( legacy_skill_dir.join("SKILL.md"), - build_skill_md("shared-skill", "legacy", "Goose"), + build_skill_md("shared-skill", "legacy", "Goose", &HashMap::new()), ) .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")); } @@ -744,6 +1274,7 @@ mod tests { "escaped", "new description", "new content", + Some(HashMap::new()), ) .unwrap_err(); assert!(format!("{:?}", err).contains("not found")); diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index adb23ca67d..4478545f86 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -140,7 +140,7 @@ fn test_custom_list_builtin_skill_sources() { ); assert_eq!(builtin.get("global"), Some(&serde_json::json!(true))); assert_eq!( - builtin.get("directory"), + builtin.get("path"), Some(&serde_json::json!("builtin://skills/goose-doc-guide")) ); }); diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index 5584076fe3..29643efa25 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -8,5 +8,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 cd41faa30c..0000000000 --- a/ui/goose2/src-tauri/src/commands/projects.rs +++ /dev/null @@ -1,493 +0,0 @@ -use serde::Deserialize; -use std::fs; -use std::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()) -} - -#[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, -} - -fn project_info_from_stored(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, - } -} - -#[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(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(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(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(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 (_, info) = find_project_by_id(&id)?; - Ok(project_info_from_stored(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::StoredProjectInfo; - - #[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"]); - } -} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index ce630222e5..193b180700 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -50,15 +50,6 @@ pub fn run() { commands::agents::get_avatars_dir, commands::acp::get_goose_serve_url, commands::acp::get_goose_serve_host_info, - 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 1212ebc281..ae73736cd4 100644 --- a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts +++ b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts @@ -15,7 +15,6 @@ import { selectProjects } from "@/features/projects/stores/projectSelectors"; import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog"; import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { - buildProjectSystemPrompt, composeSystemPrompt, resolveProjectDefaultArtifactRoot, } from "@/features/projects/lib/chatProjectContext"; @@ -31,6 +30,7 @@ import { useResolvedAgentModelPicker, type PreferredModelSelection, } from "./useResolvedAgentModelPicker"; +import { updateSessionProject } from "@/shared/api/acpApi"; interface UseChatSessionControllerOptions { sessionId: string | null; @@ -147,22 +147,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( @@ -332,6 +324,9 @@ export function useChatSessionController({ null); useChatSessionStore.getState().patchSession(sessionId, { projectId }); + + void updateSessionProject(sessionId, projectId).catch(console.error); + if (!selectedProvider) { return; } @@ -735,6 +730,9 @@ export function useChatSessionController({ } if (hasPendingProject) { patch.projectId = nextProjectId ?? null; + void updateSessionProject(sessionId, nextProjectId ?? null).catch( + console.error, + ); } useChatSessionStore.getState().patchSession(sessionId, patch); diff --git a/ui/goose2/src/features/projects/api/projects.ts b/ui/goose2/src/features/projects/api/projects.ts index 7d338387e8..9b72b9d344 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,8 +16,86 @@ export interface ProjectInfo { useWorktrees: boolean; order: number; archivedAt: string | null; - createdAt: string; - updatedAt: 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"; +} + +/** Pick a slug for `name` that does not collide with any existing project ID + * (active or archived). Two display names that normalize to the same slug + * (e.g. "My App" and "my-app", or both collapsing to "project" because they + * contain no ASCII alphanumerics) are disambiguated with a numeric suffix. */ +function uniqueProjectSlug(name: string, existingIds: Set): string { + const base = slugify(name); + if (!existingIds.has(base)) { + return base; + } + let counter = 2; + while (existingIds.has(`${base}-${counter}`)) { + counter += 1; + } + return `${base}-${counter}`; } export interface ProjectIconCandidate { @@ -29,7 +110,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( @@ -53,67 +142,112 @@ export async function createProject( workingDirs: string[], useWorktrees: boolean, ): Promise { - return invoke("create_project", { - name, + const client = await getClient(); + const existing = await listAllProjects(); + const id = uniqueProjectSlug(name, new Set(existing.map((p) => p.id))); + 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( + 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 deleteProject(id: string): Promise { - return invoke("delete_project", { id }); -} - 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 b1c915e56c..203521fb1c 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,44 +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"], - 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( - "Default working directory: /Users/wesb/dev/goose2", - ); - expect(systemPrompt).toContain("Preferred provider: goose"); - expect(systemPrompt).toContain( - "Use git worktrees for branch isolation: yes", - ); - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain( - "Use /Users/wesb/dev/goose2 as the default working directory for this project.", - ); - 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"), diff --git a/ui/goose2/src/features/projects/lib/chatProjectContext.ts b/ui/goose2/src/features/projects/lib/chatProjectContext.ts index 9ba9377c7b..c94645a7e3 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; @@ -36,7 +37,7 @@ export function getProjectArtifactRoots( } export function resolveProjectDefaultArtifactRoot( - project: ProjectInfo | null | undefined, + project: Pick | null | undefined, ): string | undefined { const workingDirs = resolveProjectRoots(project); return workingDirs[0]; @@ -56,61 +57,6 @@ export function getProjectFolderOption( })); } -export function buildProjectSystemPrompt( - project: ProjectInfo | null | undefined, -): string | undefined { - if (!project) { - return undefined; - } - - const workingDir = resolveProjectDefaultArtifactRoot(project); - const settings: string[] = [`Project name: ${project.name}`]; - const description = trimValue(project.description); - const workingDirs = resolveProjectRoots(project); - const prompt = trimValue(project.prompt); - - if (description) { - settings.push(`Project description: ${description}`); - } - if (workingDirs.length > 0) { - settings.push(`Working directories: ${workingDirs.join(", ")}`); - } - if (workingDir) { - settings.push(`Default working directory: ${workingDir}`); - } - 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 (workingDir) { - sections.push( - `\n` + - `Use ${workingDir} as the default working directory for this project.\n` + - `Write newly generated files relative to ${workingDir} by default.\n` + - `Only write outside ${workingDir} when the user explicitly asks you to edit or create a file at a specific path.\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 47a071cfea..8f19bb55e0 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,8 +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", ...overrides, }; } diff --git a/ui/goose2/src/features/projects/stores/projectStore.ts b/ui/goose2/src/features/projects/stores/projectStore.ts index a0bdfa28b2..68fdfea58b 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 bf1687444e..05ace40aa3 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 da09860fb9..dad92ca5ac 100644 --- a/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx +++ b/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx @@ -28,6 +28,7 @@ vi.mock("@/shared/api/system", () => ({ vi.mock("../../api/projects", () => ({ createProject: vi.fn().mockResolvedValue({ id: "new-1", + path: "/tmp/projects/new-1.md", name: "Test", description: "", prompt: "", @@ -39,11 +40,10 @@ vi.mock("../../api/projects", () => ({ useWorktrees: false, order: 0, archivedAt: null, - createdAt: "2024-01-01", - updatedAt: "2024-01-01", }), updateProject: vi.fn().mockResolvedValue({ id: "proj-1", + path: "/tmp/projects/proj-1.md", name: "Updated", description: "", prompt: "", @@ -55,8 +55,6 @@ vi.mock("../../api/projects", () => ({ useWorktrees: false, order: 0, archivedAt: null, - createdAt: "2024-01-01", - updatedAt: "2024-01-01", }), scanProjectIcons: vi.fn().mockResolvedValue([]), readProjectIcon: vi.fn().mockResolvedValue({ @@ -93,6 +91,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,8 +103,6 @@ function makeEditingProject(overrides: Partial = {}): ProjectInfo { useWorktrees: false, order: 0, archivedAt: null, - createdAt: "2024-01-01", - updatedAt: "2024-01-01", ...overrides, }; } diff --git a/ui/goose2/src/features/skills/api/skills.test.ts b/ui/goose2/src/features/skills/api/skills.test.ts index 9d20b2589a..229d512310 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, }, ], @@ -38,7 +38,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, }, { @@ -46,7 +46,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, }, ], @@ -97,7 +97,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, }, ], @@ -133,7 +133,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, }, ], @@ -147,7 +147,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, }, ], @@ -172,7 +172,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, }, ], @@ -185,7 +185,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, }, ], @@ -219,7 +219,7 @@ describe("listSkills", () => { name: "personal-review", description: "Reviews personal code", content: "Review local changes", - directory: "/Users/test/.agents/skills/personal-review", + path: "/Users/test/.agents/skills/personal-review", global: true, }, ], @@ -231,7 +231,7 @@ describe("listSkills", () => { name: "goose-doc-guide", description: "Goose documentation guide", content: "Use Goose docs", - directory: "builtin://skills/goose-doc-guide", + path: "builtin://skills/goose-doc-guide", global: true, }, ], @@ -243,7 +243,7 @@ describe("listSkills", () => { name: "project-helper", description: "Helps project work", content: "Use project context", - directory: "/tmp/alpha/.agents/skills/project-helper", + path: "/tmp/alpha/.agents/skills/project-helper", global: false, }, ], diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index ebc488e752..24fadcc75b 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -62,8 +62,8 @@ function toSkillInfo(source: SkillSourceEntry): SkillInfo { name: source.name, description: source.description, instructions: source.content, - path: source.directory, - fileLocation: source.directory, + path: source.path, + fileLocation: source.path, sourceKind: "builtin", sourceLabel: "Built in", projectLinks: [], @@ -71,10 +71,21 @@ 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 ? [ @@ -87,12 +98,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", @@ -104,10 +115,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({ @@ -115,7 +133,8 @@ export async function createSkill( name, description, content: instructions, - global: true, + global: !options.projectId, + ...(options.projectId ? { projectId: options.projectId } : {}), }); } @@ -164,7 +183,7 @@ export async function listSkills( const key = source.type === BUILTIN_SKILL_SOURCE_TYPE ? `builtin:${source.name}` - : `${source.global ? "global" : "project"}:${source.directory}`; + : `${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 de289d2002..08d16c41ab 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 */}