| 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, DELEGATION_SCOPE}; |
| 14 |
use std::path::PathBuf; |
| 15 |
use std::time::Duration; |
| 16 |
|
| 17 |
pub const KINDS: &[&str] = &["claude-code", "antigravity", "kimi-code", "codex", "other"]; |
| 18 |
|
| 19 |
pub struct Add { |
| 20 |
pub name: String, |
| 21 |
pub kind: String, |
| 22 |
pub project: String, |
| 23 |
pub home: PathBuf, |
| 24 |
pub uid: Option<u32>, |
| 25 |
pub learn_uid: bool, |
| 26 |
pub wait_secs: u64, |
| 27 |
pub owner_home: Option<PathBuf>, |
| 28 |
} |
| 29 |
|
| 30 |
pub async fn add(ctx: &Ctx, a: Add) -> Result<i32> { |
| 31 |
if !valid_name(&a.name) { |
| 32 |
bail!("an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64"); |
| 33 |
} |
| 34 |
if !KINDS.contains(&a.kind.as_str()) { |
| 35 |
bail!("--kind is one of {}", KINDS.join(", ")); |
| 36 |
} |
| 37 |
let home = owner_home(ctx, a.owner_home.clone()); |
| 38 |
let (exists, known) = match ctx.owner_client(&home).await? { |
| 39 |
Some(c) => { |
| 40 |
let agents = call(&c, "GET", "/api/v1/agents", None).await?; |
| 41 |
let projects = call(&c, "GET", "/api/v1/projects", None).await?; |
| 42 |
let has = |v: &serde_json::Value, n: &str| v.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == n)); |
| 43 |
(has(&agents, &a.name), has(&projects, &a.project)) |
| 44 |
} |
| 45 |
None => { |
| 46 |
let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
| 47 |
let p = Principals::load(&ledger).await?; |
| 48 |
let known: Option<String> = |
| 49 |
sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(&a.project).fetch_optional(ledger.pool()).await?; |
| 50 |
(p.agent_key(&a.name).is_some(), known.is_some()) |
| 51 |
} |
| 52 |
}; |
| 53 |
if exists { |
| 54 |
bail!("an agent named {} already exists (spectra agent list)", a.name); |
| 55 |
} |
| 56 |
if !known { |
| 57 |
bail!("no project named {} (spectra project add PATH --name {})", a.project, a.project); |
| 58 |
} |
| 59 |
|
| 60 |
let agent = if a.home.join("keys").exists() { |
| 61 |
KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))? |
| 62 |
} else { |
| 63 |
let pair = KeyPair::create_home(&a.home, &a.name, "")?; |
| 64 |
println!("{} {} {}", ui::done("Created"), ui::subject(format!("{}'s key", a.name)), ui::path(a.home.display())); |
| 65 |
pair |
| 66 |
}; |
| 67 |
let delegated = |id: &oxy_core::identity::Identity| id.delegates_to(&agent.key, DELEGATION_SCOPE, now_ms()); |
| 68 |
if !delegated(&home_identity(&home)?) { |
| 69 |
println!("{} {}", ui::caution(), ui::warn(format!("{} is not yours yet: delegate to its key, as yourself:", a.name))); |
| 70 |
println!( |
| 71 |
" {}", |
| 72 |
ui::id(format!( |
| 73 |
"oxy identity delegate add {} --scope changes --expires 8h", |
| 74 |
a.home.join("keys").join(format!("{}.identity", agent.key.to_hex())).display() |
| 75 |
)) |
| 76 |
); |
| 77 |
let spin = ui::spinner(&format!( |
| 78 |
"waiting for your delegation to {} (up to {} s; Ctrl-C stops, and running this again resumes)", |
| 79 |
a.name, a.wait_secs |
| 80 |
)); |
| 81 |
let deadline = std::time::Instant::now() + Duration::from_secs(a.wait_secs); |
| 82 |
loop { |
| 83 |
if home_identity(&home).is_ok_and(|id| delegated(&id)) { |
| 84 |
spin.clear(); |
| 85 |
break; |
| 86 |
} |
| 87 |
if std::time::Instant::now() >= deadline { |
| 88 |
spin.clear(); |
| 89 |
bail!("{}'s key is not delegated yet: run the command above, then this again", a.name); |
| 90 |
} |
| 91 |
tokio::time::sleep(Duration::from_secs(2)).await; |
| 92 |
} |
| 93 |
} |
| 94 |
let w = ctx.owner_writer(&home).await?; |
| 95 |
let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
| 96 |
let owner = KeyPair::load_home(&home)?; |
| 97 |
let mut p = identity_payload(&agent.identity); |
| 98 |
p["name"] = a.name.clone().into(); |
| 99 |
p["kind"] = a.kind.into(); |
| 100 |
p["project"] = a.project.into(); |
| 101 |
p["home"] = a.home.display().to_string().into(); |
| 102 |
p["owner"] = owner.key.to_hex().into(); |
| 103 |
p["owner_name"] = owner.identity.name.clone().into(); |
| 104 |
p["scopes"] = DEFAULT_SCOPES.into(); |
| 105 |
if let Some(u) = a.uid { |
| 106 |
p["uid"] = u.into(); |
| 107 |
} |
| 108 |
p["learn_uid"] = a.learn_uid.into(); |
| 109 |
let d = owner.identity.delegate(&agent.key).context("the delegation vanished")?; |
| 110 |
p["delegation"] = serde_json::json!({ "scope": d.scope.names(), "expires": d.expires }); |
| 111 |
events.push(signed_event(&owner, "human", "agent.added", p)); |
| 112 |
w.append(events).await?; |
| 113 |
println!("{} {}{}{}", ui::done("Added"), ui::subject(&a.name), ui::sep(), ui::author(format!("{}'s agent", owner.identity.name))); |
| 114 |
println!("{}", ui::kv("key", ui::hash(agent.key.to_hex()))); |
| 115 |
let until = d.expires.map(|e| format!(" until {}", super::format::when(e))).unwrap_or_default(); |
| 116 |
println!("{}", ui::kv("delegated", format!("{}{until}", d.scope.names().join(",")))); |
| 117 |
println!(" {} {} {}", ui::note(), ui::dim("connect its client with"), ui::id(format!("spectra mcp --agent {}", a.name))); |
| 118 |
Ok(0) |
| 119 |
} |
| 120 |
|
| 121 |
|
| 122 |
#[derive(sqlx::FromRow)] |
| 123 |
struct AgentRow { |
| 124 |
name: String, |
| 125 |
key: String, |
| 126 |
kind: String, |
| 127 |
project: String, |
| 128 |
disabled_ms: Option<i64>, |
| 129 |
presence: Option<String>, |
| 130 |
status: Option<String>, |
| 131 |
} |
| 132 |
|
| 133 |
pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> { |
| 134 |
let out = match ctx.owner_client(&owner_home(ctx, None)).await? { |
| 135 |
Some(c) => call(&c, "GET", "/api/v1/agents", None).await?.as_array().cloned().unwrap_or_default(), |
| 136 |
None => list_local(ctx).await?, |
| 137 |
}; |
| 138 |
print_list(out, json) |
| 139 |
} |
| 140 |
|
| 141 |
async fn list_local(ctx: &Ctx) -> Result<Vec<serde_json::Value>> { |
| 142 |
let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
| 143 |
let p = Principals::load(&ledger).await?; |
| 144 |
let rows: Vec<AgentRow> = sqlx::query_as( |
| 145 |
"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", |
| 146 |
) |
| 147 |
.fetch_all(ledger.pool()) |
| 148 |
.await?; |
| 149 |
let owner = p.owner(); |
| 150 |
let now = now_ms(); |
| 151 |
let mut out = Vec::new(); |
| 152 |
for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows { |
| 153 |
let k = oxy_core::hash::ObjectRef::from_hex(&key).context("a stored key")?; |
| 154 |
let state = match p.principal(&k, now) { |
| 155 |
Ok(_) => "ok".to_string(), |
| 156 |
Err(why) => why, |
| 157 |
}; |
| 158 |
let expires = owner.and_then(|o| o.delegate(&k)).and_then(|d| d.expires); |
| 159 |
let who = match p.principal(&k, now) { |
| 160 |
Ok(pr @ Principal::Agent { .. }) => pr.display(), |
| 161 |
_ => format!("{name} · {}'s agent", owner.map(|o| o.name.as_str()).unwrap_or("?")), |
| 162 |
}; |
| 163 |
let presence = if disabled.is_some() { "offline".to_string() } else { presence.unwrap_or_else(|| "offline".into()) }; |
| 164 |
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() })); |
| 165 |
} |
| 166 |
Ok(out) |
| 167 |
} |
| 168 |
|
| 169 |
fn print_list(out: Vec<serde_json::Value>, json: bool) -> Result<i32> { |
| 170 |
if json { |
| 171 |
println!("{}", serde_json::Value::Array(out)); |
| 172 |
return Ok(0); |
| 173 |
} |
| 174 |
if out.is_empty() { |
| 175 |
println!("{} {}", ui::note(), ui::dim("no agents yet: spectra agent add NAME --kind KIND --project PROJECT --home DIR")); |
| 176 |
} |
| 177 |
for a in out { |
| 178 |
let state = a["state"].as_str().unwrap_or(""); |
| 179 |
let until = a["delegation_expires"].as_u64().map(|e| format!("{}until {}", ui::sep(), super::format::when(e))).unwrap_or_default(); |
| 180 |
let display = a["display"].as_str().unwrap_or(""); |
| 181 |
let (name, owner) = display.split_once(" · ").unwrap_or((display, "")); |
| 182 |
println!( |
| 183 |
"{} {} {}{}{}", |
| 184 |
marks::health(state), |
| 185 |
marks::presence(a["presence"].as_str().unwrap_or("offline")), |
| 186 |
ui::subject(name), |
| 187 |
ui::sep(), |
| 188 |
ui::author(owner) |
| 189 |
); |
| 190 |
if let Some(st) = a["status"].as_str() { |
| 191 |
println!(" {} {}", ui::note(), st); |
| 192 |
} |
| 193 |
println!( |
| 194 |
" {}", |
| 195 |
ui::dim(format!( |
| 196 |
"{}{}{}{}{}{until}", |
| 197 |
a["kind"].as_str().unwrap_or(""), |
| 198 |
ui::sep(), |
| 199 |
a["project"].as_str().unwrap_or(""), |
| 200 |
ui::sep(), |
| 201 |
&a["key"].as_str().unwrap_or("")[..12] |
| 202 |
)) |
| 203 |
); |
| 204 |
if state != "ok" { |
| 205 |
println!(" {} {}", ui::caution(), ui::warn(state)); |
| 206 |
} |
| 207 |
} |
| 208 |
Ok(0) |
| 209 |
} |
| 210 |
|
| 211 |
pub async fn disable(ctx: &Ctx, name: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
| 212 |
let home = owner_home(ctx, owner_home_flag); |
| 213 |
let w = ctx.owner_writer(&home).await?; |
| 214 |
let known = match (w.ledger(), w.client()) { |
| 215 |
(_, Some(c)) => call(c, "GET", "/api/v1/agents", None).await?.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == name)), |
| 216 |
(Some(l), None) => Principals::load(l).await?.agent_key(name).is_some(), |
| 217 |
(None, None) => false, |
| 218 |
}; |
| 219 |
if !known { |
| 220 |
bail!("no agent named {name}"); |
| 221 |
} |
| 222 |
let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
| 223 |
let owner = KeyPair::load_home(&home)?; |
| 224 |
events.push(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name }))); |
| 225 |
w.append(events).await?; |
| 226 |
println!("{} {}", ui::done("Disabled"), ui::subject(name)); |
| 227 |
println!(" {} {}", ui::note(), ui::dim("its key is refused from now on; earlier events keep their signatures")); |
| 228 |
Ok(0) |
| 229 |
} |
| 230 |
|
| 231 |
pub fn valid_name(n: &str) -> bool { |
| 232 |
!n.is_empty() |
| 233 |
&& n.len() <= 64 |
| 234 |
&& n.bytes().next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) |
| 235 |
&& n.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') |
| 236 |
} |