feat: projects as backend sources with system prompt injection (#8739)

Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Douwe Osinga
2026-05-07 10:30:15 -04:00
committed by GitHub
parent bced4ea5ec
commit ff42171903
29 changed files with 1241 additions and 919 deletions
+30 -4
View File
@@ -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/<name>` 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/<name>` 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<String>,
/// 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<String, serde_json::Value>,
}
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<String>,
/// 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<String>,
/// Arbitrary key/value metadata.
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub properties: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
@@ -903,6 +918,10 @@ pub struct ListSourcesRequest {
pub source_type: Option<SourceType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_dir: Option<String>,
/// 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<std::collections::HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
+35 -4
View File
@@ -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/<name>` 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/<name>` 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": [
+1 -1
View File
@@ -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),
+20 -2
View File
@@ -5,13 +5,26 @@ impl GooseAcpAgent {
&self,
req: CreateSourceRequest,
) -> Result<CreateSourceResponse, agent_client_protocol::Error> {
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<ListSourcesResponse, agent_client_protocol::Error> {
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 })
}
+23
View File
@@ -366,6 +366,24 @@ impl Agent {
messages
}
async fn load_project_instructions(&self, session: &Session) -> Option<String> {
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?;
@@ -125,9 +125,10 @@ fn parse_agent_content(content: &str, path: &Path) -> Option<SourceEntry> {
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<Recipe, String> {
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))?
};
+4 -4
View File
@@ -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());
+36 -6
View File
@@ -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<String>,
#[serde(default)]
pub description: String,
/// Free-form bag for caller-defined fields. Per the agentskills.io spec
/// (<https://agentskills.io/specification#frontmatter>), arbitrary
/// metadata lives in this nested mapping so it doesn't collide with
/// reserved frontmatter fields.
#[serde(default)]
pub metadata: HashMap<String, Value>,
}
/// 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, Value>,
) -> 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<Sourc
name,
description: metadata.description,
content: body,
directory: path.to_string_lossy().into_owned(),
path: path.to_string_lossy().into_owned(),
global,
supporting_files: Vec::new(),
properties: metadata.metadata,
})
}
@@ -369,10 +399,10 @@ pub fn discover_skills(working_dir: Option<&Path>) -> Vec<SourceEntry> {
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
});
}
File diff suppressed because it is too large Load Diff
@@ -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"))
);
});
-1
View File
@@ -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;
@@ -1,493 +0,0 @@
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
fn projects_dir() -> Result<PathBuf, String> {
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::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.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<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum WorkingDirsField {
Many(Vec<String>),
One(String),
Null,
}
let value = Option::<WorkingDirsField>::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<String>,
pub preferred_model: Option<String>,
#[serde(
default,
alias = "workingDir",
deserialize_with = "deserialize_working_dirs"
)]
pub working_dirs: Vec<String>,
pub use_worktrees: bool,
#[serde(default)]
pub order: i32,
#[serde(default)]
pub archived_at: Option<String>,
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<String>,
pub preferred_model: Option<String>,
#[serde(default)]
pub working_dirs: Vec<String>,
pub use_worktrees: bool,
#[serde(default)]
pub order: i32,
#[serde(default)]
pub archived_at: Option<String>,
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<Vec<ProjectInfo>, 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<Vec<ProjectInfo>, 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::<StoredProjectInfo>(&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<String>,
preferred_model: Option<String>,
working_dirs: Vec<String>,
use_worktrees: bool,
) -> Result<ProjectInfo, String> {
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<String>,
preferred_model: Option<String>,
working_dirs: Vec<String>,
use_worktrees: bool,
) -> Result<ProjectInfo, String> {
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<ProjectInfo, String> {
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::<StoredProjectInfo>(&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::<StoredProjectInfo>(&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"]);
}
}
-9
View File
@@ -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,
@@ -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 `<active-working-context>\nActive branch: ${activeWorkspace.branch}\nWorking directory: ${activeWorkspace.path}\n</active-working-context>`;
}, [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);
+178 -44
View File
@@ -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<string, unknown>;
}
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<string, unknown> {
const props: Record<string, unknown> = {};
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>): 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<ProjectInfo[]> {
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<ProjectInfo> {
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<Omit<ProjectInfo, "id" | "path">>,
): Promise<ProjectInfo> {
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<void> {
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<void> {
return invoke("delete_project", { id });
}
export async function getProject(id: string): Promise<ProjectInfo> {
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<ProjectInfo[]> {
return invoke("list_archived_projects");
/** List both archived and active projects. */
async function listAllProjects(): Promise<ProjectInfo[]> {
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<void> {
return invoke("archive_project", { id });
const project = await getProject(id);
await updateProject(project, {
archivedAt: new Date().toISOString(),
});
}
export async function restoreProject(id: string): Promise<void> {
const project = await getProject(id);
await updateProject(project, { archivedAt: null });
}
export async function reorderProjects(
order: [string, number][],
): Promise<void> {
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<void> {
return invoke("restore_project", { id });
export async function listArchivedProjects(): Promise<ProjectInfo[]> {
const all = await listAllProjects();
return all.filter((p) => p.archivedAt !== null);
}
@@ -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("<project-settings>");
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("<project-file-policy>");
expect(systemPrompt).toContain(
"Use /Users/wesb/dev/goose2 as the default working directory for this project.",
);
expect(systemPrompt).toContain("<project-instructions>");
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"),
@@ -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<ProjectInfo, "workingDirs"> | 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 = [
`<project-settings>\n${settings.join("\n")}\n</project-settings>`,
];
if (workingDir) {
sections.push(
`<project-file-policy>\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` +
`</project-file-policy>`,
);
}
if (prompt) {
sections.push(`<project-instructions>\n${prompt}\n</project-instructions>`);
}
return sections.join("\n\n");
}
export function composeSystemPrompt(
...parts: Array<string | null | undefined>
): string | undefined {
@@ -14,6 +14,7 @@ vi.mock("@/shared/api/pathResolver", () => ({
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
id: "project-1",
path: "/tmp/projects/project-1.md",
name: "Project",
description: "",
prompt: "",
@@ -25,8 +26,6 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
useWorktrees: false,
order: 0,
archivedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
@@ -125,8 +125,9 @@ export const useProjectStore = create<ProjectStore>((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<ProjectStore>((set, get) => ({
preferredModel,
workingDirs,
useWorktrees,
);
});
set((state) => ({
projects: state.projects.map((p) => (p.id === id ? project : p)),
}));
@@ -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(),
@@ -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> = {}): 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> = {}): ProjectInfo {
useWorktrees: false,
order: 0,
archivedAt: null,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
...overrides,
};
}
@@ -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,
},
],
+28 -9
View File
@@ -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<string, unknown>;
// 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<void> {
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;
}
@@ -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<string | null>(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({
)}
</div>
{/* Save location — only shown when creating */}
{!isEditing && projectsWithDirs.length > 0 && (
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.saveLocation")}
</Label>
<Select value={saveLocation} onValueChange={setSaveLocation}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={GLOBAL_VALUE}>
{t("dialog.global")}
</SelectItem>
{projectsWithDirs.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{saveLocation === GLOBAL_VALUE
? t("dialog.globalHint")
: t("dialog.projectHint")}
</p>
</div>
)}
{/* Description */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
@@ -244,6 +244,7 @@ describe("SkillEditor", () => {
"my-skill",
"A description",
"Some instructions",
{ projectId: undefined },
);
});
@@ -5,6 +5,8 @@
"description": "Description",
"descriptionPlaceholder": "What it does and when to use it...",
"editTitle": "Edit skill",
"global": "Global",
"globalHint": "Available to all sessions",
"instructions": "Instructions",
"instructionsPlaceholder": "Markdown instructions the agent will follow...",
"name": "Name",
@@ -12,6 +14,8 @@
"nameValidation": "Use 164 lowercase letters, numbers, or hyphens. Names cannot start or end with a hyphen.",
"pathOnDisk": "Path on disk",
"newTitle": "New skill",
"projectHint": "Stored in the project folder",
"saveLocation": "Save to",
"saving": "Saving..."
},
"view": {
+4 -4
View File
@@ -119,7 +119,7 @@ export function buildInitScript(options?: {
name: s.name,
description: s.description,
content: s.instructions ?? s.content ?? "",
directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""),
path: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""),
global: s.global ?? true,
supportingFiles: [],
});
@@ -249,7 +249,7 @@ export function buildInitScript(options?: {
type: "skill",
description: message.params?.description ?? "",
content: message.params?.content ?? "",
directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"),
path: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"),
global: message.params?.global ?? true,
},
});
@@ -265,14 +265,14 @@ export function buildInitScript(options?: {
if (segments.length > 0) {
segments[segments.length - 1] = name;
}
const directory = \`/\${segments.join("/")}\`;
const updatedPath = \`/\${segments.join("/")}\`;
return jsonRpcResult(message.id, {
source: {
name,
type: "skill",
description: message.params?.description ?? "",
content: message.params?.content ?? "",
directory,
path: updatedPath,
global: message.params?.global ?? true,
supportingFiles: [],
},
+40 -5
View File
@@ -752,12 +752,24 @@ export type CreateSourceRequest = {
* Absolute path to the project root. Required when `global` is false.
*/
projectDir?: string | null;
/**
* Project source ID. When set with `global: false`, the backend resolves
* the project's first working directory automatically. Takes precedence
* over `project_dir`.
*/
projectId?: string | null;
/**
* Arbitrary key/value metadata.
*/
properties?: {
[key: string]: unknown;
};
};
/**
* The type of source entity.
*/
export type SourceType = 'skill' | 'builtinSkill' | 'recipe' | 'subrecipe' | 'agent';
export type SourceType = 'skill' | 'builtinSkill' | 'recipe' | 'subrecipe' | 'agent' | 'project';
export type CreateSourceResponse = {
source: SourceEntry;
@@ -774,11 +786,12 @@ export type SourceEntry = {
description: string;
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/<name>` paths.
* 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/<name>` synthetic paths.
*/
directory: string;
path: string;
/**
* True when the source lives in the user's global sources directory; false
* when it lives inside a specific project.
@@ -789,6 +802,13 @@ export type SourceEntry = {
* Only skills currently populate this; empty for other source types.
*/
supportingFiles?: Array<string>;
/**
* Arbitrary key/value pairs for type-specific metadata (e.g. icon, color,
* preferredProvider for projects). Stored in the frontmatter.
*/
properties?: {
[key: string]: unknown;
};
};
/**
@@ -802,6 +822,11 @@ export type SourceEntry = {
export type ListSourcesRequest = {
type?: SourceType | null;
projectDir?: string | null;
/**
* When true, also scan the working directories of all known projects for
* project-scoped sources (e.g. skills stored under `{workingDir}/.agents/skills/`).
*/
includeProjectSources?: boolean;
};
export type ListSourcesResponse = {
@@ -817,6 +842,16 @@ export type UpdateSourceRequest = {
name: string;
description: string;
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.
*/
properties?: {
[key: string]: unknown;
} | null;
};
export type UpdateSourceResponse = {
+18 -6
View File
@@ -755,7 +755,8 @@ export const zSourceType = z.enum([
'builtinSkill',
'recipe',
'subrecipe',
'agent'
'agent',
'project'
]);
/**
@@ -770,7 +771,12 @@ export const zCreateSourceRequest = z.object({
projectDir: z.union([
z.string(),
z.null()
]).optional()
]).optional(),
projectId: z.union([
z.string(),
z.null()
]).optional(),
properties: z.record(z.unknown()).optional()
});
/**
@@ -783,9 +789,10 @@ export const zSourceEntry = z.object({
name: z.string(),
description: z.string(),
content: z.string(),
directory: z.string(),
path: z.string(),
global: z.boolean(),
supportingFiles: z.array(z.string()).optional()
supportingFiles: z.array(z.string()).optional(),
properties: z.record(z.unknown()).optional()
});
export const zCreateSourceResponse = z.object({
@@ -808,7 +815,8 @@ export const zListSourcesRequest = z.object({
projectDir: z.union([
z.string(),
z.null()
]).optional()
]).optional(),
includeProjectSources: z.boolean().optional().default(false)
});
export const zListSourcesResponse = z.object({
@@ -823,7 +831,11 @@ export const zUpdateSourceRequest = z.object({
path: z.string(),
name: z.string(),
description: z.string(),
content: z.string()
content: z.string(),
properties: z.union([
z.record(z.unknown()),
z.null()
]).optional()
});
export const zUpdateSourceResponse = z.object({