| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | | use super::identity::owner_home; |
| 8 | | use super::{call, marks, ui, Ctx}; |
| 9 | | use anyhow::{bail, Context, Result}; |
| 10 | | use spectra_core::ids::now_ms; |
| 11 | | use spectra_core::keys::{home_identity, KeyPair}; |
| 12 | | use spectra_core::ledger::Ledger; |
| 13 | | use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES}; |
| 14 | | use std::path::PathBuf; |
| 15 | |
|
| 16 | | pub const KINDS: &[&str] = &["claude-code", "antigravity", "kimi-code", "codex", "other"]; |
| 17 | |
|
| 18 | | pub struct Add { |
| 19 | | pub name: String, |
| 20 | | pub kind: String, |
| 21 | | pub project: String, |
| 22 | | pub home: PathBuf, |
| 23 | | pub uid: Option<u32>, |
| 24 | | pub learn_uid: bool, |
| 25 | | pub expires: Option<String>, |
| 26 | | pub yes: bool, |
| 27 | | pub owner_home: Option<PathBuf>, |
| 28 | | } |
| 29 | |
|
| 30 | |
|
| 31 | 3× | pub fn expiry(s: &str) -> Result<Option<u64>> { |
| 32 | 3× | let s = s.trim(); |
| 33 | 3× | if s == "never" { |
| 34 | 1× | return Ok(None); |
| 35 | 2× | } |
| 36 | 4× | let (n, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())); |
| 37 | 2× | let n: u64 = n.parse().with_context(|| format!("{s} is not a length of time (30m, 8h, 7d)"))?; |
| 38 | 2× | let ms = match unit { |
| 39 | 2× | "s" => n * 1000, |
| 40 | 2× | "m" => n * 60_000, |
| 41 | 2× | "h" => n * 3_600_000, |
| 42 | 0× | "d" | "" => n * 86_400_000, |
| 43 | 0× | _ => bail!("{s} is not a length of time (30m, 8h, 7d)"), |
| 44 | | }; |
| 45 | 2× | Ok(Some(now_ms() + ms)) |
| 46 | 3× | } |
| 47 | |
|
| 48 | |
|
| 49 | 25× | pub fn confirm(question: &str, yes: bool) -> Result<()> { |
| 50 | | use std::io::IsTerminal; |
| 51 | 25× | if yes { |
| 52 | 23× | return Ok(()); |
| 53 | 2× | } |
| 54 | 2× | if !std::io::stdin().is_terminal() { |
| 55 | 2× | bail!("{question}: not asked, since this is not a terminal (--yes answers for a script)"); |
| 56 | 0× | } |
| 57 | 0× | print!("{} {} {} ", ui::glyph("?", "?"), question, ui::dim("[y/N]")); |
| 58 | | use std::io::Write; |
| 59 | 0× | std::io::stdout().flush().ok(); |
| 60 | 0× | let mut line = String::new(); |
| 61 | 0× | std::io::stdin().read_line(&mut line)?; |
| 62 | 0× | if !matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") { |
| 63 | 0× | bail!("not delegated: nothing was changed"); |
| 64 | 0× | } |
| 65 | 0× | Ok(()) |
| 66 | 25× | } |
| 67 | |
|
| 68 | 23× | pub async fn add(ctx: &Ctx, a: Add) -> Result<i32> { |
| 69 | 23× | if !valid_name(&a.name) { |
| 70 | 0× | bail!("an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64"); |
| 71 | 23× | } |
| 72 | 23× | if !KINDS.contains(&a.kind.as_str()) { |
| 73 | 0× | bail!("--kind is one of {}", KINDS.join(", ")); |
| 74 | 23× | } |
| 75 | 23× | let home = owner_home(ctx, a.owner_home.clone()); |
| 76 | 23× | let (exists, known) = match ctx.owner_client(&home).await? { |
| 77 | 3× | Some(c) => { |
| 78 | 3× | let agents = call(&c, "GET", "/api/v1/agents", None).await?; |
| 79 | 3× | let projects = call(&c, "GET", "/api/v1/projects", None).await?; |
| 80 | 6× | let has = |v: &serde_json::Value, n: &str| v.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == n)); |
| 81 | 3× | (has(&agents, &a.name), has(&projects, &a.project)) |
| 82 | | } |
| 83 | | None => { |
| 84 | 20× | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
| 85 | 20× | let p = Principals::load(&ledger).await?; |
| 86 | 20× | let known: Option<String> = |
| 87 | 20× | sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(&a.project).fetch_optional(ledger.pool()).await?; |
| 88 | 20× | (p.agent_key(&a.name).is_some(), known.is_some()) |
| 89 | | } |
| 90 | | }; |
| 91 | 23× | if exists { |
| 92 | 0× | bail!("an agent named {} already exists (spectra agent list)", a.name); |
| 93 | 23× | } |
| 94 | 23× | if !known { |
| 95 | 1× | bail!("no project named {} (spectra project add PATH --name {})", a.project, a.project); |
| 96 | 22× | } |
| 97 | | |
| 98 | 22× | let agent = if a.home.join("keys").exists() { |
| 99 | 1× | KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))? |
| 100 | | } else { |
| 101 | 21× | let pair = KeyPair::create_home(&a.home, &a.name, "")?; |
| 102 | 21× | println!("{} {} {}", ui::done("Created"), ui::subject(format!("{}'s key", a.name)), ui::path(a.home.display())); |
| 103 | 21× | pair |
| 104 | | }; |
| 105 | 22× | let expires = a.expires.as_deref().map(expiry).transpose()?.flatten(); |
| 106 | 22× | let current = home_identity(&home)?; |
| 107 | 22× | if current.delegate(&agent.key).map(|d| d.expires) != Some(expires) { |
| 108 | 22× | let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into()); |
| 109 | 22× | println!("{}", ui::kv("agent", format!("{}{}{}", ui::subject(&a.name), ui::sep(), ui::hash(agent.key.short(12))))); |
| 110 | 22× | println!("{}", ui::kv("acts for", format!("{}{}{until}", ui::author(¤t.name), ui::sep()))); |
| 111 | 22× | confirm(&format!("Let {} act for you", a.name), a.yes)?; |
| 112 | 21× | spectra_core::keys::delegate(&home, agent.key, expires)?; |
| 113 | 0× | } |
| 114 | 21× | let w = ctx.owner_writer(&home).await?; |
| 115 | 21× | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
| 116 | 21× | let owner = KeyPair::load_home(&home)?; |
| 117 | 21× | let mut p = identity_payload(&agent.identity); |
| 118 | 21× | p["name"] = a.name.clone().into(); |
| 119 | 21× | p["kind"] = a.kind.into(); |
| 120 | 21× | p["project"] = a.project.into(); |
| 121 | 21× | p["home"] = a.home.display().to_string().into(); |
| 122 | 21× | p["owner"] = owner.key.to_hex().into(); |
| 123 | 21× | p["owner_name"] = owner.identity.name.clone().into(); |
| 124 | 21× | p["scopes"] = DEFAULT_SCOPES.into(); |
| 125 | 21× | if let Some(u) = a.uid { |
| 126 | 2× | p["uid"] = u.into(); |
| 127 | 19× | } |
| 128 | 21× | p["learn_uid"] = a.learn_uid.into(); |
| 129 | 21× | let d = owner.identity.delegate(&agent.key).context("the delegation vanished")?; |
| 130 | 21× | p["delegation"] = serde_json::json!({ "expires": d.expires }); |
| 131 | 21× | events.push(signed_event(&owner, "human", "agent.added", p)); |
| 132 | 21× | w.append(events).await?; |
| 133 | 21× | println!("{} {}{}{}", ui::done("Added"), ui::subject(&a.name), ui::sep(), ui::author(format!("{}'s agent", owner.identity.name))); |
| 134 | 21× | println!("{}", ui::kv("key", ui::hash(agent.key.to_hex()))); |
| 135 | 21× | let until = d.expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into()); |
| 136 | 21× | println!("{}", ui::kv("delegated", until)); |
| 137 | | |
| 138 | 21× | let shim = if ctx.remote()?.is_some() { |
| 139 | 2× | format!("spectra mcp --agent {} --home {}", a.name, a.home.display()) |
| 140 | | } else { |
| 141 | 19× | format!("spectra mcp --agent {}", a.name) |
| 142 | | }; |
| 143 | 21× | println!(" {} {} {}", ui::note(), ui::dim("connect its client with"), ui::id(shim)); |
| 144 | 21× | Ok(0) |
| 145 | 23× | } |
| 146 | |
|
| 147 | |
|
| 148 | | #[derive(sqlx::FromRow)] |
| 149 | | struct AgentRow { |
| 150 | | name: String, |
| 151 | | key: String, |
| 152 | | kind: String, |
| 153 | | project: String, |
| 154 | | disabled_ms: Option<i64>, |
| 155 | | presence: Option<String>, |
| 156 | | status: Option<String>, |
| 157 | | } |
| 158 | |
|
| 159 | 6× | pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> { |
| 160 | 6× | let out = match ctx.owner_client(&owner_home(ctx, None)).await? { |
| 161 | 2× | Some(c) => call(&c, "GET", "/api/v1/agents", None).await?.as_array().cloned().unwrap_or_default(), |
| 162 | 4× | None => list_local(ctx).await?, |
| 163 | | }; |
| 164 | 6× | print_list(out, json) |
| 165 | 6× | } |
| 166 | |
|
| 167 | 4× | async fn list_local(ctx: &Ctx) -> Result<Vec<serde_json::Value>> { |
| 168 | 4× | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
| 169 | 4× | let p = Principals::load(&ledger).await?; |
| 170 | 4× | let rows: Vec<AgentRow> = sqlx::query_as( |
| 171 | 4× | "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", |
| 172 | 4× | ) |
| 173 | 4× | .fetch_all(ledger.pool()) |
| 174 | 4× | .await?; |
| 175 | 4× | let owner = p.owner(); |
| 176 | 4× | let now = now_ms(); |
| 177 | 4× | let mut out = Vec::new(); |
| 178 | 4× | for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows { |
| 179 | 4× | let k = spectra_core::identity::KeyId::from_hex(&key).context("a stored key")?; |
| 180 | 4× | let state = match p.principal(&k, now) { |
| 181 | 2× | Ok(_) => "ok".to_string(), |
| 182 | 2× | Err(why) => why, |
| 183 | | }; |
| 184 | 4× | let expires = owner.and_then(|o| o.delegate(&k)).and_then(|d| d.expires); |
| 185 | 4× | let who = match p.principal(&k, now) { |
| 186 | 2× | Ok(pr @ Principal::Agent { .. }) => pr.display(), |
| 187 | 2× | _ => format!("{name} · {}'s agent", owner.map(|o| o.name.as_str()).unwrap_or("?")), |
| 188 | | }; |
| 189 | 4× | let presence = if disabled.is_some() { "offline".to_string() } else { presence.unwrap_or_else(|| "offline".into()) }; |
| 190 | 4× | 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() })); |
| 191 | | } |
| 192 | 4× | Ok(out) |
| 193 | 4× | } |
| 194 | |
|
| 195 | 6× | fn print_list(out: Vec<serde_json::Value>, json: bool) -> Result<i32> { |
| 196 | 6× | if json { |
| 197 | 5× | println!("{}", serde_json::Value::Array(out)); |
| 198 | 5× | return Ok(0); |
| 199 | 1× | } |
| 200 | 1× | if out.is_empty() { |
| 201 | 0× | println!("{} {}", ui::note(), ui::dim("no agents yet: spectra agent add NAME --kind KIND --project PROJECT --home DIR")); |
| 202 | 1× | } |
| 203 | 1× | for a in out { |
| 204 | 1× | let state = a["state"].as_str().unwrap_or(""); |
| 205 | 1× | let until = a["delegation_expires"].as_u64().map(|e| format!("{}until {}", ui::sep(), super::format::when(e))).unwrap_or_default(); |
| 206 | 1× | let display = a["display"].as_str().unwrap_or(""); |
| 207 | 1× | let (name, owner) = display.split_once(" · ").unwrap_or((display, "")); |
| 208 | 1× | println!( |
| 209 | | "{} {} {}{}{}", |
| 210 | 1× | marks::health(state), |
| 211 | 1× | marks::presence(a["presence"].as_str().unwrap_or("offline")), |
| 212 | 1× | ui::subject(name), |
| 213 | 1× | ui::sep(), |
| 214 | 1× | ui::author(owner) |
| 215 | | ); |
| 216 | 1× | if let Some(st) = a["status"].as_str() { |
| 217 | 0× | println!(" {} {}", ui::note(), st); |
| 218 | 1× | } |
| 219 | 1× | println!( |
| 220 | | " {}", |
| 221 | 1× | ui::dim(format!( |
| 222 | | "{}{}{}{}{}{until}", |
| 223 | 1× | a["kind"].as_str().unwrap_or(""), |
| 224 | 1× | ui::sep(), |
| 225 | 1× | a["project"].as_str().unwrap_or(""), |
| 226 | 1× | ui::sep(), |
| 227 | 1× | &a["key"].as_str().unwrap_or("")[..12] |
| 228 | | )) |
| 229 | | ); |
| 230 | 1× | if state != "ok" { |
| 231 | 1× | println!(" {} {}", ui::caution(), ui::warn(state)); |
| 232 | 1× | } |
| 233 | | } |
| 234 | 1× | Ok(0) |
| 235 | 6× | } |
| 236 | |
|
| 237 | 3× | pub async fn disable(ctx: &Ctx, name: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
| 238 | 3× | let home = owner_home(ctx, owner_home_flag); |
| 239 | 3× | let w = ctx.owner_writer(&home).await?; |
| 240 | 2× | let known = match (w.ledger(), w.client()) { |
| 241 | 2× | (_, Some(c)) => call(c, "GET", "/api/v1/agents", None).await?.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == name)), |
| 242 | 1× | (Some(l), None) => Principals::load(l).await?.agent_key(name).is_some(), |
| 243 | 0× | (None, None) => false, |
| 244 | | }; |
| 245 | 2× | if !known { |
| 246 | 0× | bail!("no agent named {name}"); |
| 247 | 2× | } |
| 248 | | |
| 249 | 2× | if let Some(key) = agent_key(&w, name).await? { |
| 250 | 2× | if home_identity(&home)?.delegate(&key).is_some() { |
| 251 | 2× | spectra_core::keys::undelegate(&home, key)?; |
| 252 | 0× | } |
| 253 | 0× | } |
| 254 | 2× | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
| 255 | 2× | let owner = KeyPair::load_home(&home)?; |
| 256 | 2× | events.push(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name }))); |
| 257 | 2× | w.append(events).await?; |
| 258 | 2× | println!("{} {}", ui::done("Disabled"), ui::subject(name)); |
| 259 | 2× | println!(" {} {}", ui::note(), ui::dim("its key is refused from now on; earlier events keep their signatures")); |
| 260 | 2× | Ok(0) |
| 261 | 3× | } |
| 262 | |
|
| 263 | |
|
| 264 | 3× | async fn agent_key(w: &super::Writer, name: &str) -> Result<Option<spectra_core::identity::KeyId>> { |
| 265 | 3× | Ok(match (w.client(), w.ledger()) { |
| 266 | 1× | (Some(c), _) => call(c, "GET", "/api/v1/agents", None).await?.as_array().and_then(|l| { |
| 267 | 2× | l.iter().find(|x| x["name"] == name).and_then(|x| x["key"].as_str().and_then(spectra_core::identity::KeyId::from_hex)) |
| 268 | 1× | }), |
| 269 | 2× | (None, Some(l)) => Principals::load(l).await?.agent_key(name), |
| 270 | 0× | (None, None) => None, |
| 271 | | }) |
| 272 | 3× | } |
| 273 | |
|
| 274 | |
|
| 275 | |
|
| 276 | 1× | pub async fn renew(ctx: &Ctx, name: &str, expires: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
| 277 | 1× | let home = owner_home(ctx, owner_home_flag); |
| 278 | 1× | let w = ctx.owner_writer(&home).await?; |
| 279 | 1× | let key = agent_key(&w, name).await?.with_context(|| format!("no agent named {name}"))?; |
| 280 | 1× | let expires = expiry(expires)?; |
| 281 | 1× | spectra_core::keys::delegate(&home, key, expires)?; |
| 282 | 1× | let events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
| 283 | 1× | w.append(events).await?; |
| 284 | 1× | let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into()); |
| 285 | 1× | println!("{} {}{}{}", ui::done("Renewed"), ui::subject(name), ui::sep(), until); |
| 286 | 1× | Ok(0) |
| 287 | 1× | } |
| 288 | |
|
| 289 | 46× | pub fn valid_name(n: &str) -> bool { |
| 290 | 46× | !n.is_empty() |
| 291 | 46× | && n.len() <= 64 |
| 292 | 46× | && n.bytes().next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) |
| 293 | 371× | && n.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') |
| 294 | 46× | } |