//! `spectra agent`: agents as their owner's delegates (spec §8.1). //! //! `add` makes the agent's key pair in its own home, asks the owner to //! confirm, signs the owner's successor that delegates to it, and records //! both. There is no agent without an owner. use super::identity::owner_home; use super::{call, marks, ui, Ctx}; use anyhow::{bail, Context, Result}; use spectra_core::ids::now_ms; use spectra_core::keys::{home_identity, KeyPair}; use spectra_core::ledger::Ledger; use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES}; use std::path::PathBuf; pub const KINDS: &[&str] = &["claude-code", "antigravity", "kimi-code", "codex", "other"]; pub struct Add { pub name: String, pub kind: String, pub project: String, pub home: PathBuf, pub uid: Option, pub learn_uid: bool, pub expires: Option, pub yes: bool, pub owner_home: Option, } /// `30m`, `8h`, `7d` from now, in ms; `never` is none. pub fn expiry(s: &str) -> Result> { let s = s.trim(); if s == "never" { return Ok(None); } let (n, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())); let n: u64 = n.parse().with_context(|| format!("{s} is not a length of time (30m, 8h, 7d)"))?; let ms = match unit { "s" => n * 1000, "m" => n * 60_000, "h" => n * 3_600_000, "d" | "" => n * 86_400_000, _ => bail!("{s} is not a length of time (30m, 8h, 7d)"), }; Ok(Some(now_ms() + ms)) } /// Ask the owner at the terminal; `yes` answers for scripts. pub fn confirm(question: &str, yes: bool) -> Result<()> { use std::io::IsTerminal; if yes { return Ok(()); } if !std::io::stdin().is_terminal() { bail!("{question}: not asked, since this is not a terminal (--yes answers for a script)"); } print!("{} {} {} ", ui::glyph("?", "?"), question, ui::dim("[y/N]")); use std::io::Write; std::io::stdout().flush().ok(); let mut line = String::new(); std::io::stdin().read_line(&mut line)?; if !matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") { bail!("not delegated: nothing was changed"); } Ok(()) } pub async fn add(ctx: &Ctx, a: Add) -> Result { if !valid_name(&a.name) { bail!("an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64"); } if !KINDS.contains(&a.kind.as_str()) { bail!("--kind is one of {}", KINDS.join(", ")); } let home = owner_home(ctx, a.owner_home.clone()); let (exists, known) = match ctx.owner_client(&home).await? { Some(c) => { let agents = call(&c, "GET", "/api/v1/agents", None).await?; let projects = call(&c, "GET", "/api/v1/projects", None).await?; let has = |v: &serde_json::Value, n: &str| v.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == n)); (has(&agents, &a.name), has(&projects, &a.project)) } None => { let ledger = Ledger::open_read_only(&ctx.db()?).await?; let p = Principals::load(&ledger).await?; let known: Option = sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(&a.project).fetch_optional(ledger.pool()).await?; (p.agent_key(&a.name).is_some(), known.is_some()) } }; if exists { bail!("an agent named {} already exists (spectra agent list)", a.name); } if !known { bail!("no project named {} (spectra project add PATH --name {})", a.project, a.project); } // The agent's key, made once in its own home and reused if `add` is run again. let agent = if a.home.join("keys").exists() { KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))? } else { let pair = KeyPair::create_home(&a.home, &a.name, "")?; println!("{} {} {}", ui::done("Created"), ui::subject(format!("{}'s key", a.name)), ui::path(a.home.display())); pair }; let expires = a.expires.as_deref().map(expiry).transpose()?.flatten(); let current = home_identity(&home)?; if current.delegate(&agent.key).map(|d| d.expires) != Some(expires) { let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into()); println!("{}", ui::kv("agent", format!("{}{}{}", ui::subject(&a.name), ui::sep(), ui::hash(agent.key.short(12))))); println!("{}", ui::kv("acts for", format!("{}{}{until}", ui::author(¤t.name), ui::sep()))); confirm(&format!("Let {} act for you", a.name), a.yes)?; spectra_core::keys::delegate(&home, agent.key, expires)?; } let w = ctx.owner_writer(&home).await?; let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); let owner = KeyPair::load_home(&home)?; let mut p = identity_payload(&agent.identity); p["name"] = a.name.clone().into(); p["kind"] = a.kind.clone().into(); p["project"] = a.project.clone().into(); p["home"] = a.home.display().to_string().into(); p["owner"] = owner.key.to_hex().into(); p["owner_name"] = owner.identity.name.clone().into(); p["scopes"] = DEFAULT_SCOPES.into(); if let Some(u) = a.uid { p["uid"] = u.into(); } p["learn_uid"] = a.learn_uid.into(); let d = owner.identity.delegate(&agent.key).context("the delegation vanished")?; p["delegation"] = serde_json::json!({ "expires": d.expires }); events.push(signed_event(&owner, "human", "agent.added", p)); w.append(events).await?; // Label the home, so `spectra mcp --auto` finds this agent from its folder. let folder = match (w.client(), w.ledger()) { (Some(c), _) => call(c, "GET", "/api/v1/projects", None).await.ok().and_then(|v| { v.as_array().and_then(|l| l.iter().find(|p| p["name"] == a.project.as_str()).and_then(|p| p["path"].as_str().map(String::from))) }), (None, Some(l)) => sqlx::query_scalar::<_, Option>("SELECT path FROM projects WHERE name = ?") .bind(&a.project) .fetch_optional(l.pool()) .await .ok() .flatten() .flatten(), _ => None, }; super::auto::write_label(&a.home, &a.name, &a.kind, &a.project, folder.as_deref())?; println!("{} {}{}{}", ui::done("Added"), ui::subject(&a.name), ui::sep(), ui::author(format!("{}'s agent", owner.identity.name))); println!("{}", ui::kv("key", ui::hash(agent.key.to_hex()))); let until = d.expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into()); println!("{}", ui::kv("delegated", until)); // With Spectra on another machine the shim cannot look the home up. let shim = if ctx.remote()?.is_some() { format!("spectra mcp --agent {} --home {}", a.name, a.home.display()) } else { format!("spectra mcp --agent {}", a.name) }; println!(" {} {} {}", ui::note(), ui::dim("connect its client with"), ui::id(shim)); Ok(0) } /// An agent as `agent list` reads it, with its presence. #[derive(sqlx::FromRow)] struct AgentRow { name: String, key: String, kind: String, project: String, disabled_ms: Option, presence: Option, status: Option, } 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/agents", None).await?.as_array().cloned().unwrap_or_default(), None => list_local(ctx).await?, }; print_list(out, json) } async fn list_local(ctx: &Ctx) -> Result> { let ledger = Ledger::open_read_only(&ctx.db()?).await?; let p = Principals::load(&ledger).await?; let rows: Vec = sqlx::query_as( "SELECT a.name, a.key, a.kind, a.project, a.disabled_ms, p.state AS presence, p.status_text AS status FROM agents a LEFT JOIN presence p ON p.agent = a.name ORDER BY a.name", ) .fetch_all(ledger.pool()) .await?; let owner = p.owner(); let now = now_ms(); let mut out = Vec::new(); for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows { let k = spectra_core::identity::KeyId::from_hex(&key).context("a stored key")?; let state = match p.principal(&k, now) { Ok(_) => "ok".to_string(), Err(why) => why, }; let expires = owner.and_then(|o| o.delegate(&k)).and_then(|d| d.expires); let who = match p.principal(&k, now) { Ok(pr @ Principal::Agent { .. }) => pr.display(), _ => format!("{name} · {}'s agent", owner.map(|o| o.name.as_str()).unwrap_or("?")), }; let presence = if disabled.is_some() { "offline".to_string() } else { presence.unwrap_or_else(|| "offline".into()) }; out.push(serde_json::json!({ "name": name, "display": who, "key": key, "kind": kind, "project": project, "state": state, "presence": presence, "status": status, "delegation_expires": expires, "disabled": disabled.is_some() })); } Ok(out) } fn print_list(out: Vec, json: bool) -> Result { if json { println!("{}", serde_json::Value::Array(out)); return Ok(0); } if out.is_empty() { println!("{} {}", ui::note(), ui::dim("no agents yet: spectra agent add NAME --kind KIND --project PROJECT --home DIR")); } for a in out { let state = a["state"].as_str().unwrap_or(""); let until = a["delegation_expires"].as_u64().map(|e| format!("{}until {}", ui::sep(), super::format::when(e))).unwrap_or_default(); let display = a["display"].as_str().unwrap_or(""); let (name, owner) = display.split_once(" · ").unwrap_or((display, "")); println!( "{} {} {}{}{}", marks::health(state), marks::presence(a["presence"].as_str().unwrap_or("offline")), ui::subject(name), ui::sep(), ui::author(owner) ); if let Some(st) = a["status"].as_str() { println!(" {} {}", ui::note(), st); } println!( " {}", ui::dim(format!( "{}{}{}{}{}{until}", a["kind"].as_str().unwrap_or(""), ui::sep(), a["project"].as_str().unwrap_or(""), ui::sep(), &a["key"].as_str().unwrap_or("")[..12] )) ); if state != "ok" { println!(" {} {}", ui::caution(), ui::warn(state)); } } Ok(0) } pub async fn disable(ctx: &Ctx, name: &str, owner_home_flag: Option) -> Result { let home = owner_home(ctx, owner_home_flag); let w = ctx.owner_writer(&home).await?; let known = match (w.ledger(), w.client()) { (_, Some(c)) => call(c, "GET", "/api/v1/agents", None).await?.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == name)), (Some(l), None) => Principals::load(l).await?.agent_key(name).is_some(), (None, None) => false, }; if !known { bail!("no agent named {name}"); } // Stop delegating to its key too, where the owner's identity names it. if let Some(key) = agent_key(&w, name).await? { if home_identity(&home)?.delegate(&key).is_some() { spectra_core::keys::undelegate(&home, key)?; } } let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); let owner = KeyPair::load_home(&home)?; events.push(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name }))); w.append(events).await?; println!("{} {}", ui::done("Disabled"), ui::subject(name)); println!(" {} {}", ui::note(), ui::dim("its key is refused from now on; earlier events keep their signatures")); Ok(0) } /// An agent's key by name, from the daemon or the ledger here. async fn agent_key(w: &super::Writer, name: &str) -> Result> { Ok(match (w.client(), w.ledger()) { (Some(c), _) => call(c, "GET", "/api/v1/agents", None).await?.as_array().and_then(|l| { l.iter().find(|x| x["name"] == name).and_then(|x| x["key"].as_str().and_then(spectra_core::identity::KeyId::from_hex)) }), (None, Some(l)) => Principals::load(l).await?.agent_key(name), (None, None) => None, }) } /// `spectra agent allow NAME --channel CHANNEL [--remove]`: the owner lets /// an agent post in another project's channel (spec §8.4). pub async fn allow(ctx: &Ctx, name: &str, channel: &str, remove: bool, owner_home_flag: Option) -> Result { let channel = channel.trim_start_matches('#'); let scope = format!("channel:{channel}"); if !spectra_core::principals::grantable(&scope) { bail!("{channel} is not a channel name"); } let home = owner_home(ctx, owner_home_flag); let w = ctx.owner_writer(&home).await?; agent_key(&w, name).await?.with_context(|| format!("no agent named {name}"))?; let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); let owner = KeyPair::load_home(&home)?; events.push(signed_event(&owner, "human", "agent.scoped", serde_json::json!({ "name": name, "scope": scope, "grant": !remove }))); w.append(events).await?; if remove { println!("{} {} {}", ui::done("Removed"), ui::subject(name), ui::dim(format!("may no longer post in #{channel}"))); } else { println!("{} {} {}", ui::done("Allowed"), ui::subject(name), ui::dim(format!("may post in #{channel}"))); } Ok(0) } /// `spectra agent renew NAME --expires 8h`: the owner's successor with a /// new expiry for the agent's key, recorded. pub async fn renew(ctx: &Ctx, name: &str, expires: &str, owner_home_flag: Option) -> Result { let home = owner_home(ctx, owner_home_flag); let w = ctx.owner_writer(&home).await?; let key = agent_key(&w, name).await?.with_context(|| format!("no agent named {name}"))?; let expires = expiry(expires)?; spectra_core::keys::delegate(&home, key, expires)?; let events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); w.append(events).await?; let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into()); println!("{} {}{}{}", ui::done("Renewed"), ui::subject(name), ui::sep(), until); Ok(0) } pub fn valid_name(n: &str) -> bool { !n.is_empty() && n.len() <= 64 && n.bytes().next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) && n.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') }