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
-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,