From dccd332f909584bafb3120fda3bfc50af8ae30ff Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 22 Apr 2026 08:14:17 -0700 Subject: [PATCH 1/2] feat: projects as backend sources with system prompt injection Move projects from Tauri IPC commands to the ACP sources system. Backend: - Add Project to SourceType, properties bag on SourceEntry - Project storage in Paths::data_dir()/projects/ - System prompt injection in agent reply via load_project_instructions - _goose/session/set_project ACP method - includeProjectSources flag for listing skills from project working dirs - projectId convenience on CreateSourceRequest for project-scoped skills Frontend: - Rewrite projects API from Tauri invoke to ACP ext methods - Remove buildProjectSystemPrompt (backend handles it now) - Filter archived projects, sort by persisted order - updateProject preserves existing order - Add project picker to CreateSkillDialog - Show project badges on non-global skills - Fix SDK listSessions rename, TS strict errors Delete ui/goose2/src-tauri/src/commands/projects.rs (508 lines). Signed-off-by: Douwe Osinga --- crates/goose-sdk/src/custom_requests.rs | 30 + crates/goose/acp-meta.json | 5 + crates/goose/acp-schema.json | 60 +- crates/goose/src/acp/server.rs | 36 +- crates/goose/src/agents/agent.rs | 23 + crates/goose/src/sources.rs | 606 ++++++++++++++++-- ui/goose2/src-tauri/src/commands/mod.rs | 1 - ui/goose2/src-tauri/src/commands/projects.rs | 508 --------------- ui/goose2/src-tauri/src/lib.rs | 9 - ui/goose2/src/app/AppShell.tsx | 21 +- .../chat/hooks/useChatSessionController.ts | 25 +- .../src/features/projects/api/projects.ts | 226 +++++-- .../projects/lib/chatProjectContext.test.ts | 55 +- .../projects/lib/chatProjectContext.ts | 76 +-- .../projects/lib/sessionCwdSelection.test.ts | 33 +- .../projects/lib/sessionCwdSelection.ts | 5 - .../features/projects/stores/projectStore.ts | 7 +- .../projects/ui/CreateProjectDialog.tsx | 26 +- .../src/features/projects/ui/ProjectsView.tsx | 31 +- .../ui/__tests__/CreateProjectDialog.test.tsx | 2 + ui/goose2/src/features/skills/api/skills.ts | 51 +- .../features/skills/ui/CreateSkillDialog.tsx | 76 ++- .../src/features/skills/ui/SkillsView.tsx | 30 +- .../ui/__tests__/CreateSkillDialog.test.tsx | 2 + .../skills/ui/__tests__/SkillsView.test.tsx | 4 +- ui/goose2/src/shared/api/acpApi.ts | 18 +- .../src/shared/i18n/locales/en/skills.json | 4 + ui/sdk/src/generated/client.gen.ts | 7 + ui/sdk/src/generated/index.ts | 7 +- ui/sdk/src/generated/types.gen.ts | 45 +- ui/sdk/src/generated/zod.gen.ts | 30 +- ui/sdk/src/goose-client.ts | 2 +- ui/text/server-binary.json | 3 + 33 files changed, 1163 insertions(+), 901 deletions(-) delete mode 100644 ui/goose2/src-tauri/src/commands/projects.rs create mode 100644 ui/text/server-binary.json diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 17ce9458f19e..ddd720b0127b 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -206,6 +206,16 @@ pub struct UnarchiveSessionRequest { pub session_id: String, } +/// Set or clear the project associated with a session. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/session/set_project", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct SetSessionProjectRequest { + pub session_id: String, + /// The source name (kebab-case ID) of the project, or null to clear. + pub project_id: Option, +} + /// Export a session as a JSON string. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/session/export", response = ExportSessionResponse)] @@ -259,6 +269,7 @@ pub struct ProviderConfigKey { pub enum SourceType { #[default] Skill, + Project, } /// A source — a user-editable entity backed by an on-disk directory. Sources @@ -276,6 +287,10 @@ pub struct SourceEntry { /// True when the source lives in the user's global sources directory; false /// when it lives inside a specific project. pub global: bool, + /// 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, } /// Create a new source (global or project-scoped). @@ -292,6 +307,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)] @@ -310,6 +333,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)] @@ -331,6 +358,9 @@ pub struct UpdateSourceRequest { pub global: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub project_dir: Option, + /// Arbitrary key/value metadata. Replaces all existing properties. + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub properties: std::collections::HashMap, } #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 99e048c1e908..16a5c5e5b802 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -105,6 +105,11 @@ "requestType": "UnarchiveSessionRequest", "responseType": "EmptyResponse" }, + { + "method": "_goose/session/set_project", + "requestType": "SetSessionProjectRequest", + "responseType": "EmptyResponse" + }, { "method": "_goose/sources/create", "requestType": "CreateSourceRequest", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index cb6d121998c5..7e27c1ff0d32 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -717,6 +717,27 @@ "x-side": "agent", "x-method": "_goose/session/unarchive" }, + "SetSessionProjectRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "projectId": { + "type": [ + "string", + "null" + ], + "description": "The source name (kebab-case ID) of the project, or null to clear." + } + }, + "required": [ + "sessionId" + ], + "description": "Set or clear the project associated with a session.", + "x-side": "agent", + "x-method": "_goose/session/set_project" + }, "CreateSourceRequest": { "type": "object", "properties": { @@ -741,6 +762,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": [ @@ -757,7 +790,8 @@ "SourceType": { "type": "string", "enum": [ - "skill" + "skill", + "project" ], "description": "The type of source entity." }, @@ -796,6 +830,11 @@ "global": { "type": "boolean", "description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project." + }, + "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": [ @@ -826,6 +865,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 sources. If `type` is omitted, sources of all known types are returned.\nBoth global and project-scoped sources are included when `project_dir` is set.", @@ -871,6 +915,11 @@ "string", "null" ] + }, + "properties": { + "type": "object", + "additionalProperties": {}, + "description": "Arbitrary key/value metadata. Replaces all existing properties." } }, "required": [ @@ -1534,6 +1583,15 @@ "description": "Params for _goose/session/unarchive", "title": "UnarchiveSessionRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/SetSessionProjectRequest" + } + ], + "description": "Params for _goose/session/set_project", + "title": "SetSessionProjectRequest" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index cb171431fe24..5fd5bf69f47f 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -3185,18 +3185,45 @@ impl GooseAcpAgent { Ok(EmptyResponse {}) } + #[custom_method(SetSessionProjectRequest)] + async fn on_set_session_project( + &self, + req: SetSessionProjectRequest, + ) -> Result { + let thread_id = req.session_id.clone(); + let project_id = req.project_id.clone(); + self.update_thread_metadata(&thread_id, move |meta| { + meta.project_id = project_id; + }) + .await?; + Ok(EmptyResponse {}) + } + #[custom_method(CreateSourceRequest)] async fn on_create_source( &self, req: CreateSourceRequest, ) -> Result { + let project_dir = match (&req.project_id, &req.project_dir) { + (Some(pid), _) if !req.global => { + let dirs = crate::sources::project_working_dirs(pid); + Some(dirs.into_iter().next().ok_or_else(|| { + sacp::Error::invalid_params().data(format!( + "Project \"{pid}\" has no working directories configured" + )) + })?) + } + (_, Some(pd)) => Some(pd.clone()), + _ => None, + }; let source = crate::sources::create_source( req.source_type, &req.name, &req.description, &req.content, req.global, - req.project_dir.as_deref(), + project_dir.as_deref(), + req.properties, )?; Ok(CreateSourceResponse { source }) } @@ -3206,7 +3233,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 }) } @@ -3222,6 +3253,7 @@ impl GooseAcpAgent { &req.content, req.global, req.project_dir.as_deref(), + req.properties, )?; Ok(UpdateSourceResponse { source }) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 57446f07d14b..9ce9cce50141 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -334,6 +334,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, @@ -1194,6 +1212,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/sources.rs b/crates/goose/src/sources.rs index 35d0b67805a4..00d427397a4c 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -1,13 +1,15 @@ //! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom //! methods. A source is a user-editable entity stored under a per-scope root //! directory — `~/.agents/skills` for global sources and `/.goose/skills` -//! for project-specific sources. +//! for project-specific sources. Projects are stored under `Paths::data_dir()/projects/`. use crate::agents::platform_extensions::parse_frontmatter; +use crate::config::paths::Paths; use fs_err as fs; use goose_sdk::custom_requests::{SourceEntry, SourceType}; use sacp::Error; use serde::Deserialize; +use std::collections::HashMap; use std::path::{Path, PathBuf}; #[derive(Deserialize)] @@ -16,6 +18,16 @@ struct SkillFront { description: String, } +#[derive(Deserialize)] +struct ProjectFront { + #[serde(default)] + name: String, + #[serde(default)] + description: String, + #[serde(default, flatten)] + properties: HashMap, +} + const GLOBAL_SKILLS_SUBPATH: &[&str] = &[".agents", "skills"]; const PROJECT_SKILLS_SUBPATH: &[&str] = &[".goose", "skills"]; @@ -45,6 +57,10 @@ fn skills_dir_project(project_dir: &str) -> Result { Ok(dir) } +fn projects_dir() -> PathBuf { + Paths::data_dir().join("projects") +} + fn source_base_dir( source_type: SourceType, global: bool, @@ -61,6 +77,7 @@ fn source_base_dir( skills_dir_project(pd) } } + SourceType::Project => Ok(projects_dir()), } } @@ -115,13 +132,85 @@ fn parse_skill_frontmatter(raw: &str) -> (String, String) { } } -fn source_entry( +fn build_project_md( + name: &str, + description: &str, + content: &str, + properties: &HashMap, +) -> String { + let mut fm = serde_yaml::Mapping::new(); + fm.insert( + serde_yaml::Value::String("name".into()), + serde_yaml::Value::String(name.into()), + ); + fm.insert( + serde_yaml::Value::String("description".into()), + serde_yaml::Value::String(description.into()), + ); + for (k, v) in properties { + if k == "name" || k == "description" { + continue; + } + if let Ok(yv) = serde_yaml::to_value(v) { + fm.insert(serde_yaml::Value::String(k.clone()), yv); + } + } + let yaml = serde_yaml::to_string(&fm).unwrap_or_default(); + let mut md = format!("---\n{yaml}---\n"); + if !content.is_empty() { + md.push('\n'); + md.push_str(content); + md.push('\n'); + } + md +} + +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(), + ), + } +} + +/// Resolve the on-disk path for a source entry. +fn source_path(source_type: SourceType, base: &Path, name: &str) -> PathBuf { + match source_type { + SourceType::Skill => base.join(name).join("SKILL.md"), + SourceType::Project => base.join(format!("{name}.md")), + } +} + +/// The directory we report back in SourceEntry.directory. +fn source_dir(source_type: SourceType, base: &Path, name: &str) -> PathBuf { + match source_type { + SourceType::Skill => base.join(name), + SourceType::Project => base.to_path_buf(), + } +} + +fn source_entry_with_props( source_type: SourceType, name: &str, description: &str, content: &str, dir: &Path, global: bool, + properties: HashMap, ) -> SourceEntry { SourceEntry { source_type, @@ -130,6 +219,7 @@ fn source_entry( content: content.to_string(), directory: dir.to_string_lossy().to_string(), global, + properties, } } @@ -140,31 +230,49 @@ pub fn create_source( content: &str, global: bool, project_dir: Option<&str>, + properties: HashMap, ) -> Result { validate_source_name(name)?; - let dir = source_base_dir(source_type, global, project_dir)?.join(name); + let base = source_base_dir(source_type, global, project_dir)?; + let file = source_path(source_type, &base, name); + let dir = source_dir(source_type, &base, name); - if dir.exists() { - return Err( - Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) - ); + match source_type { + SourceType::Skill => { + 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}")) + })?; + } + SourceType::Project => { + if file.exists() { + return Err(Error::invalid_params() + .data(format!("A source named \"{}\" already exists", name))); + } + fs::create_dir_all(&base).map_err(|e| { + Error::internal_error().data(format!("Failed to create projects directory: {e}")) + })?; + } } - 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}")))?; + let md = match source_type { + SourceType::Skill => build_skill_md(name, description, content), + SourceType::Project => build_project_md(name, description, content, &properties), + }; + fs::write(&file, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write source file: {e}")))?; - Ok(source_entry( + Ok(source_entry_with_props( source_type, name, description, content, &dir, global, + properties, )) } @@ -175,26 +283,32 @@ pub fn update_source( content: &str, global: bool, project_dir: Option<&str>, + properties: HashMap, ) -> Result { validate_source_name(name)?; - let dir = source_base_dir(source_type, global, project_dir)?.join(name); + let base = source_base_dir(source_type, global, project_dir)?; + let file = source_path(source_type, &base, name); + let dir = source_dir(source_type, &base, name); - if !dir.exists() { + if !file.exists() { return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); } - 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}")))?; + let md = match source_type { + SourceType::Skill => build_skill_md(name, description, content), + SourceType::Project => build_project_md(name, description, content, &properties), + }; + fs::write(&file, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write source file: {e}")))?; - Ok(source_entry( + Ok(source_entry_with_props( source_type, name, description, content, &dir, global, + properties, )) } @@ -205,23 +319,39 @@ pub fn delete_source( project_dir: Option<&str>, ) -> Result<(), Error> { validate_source_name(name)?; - let dir = source_base_dir(source_type, global, project_dir)?.join(name); + let base = source_base_dir(source_type, global, project_dir)?; - if !dir.exists() { - return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); + match source_type { + SourceType::Skill => { + let dir = base.join(name); + if !dir.exists() { + return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); + } + fs::remove_dir_all(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to delete source: {e}")) + })?; + } + SourceType::Project => { + let file = base.join(format!("{name}.md")); + if !file.exists() { + return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); + } + fs::remove_file(&file).map_err(|e| { + Error::internal_error().data(format!("Failed to delete source: {e}")) + })?; + } } - fs::remove_dir_all(&dir) - .map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?; Ok(()) } pub fn list_sources( source_type: Option, project_dir: Option<&str>, + include_project_sources: bool, ) -> Result, Error> { let kinds: Vec = match source_type { Some(k) => vec![k], - None => vec![SourceType::Skill], + None => vec![SourceType::Skill, SourceType::Project], }; let mut sources = Vec::new(); @@ -231,11 +361,43 @@ pub fn list_sources( if let Some(pd) = project_dir { if !pd.trim().is_empty() { let dir = skills_dir_project(pd)?; - sources.extend(read_skill_dir(&dir, false)?); + sources.extend(read_skill_dir(&dir, false, Some(pd), None)?); } } + + if include_project_sources { + let projects = read_project_dir()?; + 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 { + if project_dir == Some(wd.as_str()) { + continue; // already scanned above + } + let dir = skills_dir_project(wd)?; + sources.extend(read_skill_dir( + &dir, + false, + Some(wd), + Some(project_name), + )?); + } + } + } + let dir = skills_dir_global()?; - sources.extend(read_skill_dir(&dir, true)?); + sources.extend(read_skill_dir(&dir, true, None, None)?); + } + SourceType::Project => { + sources.extend(read_project_dir()?); } } } @@ -243,7 +405,17 @@ pub fn list_sources( Ok(sources) } -fn read_skill_dir(dir: &Path, global: bool) -> Result, Error> { +/// Read all skills from a skills directory. +/// +/// * `project_root` – when present, stored as the `projectDir` property so the +/// frontend can pass it back for update/delete operations. +/// * `project_name` – human-readable project name shown as a badge in the UI. +fn read_skill_dir( + dir: &Path, + global: bool, + project_root: Option<&str>, + project_name: Option<&str>, +) -> Result, Error> { if !dir.exists() { return Ok(Vec::new()); } @@ -267,18 +439,128 @@ fn read_skill_dir(dir: &Path, global: bool) -> Result, Error> { .to_string(); let raw = fs::read_to_string(&skill_md).unwrap_or_default(); let (description, content) = parse_skill_frontmatter(&raw); - out.push(source_entry( + + let mut props = HashMap::new(); + if let Some(pn) = project_name { + props.insert( + "projectName".into(), + serde_json::Value::String(pn.to_string()), + ); + } + if let Some(pr) = project_root { + props.insert( + "projectDir".into(), + serde_json::Value::String(pr.to_string()), + ); + } + + out.push(source_entry_with_props( SourceType::Skill, &name, &description, &content, &path, global, + props, )); } Ok(out) } +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; + } + let name = path + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + if name.is_empty() { + continue; + } + let raw = fs::read_to_string(&path).unwrap_or_default(); + let (title, description, content, properties) = parse_project_frontmatter(&raw); + let display_name = if title.is_empty() { + name.clone() + } else { + title + }; + out.push(source_entry_with_props( + SourceType::Project, + &name, + &description, + &content, + &dir, + true, + { + let mut p = properties; + if display_name != name { + p.insert("title".into(), serde_json::Value::String(display_name)); + } + p + }, + )); + } + Ok(out) +} + +/// Read a single project source by name. +/// Get the working directories configured for a project. +pub fn project_working_dirs(project_id: &str) -> Vec { + let entry = match read_project(project_id) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + entry + .properties + .get("workingDirs") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default() +} + +pub fn read_project(name: &str) -> Result { + validate_source_name(name)?; + let dir = projects_dir(); + let file = dir.join(format!("{name}.md")); + if !file.exists() { + return Err(Error::invalid_params().data(format!("Project \"{}\" not found", name))); + } + let raw = fs::read_to_string(&file) + .map_err(|e| Error::internal_error().data(format!("Failed to read project: {e}")))?; + let (title, description, content, properties) = parse_project_frontmatter(&raw); + let display_name = if title.is_empty() { + name.to_string() + } else { + title + }; + Ok(source_entry_with_props( + SourceType::Project, + name, + &description, + &content, + &dir, + true, + { + let mut p = properties; + if display_name != *name { + p.insert("title".into(), serde_json::Value::String(display_name)); + } + p + }, + )) +} + pub fn export_source( source_type: SourceType, name: &str, @@ -286,27 +568,52 @@ pub fn export_source( project_dir: Option<&str>, ) -> Result<(String, String), Error> { validate_source_name(name)?; - let dir = source_base_dir(source_type, global, project_dir)?.join(name); + let base = source_base_dir(source_type, global, project_dir)?; + let file = source_path(source_type, &base, name); - if !dir.exists() { + if !file.exists() { return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); } - 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 raw = fs::read_to_string(&file) + .map_err(|e| Error::internal_error().data(format!("Failed to read source: {e}")))?; let type_slug = match source_type { SourceType::Skill => "skill", + SourceType::Project => "project", }; - let export = serde_json::json!({ - "version": 1, - "type": type_slug, - "name": name, - "description": description, - "content": content, - }); + + let mut export = match source_type { + SourceType::Skill => { + let (description, content) = parse_skill_frontmatter(&raw); + serde_json::json!({ + "version": 1, + "type": type_slug, + "name": name, + "description": description, + "content": content, + }) + } + SourceType::Project => { + let (title, description, content, properties) = parse_project_frontmatter(&raw); + let mut obj = serde_json::json!({ + "version": 1, + "type": type_slug, + "name": name, + "title": title, + "description": description, + "content": content, + }); + if !properties.is_empty() { + obj["properties"] = serde_json::to_value(&properties).unwrap_or_default(); + } + obj + } + }; + if export.get("version").is_none() { + export["version"] = serde_json::json!(1); + } + let json = serde_json::to_string_pretty(&export) .map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?; let filename = format!("{}.{}.json", name, type_slug); @@ -338,6 +645,7 @@ pub fn import_sources( .unwrap_or("skill") { "skill" => SourceType::Skill, + "project" => SourceType::Project, other => { return Err(Error::invalid_params().data(format!("Unsupported source type: {}", other))); } @@ -355,11 +663,8 @@ pub fn import_sources( 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() { - return Err(Error::invalid_params().data("Source description must not be empty")); - } // Accept both the new `content` key and the legacy skills `instructions` key. let content = value @@ -369,43 +674,56 @@ pub fn import_sources( .unwrap_or("") .to_string(); + let properties: HashMap = value + .get("properties") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + validate_source_name(&name)?; let base = source_base_dir(source_type, global, project_dir)?; + let mut final_name = name.clone(); - if base.join(&final_name).exists() { + let exists = |n: &str| source_path(source_type, &base, n).exists(); + if exists(&final_name) { final_name = format!("{}-imported", name); let mut counter = 2u32; - while base.join(&final_name).exists() { + while exists(&final_name) { final_name = format!("{}-imported-{}", name, counter); counter += 1; } } - 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( + create_source( source_type, &final_name, &description, &content, - &dir, global, - )]) + project_dir, + properties, + ) + .map(|entry| vec![entry]) } #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; use tempfile::TempDir; + // Tests that set GOOSE_PATH_ROOT must run serially to avoid racing on the + // global env var. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_temp_root(f: impl FnOnce(&std::path::Path)) { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + unsafe { std::env::set_var("GOOSE_PATH_ROOT", tmp.path()) }; + f(tmp.path()); + unsafe { std::env::remove_var("GOOSE_PATH_ROOT") }; + } + #[test] fn kebab_case_validation() { assert!(validate_source_name("my-skill").is_ok()); @@ -430,13 +748,14 @@ mod tests { "step one\nstep two", false, Some(project), + HashMap::new(), ) .unwrap(); assert_eq!(created.name, "my-skill"); assert!(!created.global); assert!(PathBuf::from(&created.directory).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( @@ -446,6 +765,7 @@ mod tests { "step three", false, Some(project), + HashMap::new(), ) .unwrap(); assert_eq!(updated.description, "now does a different thing"); @@ -459,15 +779,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")); } @@ -486,6 +832,7 @@ mod tests { "body goes here", false, Some(project_a.to_str().unwrap()), + HashMap::new(), ) .unwrap(); @@ -510,7 +857,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, @@ -523,4 +879,116 @@ mod tests { let imported = import_sources(&payload, false, Some(project)).unwrap(); assert_eq!(imported[0].name, "busy-imported"); } + + #[test] + fn project_crud_with_properties() { + with_temp_root(|_root| { + let mut props = HashMap::new(); + props.insert("icon".into(), serde_json::json!("🚀")); + props.insert( + "workingDirs".into(), + serde_json::json!(["/Users/me/code/myapp"]), + ); + + let created = create_source( + SourceType::Project, + "my-app", + "A web application", + "Build with React.\nUse TypeScript.", + true, + None, + props.clone(), + ) + .unwrap(); + assert_eq!(created.name, "my-app"); + assert_eq!(created.description, "A web application"); + assert_eq!(created.content, "Build with React.\nUse TypeScript."); + assert_eq!(created.properties.get("icon").unwrap(), "🚀"); + + let listed = list_sources(Some(SourceType::Project), None, false).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, "my-app"); + assert_eq!(listed[0].properties.get("icon").unwrap(), "🚀"); + + let read_back = read_project("my-app").unwrap(); + assert_eq!(read_back.description, "A web application"); + + let updated = update_source( + SourceType::Project, + "my-app", + "Updated description", + "New instructions", + true, + None, + { + let mut p = props.clone(); + p.insert("color".into(), serde_json::json!("#ff0000")); + p + }, + ) + .unwrap(); + assert_eq!(updated.description, "Updated description"); + assert!(updated.properties.contains_key("color")); + + delete_source(SourceType::Project, "my-app", true, None).unwrap(); + assert!(read_project("my-app").is_err()); + }); + } + + #[test] + fn list_skills_includes_project_scoped() { + with_temp_root(|root| { + let work_dir = root.join("code").join("myapp"); + std::fs::create_dir_all(&work_dir).unwrap(); + + let mut props = HashMap::new(); + props.insert( + "workingDirs".into(), + serde_json::json!([work_dir.to_str().unwrap()]), + ); + props.insert("title".into(), serde_json::json!("My App")); + create_source( + SourceType::Project, + "my-app", + "test project", + "", + true, + None, + props, + ) + .unwrap(); + + create_source( + SourceType::Skill, + "local-helper", + "helps locally", + "do the thing", + false, + Some(work_dir.to_str().unwrap()), + HashMap::new(), + ) + .unwrap(); + + let without = list_sources(Some(SourceType::Skill), None, false).unwrap(); + assert!( + !without.iter().any(|s| s.name == "local-helper"), + "should not appear without includeProjectSources" + ); + + let with = list_sources(Some(SourceType::Skill), None, true).unwrap(); + let found = with.iter().find(|s| s.name == "local-helper"); + assert!(found.is_some(), "should appear with includeProjectSources"); + let skill = found.unwrap(); + assert!(!skill.global); + assert_eq!( + skill.properties.get("projectName").and_then(|v| v.as_str()), + Some("My App") + ); + assert!(skill + .properties + .get("projectDir") + .and_then(|v| v.as_str()) + .is_some()); + }); + } } diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index 8acd3c56ab61..d9bdfbe0f33a 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; pub mod git_changes; pub mod model_setup; pub mod path_resolver; -pub mod projects; pub mod system; diff --git a/ui/goose2/src-tauri/src/commands/projects.rs b/ui/goose2/src-tauri/src/commands/projects.rs deleted file mode 100644 index 31c129c16f91..000000000000 --- a/ui/goose2/src-tauri/src/commands/projects.rs +++ /dev/null @@ -1,508 +0,0 @@ -use serde::Deserialize; -use std::fs; -use std::path::{Path, PathBuf}; - -fn projects_dir() -> Result { - let home = dirs::home_dir().ok_or("Could not determine home directory")?; - Ok(home.join(".goose").join("projects")) -} - -fn generate_id() -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - use std::time::{SystemTime, UNIX_EPOCH}; - - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let mut hasher = DefaultHasher::new(); - nanos.hash(&mut hasher); - std::process::id().hash(&mut hasher); - let h1 = hasher.finish(); - // Hash again with a different seed for more bits - h1.hash(&mut hasher); - let h2 = hasher.finish(); - format!( - "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", - (h1 >> 32) as u32, - (h1 >> 16) as u16, - h1 as u16, - (h2 >> 48) as u16, - h2 & 0xffffffffffff - ) -} - -fn now_timestamp() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - millis.to_string() -} - -fn slugify(name: &str) -> String { - let slug: String = name - .to_lowercase() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - if slug.is_empty() { - "project".to_string() - } else { - slug - } -} - -/// Scan all project directories and find the one whose project.json has the given id. -/// Returns (dir_path, ProjectInfo). -fn find_project_by_id(id: &str) -> Result<(PathBuf, StoredProjectInfo), String> { - let base = projects_dir()?; - if !base.exists() { - return Err(format!("Project with id \"{}\" not found", id)); - } - - let entries = fs::read_dir(&base).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - let raw = match fs::read_to_string(&project_json) { - Ok(r) => r, - Err(_) => continue, - }; - let info: StoredProjectInfo = match serde_json::from_str(&raw) { - Ok(i) => i, - Err(_) => continue, - }; - if info.id == id { - return Ok((path, info)); - } - } - - Err(format!("Project with id \"{}\" not found", id)) -} - -fn deserialize_working_dirs<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum WorkingDirsField { - Many(Vec), - One(String), - Null, - } - - let value = Option::::deserialize(deserializer)?; - let dirs = match value { - Some(WorkingDirsField::Many(dirs)) => dirs, - Some(WorkingDirsField::One(dir)) => vec![dir], - Some(WorkingDirsField::Null) | None => Vec::new(), - }; - - Ok(dirs - .into_iter() - .map(|dir| dir.trim().to_string()) - .filter(|dir| !dir.is_empty()) - .collect()) -} - -fn project_artifacts_dir(project_dir: &Path) -> String { - project_dir.join("artifacts").to_string_lossy().into_owned() -} - -#[derive(serde::Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -struct StoredProjectInfo { - pub id: String, - pub name: String, - pub description: String, - pub prompt: String, - pub icon: String, - pub color: String, - pub preferred_provider: Option, - pub preferred_model: Option, - #[serde( - default, - alias = "workingDir", - deserialize_with = "deserialize_working_dirs" - )] - pub working_dirs: Vec, - pub use_worktrees: bool, - #[serde(default)] - pub order: i32, - #[serde(default)] - pub archived_at: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(serde::Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ProjectInfo { - pub id: String, - pub name: String, - pub description: String, - pub prompt: String, - pub icon: String, - pub color: String, - pub preferred_provider: Option, - pub preferred_model: Option, - #[serde(default)] - pub working_dirs: Vec, - pub use_worktrees: bool, - #[serde(default)] - pub order: i32, - #[serde(default)] - pub archived_at: Option, - pub created_at: String, - pub updated_at: String, - pub artifacts_dir: String, -} - -fn project_info_from_stored(project_dir: &Path, stored: StoredProjectInfo) -> ProjectInfo { - ProjectInfo { - id: stored.id, - name: stored.name, - description: stored.description, - prompt: stored.prompt, - icon: stored.icon, - color: stored.color, - preferred_provider: stored.preferred_provider, - preferred_model: stored.preferred_model, - working_dirs: stored.working_dirs, - use_worktrees: stored.use_worktrees, - order: stored.order, - archived_at: stored.archived_at, - created_at: stored.created_at, - updated_at: stored.updated_at, - artifacts_dir: project_artifacts_dir(project_dir), - } -} - -#[tauri::command] -pub fn list_projects() -> Result, String> { - let dir = projects_dir()?; - - if !dir.exists() { - return Ok(vec![]); - } - - let mut projects = Vec::new(); - let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - - let raw = match fs::read_to_string(&project_json) { - Ok(r) => r, - Err(_) => continue, - }; - let info: StoredProjectInfo = match serde_json::from_str(&raw) { - Ok(i) => i, - Err(_) => continue, - }; - - projects.push(project_info_from_stored(&path, info)); - } - - projects.sort_by_key(|p| p.order); - projects.retain(|p| p.archived_at.is_none()); - Ok(projects) -} - -#[tauri::command] -pub fn list_archived_projects() -> Result, String> { - let dir = projects_dir()?; - if !dir.exists() { - return Ok(vec![]); - } - - let mut projects = Vec::new(); - let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - let raw = fs::read_to_string(&project_json).unwrap_or_default(); - if let Ok(info) = serde_json::from_str::(&raw) { - if info.archived_at.is_some() { - projects.push(project_info_from_stored(&path, info)); - } - } - } - - projects.sort_by_key(|p| p.order); - Ok(projects) -} - -#[allow(clippy::too_many_arguments)] -#[tauri::command] -pub fn create_project( - name: String, - description: String, - prompt: String, - icon: String, - color: String, - preferred_provider: Option, - preferred_model: Option, - working_dirs: Vec, - use_worktrees: bool, -) -> Result { - if name.trim().is_empty() { - return Err("Project name must not be empty".to_string()); - } - - let base = projects_dir()?; - let slug = slugify(&name); - - // Determine final directory name, avoiding collisions - let mut dir_name = slug.clone(); - if base.join(&dir_name).exists() { - let mut counter = 2u32; - loop { - dir_name = format!("{}-{}", slug, counter); - if !base.join(&dir_name).exists() { - break; - } - counter += 1; - } - } - - let existing_count = if base.exists() { - fs::read_dir(&base) - .map(|entries| entries.flatten().filter(|e| e.path().is_dir()).count()) - .unwrap_or(0) - } else { - 0 - }; - - let dir = base.join(&dir_name); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create project directory: {}", e))?; - - let now = now_timestamp(); - let stored = StoredProjectInfo { - id: generate_id(), - name, - description, - prompt, - icon, - color, - preferred_provider, - preferred_model, - working_dirs, - use_worktrees, - order: existing_count as i32, - archived_at: None, - created_at: now.clone(), - updated_at: now, - }; - - let project_path = dir.join("project.json"); - let json = serde_json::to_string_pretty(&stored) - .map_err(|e| format!("Failed to serialize project: {}", e))?; - fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?; - - Ok(project_info_from_stored(&dir, stored)) -} - -#[allow(clippy::too_many_arguments)] -#[tauri::command] -pub fn update_project( - id: String, - name: String, - description: String, - prompt: String, - icon: String, - color: String, - preferred_provider: Option, - preferred_model: Option, - working_dirs: Vec, - use_worktrees: bool, -) -> Result { - if name.trim().is_empty() { - return Err("Project name must not be empty".to_string()); - } - - let (dir, existing) = find_project_by_id(&id)?; - - let stored = StoredProjectInfo { - id: existing.id, - name, - description, - prompt, - icon, - color, - preferred_provider, - preferred_model, - working_dirs, - use_worktrees, - order: existing.order, - archived_at: existing.archived_at, - created_at: existing.created_at, - updated_at: now_timestamp(), - }; - - let project_path = dir.join("project.json"); - let json = serde_json::to_string_pretty(&stored) - .map_err(|e| format!("Failed to serialize project: {}", e))?; - fs::write(&project_path, json).map_err(|e| format!("Failed to write project.json: {}", e))?; - - Ok(project_info_from_stored(&dir, stored)) -} - -#[tauri::command] -pub fn delete_project(id: String) -> Result<(), String> { - let (dir, _) = find_project_by_id(&id)?; - fs::remove_dir_all(&dir).map_err(|e| format!("Failed to delete project: {}", e))?; - Ok(()) -} - -#[tauri::command] -pub fn reorder_projects(order: Vec<(String, i32)>) -> Result<(), String> { - for (id, new_order) in order { - let (dir, mut stored) = find_project_by_id(&id)?; - stored.order = new_order; - let project_path = dir.join("project.json"); - let json = serde_json::to_string_pretty(&stored) - .map_err(|e| format!("Failed to serialize project: {}", e))?; - fs::write(&project_path, json) - .map_err(|e| format!("Failed to write project.json: {}", e))?; - } - Ok(()) -} - -#[tauri::command] -pub fn get_project(id: String) -> Result { - let (dir, info) = find_project_by_id(&id)?; - Ok(project_info_from_stored(&dir, info)) -} - -#[tauri::command] -pub fn archive_project(id: String) -> Result<(), String> { - let base = projects_dir()?; - if !base.exists() { - return Err("Projects directory not found".to_string()); - } - - let entries = fs::read_dir(&base).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - let raw = fs::read_to_string(&project_json).unwrap_or_default(); - if let Ok(mut info) = serde_json::from_str::(&raw) { - if info.id == id { - info.archived_at = Some(now_timestamp()); - let json = serde_json::to_string_pretty(&info) - .map_err(|e| format!("Failed to serialize: {}", e))?; - fs::write(&project_json, json).map_err(|e| format!("Failed to write: {}", e))?; - return Ok(()); - } - } - } - - Err(format!("Project with id \"{}\" not found", id)) -} - -#[tauri::command] -pub fn restore_project(id: String) -> Result<(), String> { - let base = projects_dir()?; - if !base.exists() { - return Err("Projects directory not found".to_string()); - } - - let entries = fs::read_dir(&base).map_err(|e| format!("Failed to read projects dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let project_json = path.join("project.json"); - if !project_json.exists() { - continue; - } - let raw = fs::read_to_string(&project_json).unwrap_or_default(); - if let Ok(mut info) = serde_json::from_str::(&raw) { - if info.id == id { - info.archived_at = None; - let json = serde_json::to_string_pretty(&info) - .map_err(|e| format!("Failed to serialize: {}", e))?; - fs::write(&project_json, json).map_err(|e| format!("Failed to write: {}", e))?; - return Ok(()); - } - } - } - - Err(format!("Project with id \"{}\" not found", id)) -} - -#[cfg(test)] -mod tests { - use super::{project_artifacts_dir, StoredProjectInfo}; - use std::path::Path; - - #[test] - fn deserializes_legacy_single_working_dir() { - let project: StoredProjectInfo = serde_json::from_str( - r##"{ - "id": "project-1", - "name": "Legacy", - "description": "", - "prompt": "", - "icon": "📁", - "color": "#000000", - "preferredProvider": null, - "preferredModel": null, - "workingDir": "/tmp/legacy", - "useWorktrees": false, - "createdAt": "now", - "updatedAt": "now" - }"##, - ) - .expect("legacy project"); - - assert_eq!(project.working_dirs, vec!["/tmp/legacy"]); - } - - #[test] - fn builds_project_artifacts_dir_inside_project_storage() { - assert_eq!( - project_artifacts_dir(Path::new("/Users/test/.goose/projects/sample-project")), - "/Users/test/.goose/projects/sample-project/artifacts" - ); - } -} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index f473c45d0b58..bc36c74099de 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -45,15 +45,6 @@ pub fn run() { commands::agents::save_persona_avatar_bytes, commands::agents::get_avatars_dir, commands::acp::get_goose_serve_url, - commands::projects::list_projects, - commands::projects::create_project, - commands::projects::update_project, - commands::projects::delete_project, - commands::projects::get_project, - commands::projects::reorder_projects, - commands::projects::list_archived_projects, - commands::projects::archive_project, - commands::projects::restore_project, commands::doctor::run_doctor, commands::doctor::run_doctor_fix, commands::extensions::list_extensions, diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 41f3c518c921..86299c2606f9 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Sidebar } from "@/features/sidebar/ui/Sidebar"; import { StatusBar } from "@/features/status/ui/StatusBar"; import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; @@ -628,24 +628,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return () => window.removeEventListener("keydown", handler); }, [clearActiveSession, sessionStore]); - const editingProjectProp = useMemo( - () => - editingProject - ? { - id: editingProject.id, - name: editingProject.name, - description: editingProject.description, - prompt: editingProject.prompt, - icon: editingProject.icon, - color: editingProject.color, - preferredProvider: editingProject.preferredProvider, - preferredModel: editingProject.preferredModel, - workingDirs: editingProject.workingDirs, - useWorktrees: editingProject.useWorktrees, - } - : undefined, - [editingProject], - ); + const editingProjectProp = editingProject ?? undefined; return (
diff --git a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts index fcbdc7435dfa..18e58aa40eaf 100644 --- a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts +++ b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts @@ -11,7 +11,6 @@ import { useProviderSelection } from "@/features/agents/hooks/useProviderSelecti import { useProjectStore } from "@/features/projects/stores/projectStore"; import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/providerCatalog"; import { - buildProjectSystemPrompt, composeSystemPrompt, getProjectArtifactRoots, resolveProjectDefaultArtifactRoot, @@ -28,6 +27,7 @@ import { useResolvedAgentModelPicker, type PreferredModelSelection, } from "./useResolvedAgentModelPicker"; +import { setSessionProject } from "@/shared/api/acpApi"; interface UseChatSessionControllerOptions { sessionId: string | null; @@ -132,22 +132,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( @@ -165,7 +157,10 @@ export function useChatSessionController({ nextProject, nextWorkspacePath, ); - await acpPrepareSession(sessionId, providerId, workingDir, { personaId }); + await acpPrepareSession(sessionId, providerId, workingDir, { + personaId, + projectId: nextProject?.id, + }); if (!modelSelection?.id) { return; } @@ -303,6 +298,9 @@ export function useChatSessionController({ null); useChatSessionStore.getState().updateSession(sessionId, { projectId }); + + void setSessionProject(sessionId, projectId).catch(console.error); + if (!selectedProvider) { return; } @@ -656,6 +654,9 @@ export function useChatSessionController({ } if (hasPendingProject) { patch.projectId = nextProjectId ?? null; + void setSessionProject(sessionId, nextProjectId ?? null).catch( + console.error, + ); } useChatSessionStore.getState().updateSession(sessionId, patch); diff --git a/ui/goose2/src/features/projects/api/projects.ts b/ui/goose2/src/features/projects/api/projects.ts index 405ca70a335e..af0759116893 100644 --- a/ui/goose2/src/features/projects/api/projects.ts +++ b/ui/goose2/src/features/projects/api/projects.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { getClient } from "@/shared/api/acpConnection"; export interface ProjectInfo { id: string; @@ -13,13 +13,70 @@ export interface ProjectInfo { useWorktrees: boolean; order: number; archivedAt: string | null; - createdAt: string; - updatedAt: string; - artifactsDir: string; +} + +// Shape returned by _goose/sources/*. Narrowed to project-type sources here. +interface SourceEntry { + type: "project"; + name: string; + description: string; + content: string; + directory: string; + global: boolean; + properties: Record; +} + +function toProjectInfo(source: SourceEntry): ProjectInfo { + const p = source.properties ?? {}; + return { + id: source.name, + 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, + }; +} + +function toProperties( + info: Omit, +): 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; + return props; +} + +function slugify(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "project"; } 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 createProject( @@ -33,67 +90,148 @@ export async function createProject( workingDirs: string[], useWorktrees: boolean, ): Promise { - return invoke("create_project", { - name, + const client = await getClient(); + const id = slugify(name); + const raw = await client.extMethod("_goose/sources/create", { + type: "project", + name: id, description, - prompt, - icon, - color, - preferredProvider, - preferredModel, - workingDirs, - useWorktrees, + content: prompt, + global: true, + properties: toProperties({ + name, + icon, + color, + preferredProvider, + preferredModel, + workingDirs, + useWorktrees, + order: 0, + archivedAt: null, + }), }); + return toProjectInfo(raw.source as SourceEntry); } export async function updateProject( - id: string, - name: string, - description: string, - prompt: string, - icon: string, - color: string, - preferredProvider: string | null, - preferredModel: string | null, - workingDirs: string[], - useWorktrees: boolean, + existing: ProjectInfo, + updates: Partial>, ): Promise { - return invoke("update_project", { - id, - name, - description, - prompt, - icon, - color, - preferredProvider, - preferredModel, - workingDirs, - useWorktrees, + const merged = { ...existing, ...updates }; + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/update", { + type: "project", + name: existing.id, + description: merged.description, + content: merged.prompt, + global: true, + properties: toProperties({ + name: merged.name, + icon: merged.icon, + color: merged.color, + preferredProvider: merged.preferredProvider, + preferredModel: merged.preferredModel, + workingDirs: merged.workingDirs, + useWorktrees: merged.useWorktrees, + order: merged.order, + archivedAt: merged.archivedAt, + }), }); + return toProjectInfo(raw.source as SourceEntry); } export async function deleteProject(id: string): Promise { - return invoke("delete_project", { id }); + const client = await getClient(); + await client.extMethod("_goose/sources/delete", { + type: "project", + name: id, + global: true, + }); } export async function getProject(id: string): Promise { - return invoke("get_project", { id }); + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/list", { + type: "project", + }); + const sources = (raw.sources ?? []) as SourceEntry[]; + const match = sources.find((s) => s.name === id); + if (!match) throw new Error(`Project "${id}" not found`); + return toProjectInfo(match); } -export async function listArchivedProjects(): Promise { - return invoke("list_archived_projects"); +export async function archiveProject(id: string): Promise { + // Read current, update with archivedAt property + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/list", { + type: "project", + }); + const sources = (raw.sources ?? []) as SourceEntry[]; + const existing = sources.find((s) => s.name === id); + if (!existing) throw new Error(`Project "${id}" not found`); + + const props = { ...(existing.properties ?? {}) }; + props.archivedAt = new Date().toISOString(); + + await client.extMethod("_goose/sources/update", { + type: "project", + name: id, + description: existing.description, + content: existing.content, + global: true, + properties: props, + }); } -export async function archiveProject(id: string): Promise { - return invoke("archive_project", { id }); +export async function restoreProject(id: string): Promise { + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/list", { + type: "project", + }); + const sources = (raw.sources ?? []) as SourceEntry[]; + const existing = sources.find((s) => s.name === id); + if (!existing) throw new Error(`Project "${id}" not found`); + + const props = { ...(existing.properties ?? {}) }; + delete props.archivedAt; + + await client.extMethod("_goose/sources/update", { + type: "project", + name: id, + description: existing.description, + content: existing.content, + global: true, + properties: props, + }); } export async function reorderProjects( order: [string, number][], ): Promise { - return invoke("reorder_projects", { order }); + const client = await getClient(); + // Update each project's order property + for (const [id, orderValue] of order) { + const raw = await client.extMethod("_goose/sources/list", { + type: "project", + }); + const sources = (raw.sources ?? []) as SourceEntry[]; + const existing = sources.find((s) => s.name === id); + if (!existing) continue; + + const props = { ...(existing.properties ?? {}), order: orderValue }; + await client.extMethod("_goose/sources/update", { + type: "project", + name: id, + description: existing.description, + content: existing.content, + global: true, + properties: props, + }); + } } -export async function restoreProject(id: string): Promise { - return invoke("restore_project", { id }); +export async function listArchivedProjects(): Promise { + // List all, filter for archived + const all = await listProjects(); + return all.filter((p) => p.archivedAt !== null); } diff --git a/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts b/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts index 9420a9dea066..526ee8f695ce 100644 --- a/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts +++ b/ui/goose2/src/features/projects/lib/chatProjectContext.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - buildProjectSystemPrompt, composeSystemPrompt, getProjectArtifactRoots, getProjectFolderName, @@ -8,45 +7,6 @@ import { } from "./chatProjectContext"; describe("chatProjectContext", () => { - it("builds project instructions from stored project settings", () => { - const systemPrompt = buildProjectSystemPrompt({ - id: "project-1", - name: "Goose2", - description: "Desktop app", - prompt: "Always read AGENTS.md before editing.", - icon: "folder", - color: "#000000", - preferredProvider: "goose", - preferredModel: "claude-sonnet-4", - workingDirs: ["/Users/wesb/dev/goose2"], - artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts", - useWorktrees: true, - order: 0, - archivedAt: null, - createdAt: "now", - updatedAt: "now", - }); - - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain("Project name: Goose2"); - expect(systemPrompt).toContain( - "Working directories: /Users/wesb/dev/goose2", - ); - expect(systemPrompt).toContain( - "Artifact directory: /Users/wesb/dev/goose2/artifacts", - ); - expect(systemPrompt).toContain("Preferred provider: goose"); - expect(systemPrompt).toContain( - "Use git worktrees for branch isolation: yes", - ); - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain( - "Write newly generated files to /Users/wesb/dev/goose2/artifacts by default.", - ); - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain("Always read AGENTS.md before editing."); - }); - it("combines persona and project prompts without empty sections", () => { expect( composeSystemPrompt("Persona prompt", undefined, "Project prompt"), @@ -62,7 +22,6 @@ describe("chatProjectContext", () => { expect( getProjectFolderOption({ workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"], - artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts", }), ).toEqual([ { @@ -79,18 +38,7 @@ describe("chatProjectContext", () => { }); it("returns an empty array when workingDirs is empty", () => { - expect( - getProjectFolderOption({ - workingDirs: [], - artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts", - }), - ).toEqual([ - { - id: "/Users/wesb/.goose/projects/sample-project/artifacts", - name: "artifacts", - path: "/Users/wesb/.goose/projects/sample-project/artifacts", - }, - ]); + expect(getProjectFolderOption({ workingDirs: [] })).toEqual([]); }); it("returns an empty array when project is null", () => { @@ -101,7 +49,6 @@ describe("chatProjectContext", () => { expect( getProjectArtifactRoots({ workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"], - artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts", }), ).toEqual([ "/Users/wesb/dev/goose2/artifacts", diff --git a/ui/goose2/src/features/projects/lib/chatProjectContext.ts b/ui/goose2/src/features/projects/lib/chatProjectContext.ts index 5b1069725036..66ed8b44b570 100644 --- a/ui/goose2/src/features/projects/lib/chatProjectContext.ts +++ b/ui/goose2/src/features/projects/lib/chatProjectContext.ts @@ -1,5 +1,6 @@ import type { ProjectInfo } from "../api/projects"; import { resolvePath } from "@/shared/api/pathResolver"; + export interface ProjectFolderOption { id: string; name: string; @@ -26,28 +27,23 @@ function appendArtifactsSegment(path: string): string { } function resolveProjectArtifactRoots( - project: Pick | null | undefined, + project: Pick | null | undefined, ): string[] { const workingDirs = (project?.workingDirs ?? []) .map((directory) => trimValue(directory)) .filter((directory): directory is string => directory !== null); - if (workingDirs.length > 0) { - return workingDirs.map(appendArtifactsSegment); - } - - const artifactsDir = trimValue(project?.artifactsDir); - return artifactsDir ? [artifactsDir] : []; + return workingDirs.map(appendArtifactsSegment); } export function getProjectArtifactRoots( - project: Pick | null | undefined, + project: Pick | null | undefined, ): string[] { return resolveProjectArtifactRoots(project); } export function resolveProjectDefaultArtifactRoot( - project: ProjectInfo | null | undefined, + project: Pick | null | undefined, ): string | undefined { const workingDirs = (project?.workingDirs ?? []) .map((directory) => trimValue(directory)) @@ -57,7 +53,7 @@ export function resolveProjectDefaultArtifactRoot( return appendArtifactsSegment(workingDirs[0]); } - return trimValue(project?.artifactsDir) ?? undefined; + return undefined; } export async function defaultGlobalArtifactRoot(): Promise { @@ -65,7 +61,7 @@ export async function defaultGlobalArtifactRoot(): Promise { } export function getProjectFolderOption( - project: Pick | null | undefined, + project: Pick | null | undefined, ): ProjectFolderOption[] { return resolveProjectArtifactRoots(project).map((d) => ({ id: d, @@ -74,64 +70,6 @@ export function getProjectFolderOption( })); } -export function buildProjectSystemPrompt( - project: ProjectInfo | null | undefined, -): string | undefined { - if (!project) { - return undefined; - } - - const artifactDir = resolveProjectDefaultArtifactRoot(project); - const settings: string[] = [`Project name: ${project.name}`]; - const description = trimValue(project.description); - const workingDirs = (project.workingDirs ?? []) - .map((d) => trimValue(d)) - .filter((d): d is string => d !== null); - const prompt = trimValue(project.prompt); - - if (description) { - settings.push(`Project description: ${description}`); - } - if (workingDirs.length > 0) { - settings.push(`Working directories: ${workingDirs.join(", ")}`); - } - if (artifactDir) { - settings.push(`Artifact directory: ${artifactDir}`); - } - if (project.preferredProvider) { - settings.push(`Preferred provider: ${project.preferredProvider}`); - } - if (project.preferredModel) { - settings.push(`Preferred model: ${project.preferredModel}`); - } - settings.push( - `Use git worktrees for branch isolation: ${ - project.useWorktrees ? "yes" : "no" - }`, - ); - - const sections = [ - `\n${settings.join("\n")}\n`, - ]; - - if (artifactDir) { - sections.push( - `\n` + - `Write newly generated files to ${artifactDir} by default.\n` + - `When creating translations, variants, summaries, or derived documents from existing project files, save the new file in ${artifactDir} instead of the project root.\n` + - `Only write outside ${artifactDir} when the user explicitly asks you to edit or create a file at a specific path.\n` + - `If you need to read existing files elsewhere in the project, that is fine, but generated outputs should stay in ${artifactDir} unless the user says otherwise.\n` + - ``, - ); - } - - if (prompt) { - sections.push(`\n${prompt}\n`); - } - - return sections.join("\n\n"); -} - export function composeSystemPrompt( ...parts: Array ): string | undefined { diff --git a/ui/goose2/src/features/projects/lib/sessionCwdSelection.test.ts b/ui/goose2/src/features/projects/lib/sessionCwdSelection.test.ts index f25f8da10ce6..c18b3954a9e8 100644 --- a/ui/goose2/src/features/projects/lib/sessionCwdSelection.test.ts +++ b/ui/goose2/src/features/projects/lib/sessionCwdSelection.test.ts @@ -25,9 +25,6 @@ function makeProject(overrides: Partial = {}): ProjectInfo { useWorktrees: false, order: 0, archivedAt: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - artifactsDir: "", ...overrides, }; } @@ -42,46 +39,24 @@ describe("sessionCwdSelection", () => { resolveProjectDefaultArtifactRoot( makeProject({ workingDirs: ["/Users/wesb/dev/goose2", "/Users/wesb/dev/other"], - artifactsDir: "/Users/wesb/.goose/projects/goose2/artifacts", }), ), ).toBe("/Users/wesb/dev/goose2/artifacts"); }); - it("falls back to the stored project artifact root when no workspace roots exist", () => { + it("returns undefined when no workspace roots exist", () => { expect( - resolveProjectDefaultArtifactRoot( - makeProject({ - workingDirs: [], - artifactsDir: "/Users/wesb/.goose/projects/sample-project/artifacts", - }), - ), - ).toBe("/Users/wesb/.goose/projects/sample-project/artifacts"); - }); - - it("returns undefined for a pathless project artifact root", () => { - expect( - resolveProjectDefaultArtifactRoot( - makeProject({ - workingDirs: [], - artifactsDir: " ", - }), - ), + resolveProjectDefaultArtifactRoot(makeProject({ workingDirs: [] })), ).toBeUndefined(); }); - it("falls back to global artifacts for a pathless project session cwd", async () => { + it("falls back to global artifacts for a project without working dirs", async () => { vi.mocked(resolvePath).mockResolvedValue({ path: "/Users/wesb/.goose/artifacts", }); await expect( - resolveSessionCwd( - makeProject({ - workingDirs: [], - artifactsDir: " ", - }), - ), + resolveSessionCwd(makeProject({ workingDirs: [] })), ).resolves.toBe("/Users/wesb/.goose/artifacts"); expect(resolvePath).toHaveBeenCalledWith({ diff --git a/ui/goose2/src/features/projects/lib/sessionCwdSelection.ts b/ui/goose2/src/features/projects/lib/sessionCwdSelection.ts index 064585d82e12..e5e3e659d9ab 100644 --- a/ui/goose2/src/features/projects/lib/sessionCwdSelection.ts +++ b/ui/goose2/src/features/projects/lib/sessionCwdSelection.ts @@ -22,11 +22,6 @@ function buildSessionCwdParts( return [workingDirs[0], "artifacts"]; } - const artifactRoot = trimValue(project?.artifactsDir); - if (artifactRoot) { - return [artifactRoot]; - } - return ["~", ".goose", "artifacts"]; } diff --git a/ui/goose2/src/features/projects/stores/projectStore.ts b/ui/goose2/src/features/projects/stores/projectStore.ts index 931b7a02f3d8..a4e4a01cc68c 100644 --- a/ui/goose2/src/features/projects/stores/projectStore.ts +++ b/ui/goose2/src/features/projects/stores/projectStore.ts @@ -125,8 +125,9 @@ export const useProjectStore = create((set, get) => ({ workingDirs, useWorktrees, ) => { - const project = await updateProject( - id, + const existing = get().projects.find((p) => p.id === id); + if (!existing) throw new Error(`Project ${id} not found`); + const project = await updateProject(existing, { name, description, prompt, @@ -136,7 +137,7 @@ export const useProjectStore = create((set, get) => ({ preferredModel, workingDirs, useWorktrees, - ); + }); set((state) => ({ projects: state.projects.map((p) => (p.id === id ? project : p)), })); diff --git a/ui/goose2/src/features/projects/ui/CreateProjectDialog.tsx b/ui/goose2/src/features/projects/ui/CreateProjectDialog.tsx index 33fab704ed39..96dd90746cfe 100644 --- a/ui/goose2/src/features/projects/ui/CreateProjectDialog.tsx +++ b/ui/goose2/src/features/projects/ui/CreateProjectDialog.tsx @@ -68,18 +68,7 @@ interface CreateProjectDialogProps { onClose: () => void; onCreated: (project: ProjectInfo) => void; initialWorkingDir?: string | null; - editingProject?: { - id: string; - name: string; - description: string; - prompt: string; - icon: string; - color: string; - preferredProvider: string | null; - preferredModel: string | null; - workingDirs: string[]; - useWorktrees: boolean; - }; + editingProject?: ProjectInfo; } export function CreateProjectDialog({ @@ -194,18 +183,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/ProjectsView.tsx b/ui/goose2/src/features/projects/ui/ProjectsView.tsx index 3f48c5aa0b5d..f340925a10e8 100644 --- a/ui/goose2/src/features/projects/ui/ProjectsView.tsx +++ b/ui/goose2/src/features/projects/ui/ProjectsView.tsx @@ -88,21 +88,9 @@ export function ProjectsView({ onStartChat }: ProjectsViewProps) { const fetchProjects = useProjectStore((s) => s.fetchProjects); const [search, setSearch] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); - const [editingProject, setEditingProject] = useState< - | { - id: string; - name: string; - description: string; - prompt: string; - icon: string; - color: string; - preferredProvider: string | null; - preferredModel: string | null; - workingDirs: string[]; - useWorktrees: boolean; - } - | undefined - >(undefined); + const [editingProject, setEditingProject] = useState( + undefined, + ); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [deletingProject, setDeletingProject] = useState( @@ -141,18 +129,7 @@ export function ProjectsView({ onStartChat }: ProjectsViewProps) { }; const handleEdit = (project: ProjectInfo) => { - setEditingProject({ - id: project.id, - name: project.name, - description: project.description, - prompt: project.prompt, - icon: project.icon, - color: project.color, - preferredProvider: project.preferredProvider, - preferredModel: project.preferredModel, - workingDirs: project.workingDirs, - useWorktrees: project.useWorktrees, - }); + setEditingProject(project); setDialogOpen(true); }; 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 b414add677e0..cfe62c269f6f 100644 --- a/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx +++ b/ui/goose2/src/features/projects/ui/__tests__/CreateProjectDialog.test.tsx @@ -96,6 +96,8 @@ function makeEditingProject(overrides: Record = {}) { preferredModel: null, workingDirs: ["/home/user/code"], useWorktrees: false, + order: 0, + archivedAt: null, ...overrides, }; } diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index 5f32147c1ad3..18118aafe23f 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -5,6 +5,11 @@ export interface SkillInfo { description: string; instructions: string; path: string; + global: boolean; + /** Present when the skill belongs to a project (non-global). */ + projectName?: string; + /** The project root directory. Needed for update/delete of non-global skills. */ + projectDir?: string; } // Shape returned by _goose/sources/*. Narrowed to skill-type sources here. @@ -15,21 +20,33 @@ interface SourceEntry { content: string; directory: string; global: boolean; + properties?: Record; } function toSkillInfo(source: SourceEntry): SkillInfo { - return { + const info: SkillInfo = { name: source.name, description: source.description, instructions: source.content, path: source.directory, + global: source.global, }; + const projectName = source.properties?.projectName; + if (typeof projectName === "string") { + info.projectName = projectName; + } + const projectDir = source.properties?.projectDir; + if (typeof projectDir === "string") { + info.projectDir = projectDir; + } + return info; } export async function createSkill( name: string, description: string, instructions: string, + options?: { projectId?: string }, ): Promise { const client = await getClient(); await client.extMethod("_goose/sources/create", { @@ -37,23 +54,33 @@ export async function createSkill( name, description, content: instructions, - global: true, + global: !options?.projectId, + ...(options?.projectId ? { projectId: options.projectId } : {}), }); } -export async function listSkills(): Promise { +export async function listSkills(options?: { + includeProjectSources?: boolean; +}): Promise { const client = await getClient(); - const raw = await client.extMethod("_goose/sources/list", { type: "skill" }); + const raw = await client.extMethod("_goose/sources/list", { + type: "skill", + includeProjectSources: options?.includeProjectSources ?? true, + }); const sources = (raw.sources ?? []) as SourceEntry[]; return sources.map(toSkillInfo); } -export async function deleteSkill(name: string): Promise { +export async function deleteSkill( + name: string, + options?: { projectDir?: string }, +): Promise { const client = await getClient(); await client.extMethod("_goose/sources/delete", { type: "skill", name, - global: true, + global: !options?.projectDir, + ...(options?.projectDir ? { projectDir: options.projectDir } : {}), }); } @@ -61,6 +88,7 @@ export async function updateSkill( name: string, description: string, instructions: string, + options?: { projectDir?: string }, ): Promise { const client = await getClient(); const raw = await client.extMethod("_goose/sources/update", { @@ -68,19 +96,22 @@ export async function updateSkill( name, description, content: instructions, - global: true, + global: !options?.projectDir, + ...(options?.projectDir ? { projectDir: options.projectDir } : {}), }); return toSkillInfo(raw.source as SourceEntry); } export async function exportSkill( name: string, + options?: { projectDir?: string }, ): Promise<{ json: string; filename: string }> { const client = await getClient(); const raw = await client.extMethod("_goose/sources/export", { type: "skill", name, - global: true, + global: !options?.projectDir, + ...(options?.projectDir ? { projectDir: options.projectDir } : {}), }); return { json: raw.json as string, filename: raw.filename as string }; } @@ -88,6 +119,7 @@ export async function exportSkill( export async function importSkills( fileBytes: number[], fileName: string, + options?: { projectId?: string }, ): Promise { if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) { throw new Error("File must have a .skill.json or .json extension"); @@ -96,7 +128,8 @@ export async function importSkills( const client = await getClient(); const raw = await client.extMethod("_goose/sources/import", { data, - global: true, + global: !options?.projectId, + ...(options?.projectId ? { projectId: options.projectId } : {}), }); const sources = (raw.sources ?? []) as SourceEntry[]; return sources.map(toSkillInfo); diff --git a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx index fd81b91c0eac..37ad1daa6064 100644 --- a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx +++ b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -12,15 +12,32 @@ 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 } from "../api/skills"; const KEBAB_CASE_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; +/** Sentinel value for the "Global" option in the save-location picker. */ +const GLOBAL_VALUE = "__global__"; + interface CreateSkillDialogProps { isOpen: boolean; onClose: () => void; onCreated?: () => void; - editingSkill?: { name: string; description: string; instructions: string }; + editingSkill?: { + name: string; + description: string; + instructions: string; + global?: boolean; + projectDir?: string; + }; } export function CreateSkillDialog({ @@ -33,9 +50,18 @@ export function CreateSkillDialog({ 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 @@ -44,11 +70,13 @@ export function CreateSkillDialog({ 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]); @@ -71,6 +99,7 @@ export function CreateSkillDialog({ setName(""); setDescription(""); setInstructions(""); + setSaveLocation(GLOBAL_VALUE); setError(null); onClose(); }; @@ -82,13 +111,23 @@ export function CreateSkillDialog({ setError(null); try { if (isEditing) { - await updateSkill(name, description.trim(), instructions); + await updateSkill(name, description.trim(), instructions, { + projectDir: + editingSkill?.global === false + ? editingSkill.projectDir + : undefined, + }); } 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); onCreated?.(); onClose(); } catch (err) { @@ -131,6 +170,35 @@ export function CreateSkillDialog({ )}
+ {/* Save location — only shown when creating */} + {!isEditing && projectsWithDirs.length > 0 && ( +
+ + +

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

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