OXYGENHUB
# 01320eefd505 spectra / src / cli / agent.rs
10.2 KiB · Rust · last changed The command line from another machine: every command through the API (#23, completes #21) 1 day ago source blame history raw
1
//! `spectra agent`: agents as their owner's delegates (spec §8.1).
2
//!
3
//! `add` makes the agent's key pair in its own home, prints the one `oxy`
4
//! command that makes it the owner's, and records the agent only once the
5
//! owner's identity delegates to it. There is no agent without an owner.
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
    // The agent's key, made once in its own home and reused if `add` is run again.
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
/// An agent as `agent list` reads it, with its presence.
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
}