OXYGENHUB
# 5d695d869fbf spectra / src / cli / agent.rs
8.0 KiB · Rust · last changed The command line in oxy's style: ticks and verbs, key-value rows, sections, hints, spinners; plain when piped 2 days ago source blame history raw
09752034 Andy McCall ↶ 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, refresh_owner};
5d695d86 Andy McCall ↶ 8
use super::{marks, ui, Ctx};
09752034 Andy McCall ↶ 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
    {
38
        let ledger = Ledger::open_read_only(&ctx.db()?).await?;
39
        let p = Principals::load(&ledger).await?;
40
        if p.agent_key(&a.name).is_some() {
41
            bail!("an agent named {} already exists (spectra agent list)", a.name);
42
        }
43
    }
44
    let home = owner_home(ctx, a.owner_home.clone());
45
    // The agent's key, made once in its own home and reused if `add` is run again.
46
    let agent = if a.home.join("keys").exists() {
47
        KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))?
48
    } else {
49
        let pair = KeyPair::create_home(&a.home, &a.name, "")?;
5d695d86 Andy McCall ↶ 50
        println!("{} {}  {}", ui::done("Created"), ui::subject(format!("{}'s key", a.name)), ui::path(a.home.display()));
09752034 Andy McCall ↶ 51
        pair
52
    };
53
    let delegated = |id: &oxy_core::identity::Identity| id.delegates_to(&agent.key, DELEGATION_SCOPE, now_ms());
54
    if !delegated(&home_identity(&home)?) {
5d695d86 Andy McCall ↶ 55
        println!("{} {}", ui::caution(), ui::warn(format!("{} is not yours yet: delegate to its key, as yourself:", a.name)));
09752034 Andy McCall ↶ 56
        println!(
5d695d86 Andy McCall ↶ 57
            "    {}",
09752034 Andy McCall ↶ 58
            ui::id(format!(
59
                "oxy identity delegate add {} --scope changes --expires 8h",
60
                a.home.join("keys").join(format!("{}.identity", agent.key.to_hex())).display()
61
            ))
62
        );
5d695d86 Andy McCall ↶ 63
        let spin = ui::spinner(&format!(
64
            "waiting for your delegation to {} (up to {} s; Ctrl-C stops, and running this again resumes)",
65
            a.name, a.wait_secs
66
        ));
09752034 Andy McCall ↶ 67
        let deadline = std::time::Instant::now() + Duration::from_secs(a.wait_secs);
68
        loop {
69
            if home_identity(&home).is_ok_and(|id| delegated(&id)) {
5d695d86 Andy McCall ↶ 70
                spin.clear();
09752034 Andy McCall ↶ 71
                break;
72
            }
73
            if std::time::Instant::now() >= deadline {
5d695d86 Andy McCall ↶ 74
                spin.clear();
09752034 Andy McCall ↶ 75
                bail!("{}'s key is not delegated yet: run the command above, then this again", a.name);
76
            }
77
            tokio::time::sleep(Duration::from_secs(2)).await;
78
        }
79
    }
80
    let ledger = ctx.writer().await?;
81
    refresh_owner(&ledger, &home).await?;
82
    let owner = KeyPair::load_home(&home)?;
83
    let mut p = identity_payload(&agent.identity);
84
    p["name"] = a.name.clone().into();
85
    p["kind"] = a.kind.into();
86
    p["project"] = a.project.into();
87
    p["home"] = a.home.display().to_string().into();
88
    p["owner"] = owner.key.to_hex().into();
89
    p["owner_name"] = owner.identity.name.clone().into();
90
    p["scopes"] = DEFAULT_SCOPES.into();
91
    if let Some(u) = a.uid {
92
        p["uid"] = u.into();
93
    }
94
    p["learn_uid"] = a.learn_uid.into();
95
    let d = owner.identity.delegate(&agent.key).context("the delegation vanished")?;
96
    p["delegation"] = serde_json::json!({ "scope": d.scope.names(), "expires": d.expires });
97
    ledger.append_one(signed_event(&owner, "human", "agent.added", p)).await?;
98
    ledger.checkpoint().await?;
5d695d86 Andy McCall ↶ 99
    println!("{} {}{}{}", ui::done("Added"), ui::subject(&a.name), ui::sep(), ui::author(format!("{}'s agent", owner.identity.name)));
100
    println!("{}", ui::kv("key", ui::hash(agent.key.to_hex())));
101
    let until = d.expires.map(|e| format!(" until {}", super::format::when(e))).unwrap_or_default();
102
    println!("{}", ui::kv("delegated", format!("{}{until}", d.scope.names().join(","))));
103
    println!("  {} {} {}", ui::note(), ui::dim("connect its client with"), ui::id(format!("spectra mcp --agent {}", a.name)));
09752034 Andy McCall ↶ 104
    Ok(0)
105
}
106
107
pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> {
108
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
109
    let p = Principals::load(&ledger).await?;
110
    let rows: Vec<(String, String, String, String, Option<i64>)> =
111
        sqlx::query_as("SELECT name, key, kind, project, disabled_ms FROM agents ORDER BY name").fetch_all(ledger.pool()).await?;
112
    let owner = p.owner();
113
    let now = now_ms();
114
    let mut out = Vec::new();
115
    for (name, key, kind, project, disabled) in rows {
116
        let k = oxy_core::hash::ObjectRef::from_hex(&key).context("a stored key")?;
117
        let state = match p.principal(&k, now) {
118
            Ok(_) => "ok".to_string(),
119
            Err(why) => why,
120
        };
121
        let expires = owner.and_then(|o| o.delegate(&k)).and_then(|d| d.expires);
122
        let who = match p.principal(&k, now) {
123
            Ok(pr @ Principal::Agent { .. }) => pr.display(),
124
            _ => format!("{name} · {}'s agent", owner.map(|o| o.name.as_str()).unwrap_or("?")),
125
        };
126
        out.push(serde_json::json!({ "name": name, "display": who, "key": key, "kind": kind, "project": project, "state": state, "delegation_expires": expires, "disabled": disabled.is_some() }));
127
    }
128
    if json {
129
        println!("{}", serde_json::Value::Array(out));
130
        return Ok(0);
131
    }
132
    if out.is_empty() {
5d695d86 Andy McCall ↶ 133
        println!("{} {}", ui::note(), ui::dim("no agents yet: spectra agent add NAME --kind KIND --project PROJECT --home DIR"));
09752034 Andy McCall ↶ 134
    }
135
    for a in out {
136
        let state = a["state"].as_str().unwrap_or("");
5d695d86 Andy McCall ↶ 137
        let until = a["delegation_expires"].as_u64().map(|e| format!("{}until {}", ui::sep(), super::format::when(e))).unwrap_or_default();
138
        let display = a["display"].as_str().unwrap_or("");
139
        let (name, owner) = display.split_once(" · ").unwrap_or((display, ""));
140
        println!("{} {}{}{}", marks::health(state), ui::subject(name), ui::sep(), ui::author(owner));
09752034 Andy McCall ↶ 141
        println!(
5d695d86 Andy McCall ↶ 142
            "  {}",
143
            ui::dim(format!(
144
                "{}{}{}{}{}{until}",
145
                a["kind"].as_str().unwrap_or(""),
146
                ui::sep(),
147
                a["project"].as_str().unwrap_or(""),
148
                ui::sep(),
149
                &a["key"].as_str().unwrap_or("")[..12]
150
            ))
09752034 Andy McCall ↶ 151
        );
152
        if state != "ok" {
5d695d86 Andy McCall ↶ 153
            println!("  {} {}", ui::caution(), ui::warn(state));
09752034 Andy McCall ↶ 154
        }
155
    }
156
    Ok(0)
157
}
158
159
pub async fn disable(ctx: &Ctx, name: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> {
160
    let ledger = ctx.writer().await?;
161
    let p = Principals::load(&ledger).await?;
162
    p.agent_key(name).with_context(|| format!("no agent named {name}"))?;
163
    let home = owner_home(ctx, owner_home_flag);
164
    refresh_owner(&ledger, &home).await?;
165
    let owner = KeyPair::load_home(&home)?;
166
    ledger.append_one(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name }))).await?;
167
    ledger.checkpoint().await?;
5d695d86 Andy McCall ↶ 168
    println!("{} {}", ui::done("Disabled"), ui::subject(name));
169
    println!("  {} {}", ui::note(), ui::dim("its key is refused from now on; earlier events keep their signatures"));
09752034 Andy McCall ↶ 170
    Ok(0)
171
}
172
173
pub fn valid_name(n: &str) -> bool {
174
    !n.is_empty()
175
        && n.len() <= 64
176
        && n.bytes().next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
177
        && n.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
178
}