//! `spectra project`: the work agents share (spec §23.1), any kind of it. use super::identity::owner_home; use super::{call, ui, Ctx}; use anyhow::{bail, Context, Result}; use spectra_core::keys::KeyPair; use spectra_core::ledger::Ledger; use spectra_core::principals::signed_event; use spectra_core::projects; use std::path::{Path, PathBuf}; pub struct Add { pub target: String, pub name: Option, pub path: Option, pub description: String, pub url: Option, } pub async fn add(ctx: &Ctx, a: Add, owner_home_flag: Option) -> Result { // A folder that exists is the work's folder; anything else is a name. let as_dir = Path::new(&shellexpand(&a.target)).to_path_buf(); let (name, path) = match (&a.path, as_dir.is_dir()) { (None, true) => { let path = as_dir.canonicalize()?; (a.name.clone().map(Ok).unwrap_or_else(|| projects::default_name(&path))?, Some(path)) } (Some(p), _) => { let path = p.canonicalize().with_context(|| format!("{} does not exist", p.display()))?; (a.name.clone().unwrap_or_else(|| a.target.clone()), Some(path)) } (None, false) => (a.name.clone().unwrap_or_else(|| a.target.clone()), None), }; if !super::agent::valid_name(&name) { bail!("a project's name is lower-case letters, digits and dashes, starting with a letter or digit (--name)"); } let home = owner_home(ctx, owner_home_flag); let w = ctx.owner_writer(&home).await?; let path_s = path.as_ref().map(|p| p.display().to_string()); let existing = all(&w).await?; if let Some(e) = existing.iter().find(|p| p["name"] == name.as_str() || (path_s.is_some() && p["path"].as_str() == path_s.as_deref())) { bail!("{} is already a project", e["name"].as_str().unwrap_or("")); } let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); let owner = KeyPair::load_home(&home)?; let payload = serde_json::json!({ "name": name, "path": path_s, "description": a.description, "url": a.url }); events.push(signed_event(&owner, "human", "project.added", payload).project(name.clone()).subject(name.clone())); events.push(spectra_core::conversation::project_channel_event(&name)); w.append(events).await?; println!("{} {}{}", ui::done("Added"), ui::subject(&name), path_s.map(|p| format!(" {}", ui::path(p))).unwrap_or_default()); if !a.description.is_empty() { println!("{}", ui::kv("about", &a.description)); } if let Some(u) = &a.url { println!("{}", ui::kv("link", u)); } println!( " {} {}", ui::note(), ui::dim(format!("agents talk about it in #{name}; link it with spectra project link {name} --depends-on OTHER")) ); Ok(0) } /// `~/x` as the shell would have it. fn shellexpand(s: &str) -> String { match (s.strip_prefix("~/"), std::env::var_os("HOME")) { (Some(rest), Some(h)) => Path::new(&h).join(rest).display().to_string(), _ => s.to_string(), } } /// `spectra project link P --depends-on Q [--remove]`. pub async fn link(ctx: &Ctx, project: &str, on: &str, remove: bool, owner_home_flag: Option) -> Result { if project == on { bail!("a project cannot depend on itself"); } let home = owner_home(ctx, owner_home_flag); let w = ctx.owner_writer(&home).await?; let existing = all(&w).await?; for n in [project, on] { if !existing.iter().any(|p| p["name"] == n) { bail!("no project named {n} (spectra project list)"); } } let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); let owner = KeyPair::load_home(&home)?; let kind = if remove { "project.unlinked" } else { "project.linked" }; events.push( signed_event(&owner, "human", kind, serde_json::json!({ "project": project, "depends_on": on })).project(project.to_string()), ); w.append(events).await?; let verb = if remove { "Unlinked" } else { "Linked" }; println!("{} {} {} {}", ui::done(verb), ui::subject(project), ui::dim("depends on"), ui::subject(on)); Ok(0) } async fn relations(ledger: &Ledger, name: &str) -> Result<(Vec, Vec)> { let deps = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1") .bind(name) .fetch_all(ledger.pool()) .await?; let users = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ? ORDER BY 1") .bind(name) .fetch_all(ledger.pool()) .await?; Ok((deps, users)) } /// Every project with its relations and agents: from the daemon when one /// serves, else from the ledger here. async fn all(w: &super::Writer) -> Result> { match (w.client(), w.ledger()) { (Some(c), _) => Ok(call(c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default()), (None, Some(l)) => local(l).await, (None, None) => Ok(vec![]), } } async fn local(ledger: &Ledger) -> Result> { let rows: Vec<(String, Option, String, Option)> = sqlx::query_as("SELECT name, path, description, url FROM projects ORDER BY name").fetch_all(ledger.pool()).await?; let mut out = Vec::new(); for (name, path, description, url) in rows { let (deps, users) = relations(ledger, &name).await?; let agents: Vec = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name") .bind(&name) .fetch_all(ledger.pool()) .await?; out.push(serde_json::json!({ "name": name, "path": path, "description": description, "url": url, "depends_on": deps, "depended_on_by": users, "agents": agents })); } Ok(out) } pub async fn list(ctx: &Ctx, json: bool) -> Result { let out = match ctx.owner_client(&owner_home(ctx, None)).await? { Some(c) => call(&c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default(), None => local(&Ledger::open_read_only(&ctx.db()?).await?).await?, }; if json { println!("{}", serde_json::Value::Array(out)); return Ok(0); } if out.is_empty() { println!("{} {}", ui::note(), ui::dim("no projects yet: spectra project add NAME-OR-FOLDER")); } for p in out { let list = |k: &str| p[k].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::>().join(", ")).unwrap_or_default(); println!( "{} {} {}", ui::glyph("▸", ">"), ui::bookmark(p["name"].as_str().unwrap_or("")), ui::path(p["path"].as_str().unwrap_or("")) ); if let Some(d) = p["description"].as_str().filter(|d| !d.is_empty()) { println!("{}", ui::kv("about", d)); } for (k, label) in [("depends_on", "depends on"), ("depended_on_by", "needed by"), ("agents", "agents")] { let v = list(k); if !v.is_empty() { println!("{}", ui::kv(label, v)); } } } Ok(0) }