mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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)]
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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))?
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
+714
-183
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"))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 1–64 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": {
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user