OXYGENHUB

Coverage / src/cli/mod.rs 84.4%

195 of 231 lines run, 35 of 86 functions · run #26 on main · run missed

1
//! The `spectra` command line (spec §19). Output is modern: colour and
2
//! glyphs on a UTF-8 terminal, plain when piped, with `--plain` or with
3
//! `NO_COLOR`; `--json` prints one document on stdout and nothing else.
4
5
pub mod admin;
6
pub mod agent;
7
pub mod connect;
8
pub mod format;
9
pub mod human;
10
pub mod identity;
11
pub mod marks;
12
pub mod mcp;
13
pub mod open;
14
pub mod project;
15
pub mod serve;
16
pub mod ui;
17
18
use anyhow::{bail, Context, Result};
19
use clap::{Parser, Subcommand};
20
use std::path::PathBuf;
21
22
#[derive(Parser)]
23
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
24
pub struct Cli {
25
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
26
    #[arg(long, global = true)]
27
    pub plain: bool,
28
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
29
    #[arg(long, global = true, env = "SPECTRA_DATA")]
30
    pub data: Option<PathBuf>,
31
    #[command(subcommand)]
32
    pub command: Command,
33
}
34
35
#[derive(Subcommand)]
36
pub enum Command {
37
    /// The version and build of this binary
38
    Version {
39
        /// Machine-readable output
40
        #[arg(long)]
41
        json: bool,
42
    },
43
    /// Make this machine's Spectra: record the owner's identity (read from
44
    /// their OXY_HOME and proven by signing) and make the instance key
45
    Init {
46
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
47
        #[arg(long)]
48
        owner_home: Option<PathBuf>,
49
        /// This instance's name (default: the host name)
50
        #[arg(long)]
51
        name: Option<String>,
52
        /// Set up a server without the owner's secret key: name the owner's
53
        /// KeyId, and they claim it from their machine with `spectra
54
        /// connect` (spec §8.6)
55
        #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")]
56
        owner: Option<String>,
57
    },
58
    /// Use a Spectra on another machine: trust it through your delegation to
59
    /// its instance key, pin its certificate, and claim it if it is new
60
    Connect {
61
        /// The server, https://HOST[:PORT]
62
        url: String,
63
        /// Trust the server if its instance key starts with this, without asking
64
        #[arg(long, value_name = "KEYID-PREFIX")]
65
        expect: Option<String>,
66
        /// Trust it without asking (for scripts)
67
        #[arg(long, short = 'y')]
68
        yes: bool,
69
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
70
        #[arg(long)]
71
        owner_home: Option<PathBuf>,
72
    },
73
    /// The owner's and the instance's identities: show, refresh
74
    Identity {
75
        #[command(subcommand)]
76
        command: IdentityCmd,
77
    },
78
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
79
    Serve {
80
        /// The address and port for browsers and HTTP clients (default:
81
        /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other
82
        /// machines connect over TLS (spec §8.6)
83
        #[arg(long)]
84
        bind: Option<String>,
85
        /// TLS on the TCP port: auto (beyond loopback only), on, or off
86
        /// (default: `[server] tls`, else auto)
87
        #[arg(long)]
88
        tls: Option<String>,
89
        /// Allow a non-loopback address in plain HTTP
90
        #[arg(long)]
91
        insecure: bool,
92
        /// Open Radar in the browser once serving (signs a link as the owner)
93
        #[arg(long)]
94
        open: bool,
95
    },
96
    /// Sign in to Radar: a one-time link signed with the owner's key
97
    Open {
98
        #[arg(long)]
99
        owner_home: Option<PathBuf>,
100
        /// Print the link without opening a browser
101
        #[arg(long)]
102
        no_browser: bool,
103
        /// With Spectra on another machine: the loopback port of the proxy
104
        /// Radar is served through (spec §8.6)
105
        #[arg(long, default_value_t = 7350)]
106
        port: u16,
107
    },
108
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
109
    Mcp {
110
        /// The agent it speaks for
111
        #[arg(long)]
112
        agent: String,
113
        /// The agent's home (default: the one recorded when it was added)
114
        #[arg(long)]
115
        home: Option<PathBuf>,
116
    },
117
    /// Post a message as yourself
118
    Say {
119
        /// What to say (Markdown; @name mentions)
120
        body: String,
121
        /// The channel (default: #general)
122
        #[arg(short, long)]
123
        channel: Option<String>,
124
        /// Reply in the thread of this message
125
        #[arg(long)]
126
        reply_to: Option<String>,
127
        #[arg(long)]
128
        json: bool,
129
        #[arg(long)]
130
        owner_home: Option<PathBuf>,
131
    },
132
    /// Your unread mentions and direct messages; --follow waits for more
133
    Inbox {
134
        #[arg(long)]
135
        follow: bool,
136
        #[arg(long)]
137
        json: bool,
138
        #[arg(long)]
139
        owner_home: Option<PathBuf>,
140
    },
141
    /// Search what was said: words, "phrases", prefix*, AND/OR/NOT
142
    Search {
143
        query: String,
144
        #[arg(long)]
145
        channel: Option<String>,
146
        #[arg(long)]
147
        author: Option<String>,
148
        /// 7d, 3h, 30m
149
        #[arg(long)]
150
        since: Option<String>,
151
        #[arg(long, default_value_t = 20)]
152
        limit: u32,
153
        #[arg(long)]
154
        json: bool,
155
    },
156
    /// Any API call, signed as the owner: JSON out
157
    Api {
158
        method: String,
159
        path: String,
160
        /// The request body (JSON)
161
        #[arg(short = 'd', long = "body")]
162
        body: Option<String>,
163
        #[arg(long)]
164
        owner_home: Option<PathBuf>,
165
    },
166
    /// The checkouts agents work in: add, list
167
    Project {
168
        #[command(subcommand)]
169
        command: ProjectCmd,
170
    },
171
    /// Agents, each its owner's delegate: add, list, disable
172
    Agent {
173
        #[command(subcommand)]
174
        command: AgentCmd,
175
    },
176
    /// The ledger's own checks: verify, check, rebuild
177
    Admin {
178
        #[command(subcommand)]
179
        command: Admin,
180
    },
181
}
182
183
#[derive(Subcommand)]
184
pub enum IdentityCmd {
185
    /// Make your own identity: a key pair that signs as you (spec §8.0)
186
    Create {
187
        /// Your name, as agents and Radar show it
188
        #[arg(long)]
189
        name: String,
190
        /// An email or URL, if you want one shown
191
        #[arg(long, default_value = "")]
192
        contact: String,
193
        /// Where to keep it (default: $SPECTRA_HOME, else ~/.config/spectra)
194
        #[arg(long)]
195
        home: Option<PathBuf>,
196
    },
197
    /// The owner and the instance as the ledger knows them
198
    Show {
199
        #[arg(long)]
200
        json: bool,
201
    },
202
    /// Record the owner's current identity if the ledger has missed a successor
203
    Refresh {
204
        #[arg(long)]
205
        owner_home: Option<PathBuf>,
206
    },
207
}
208
209
#[derive(Subcommand)]
210
pub enum ProjectCmd {
211
    /// Add a project: any kind of work, with a folder if it has one
212
    Add {
213
        /// A folder (its name becomes the project's), or just a name
214
        target: String,
215
        /// Its name, when TARGET is a folder
216
        #[arg(long)]
217
        name: Option<String>,
218
        /// The work's folder, when TARGET is a name; Spectra never reads it
219
        #[arg(long)]
220
        path: Option<PathBuf>,
221
        /// What the work is, for agents and Radar
222
        #[arg(long, short = 'd', default_value = "")]
223
        description: String,
224
        /// A link to it, if any
225
        #[arg(long)]
226
        url: Option<String>,
227
        #[arg(long)]
228
        owner_home: Option<PathBuf>,
229
    },
230
    /// Say that one project depends on another (`delegate to: downstream` follows links)
231
    Link {
232
        project: String,
233
        #[arg(long)]
234
        depends_on: String,
235
        /// Remove the link instead
236
        #[arg(long)]
237
        remove: bool,
238
        #[arg(long)]
239
        owner_home: Option<PathBuf>,
240
    },
241
    /// Every project with its links and agents
242
    List {
243
        #[arg(long)]
244
        json: bool,
245
    },
246
}
247
248
#[derive(Subcommand)]
249
pub enum AgentCmd {
250
    /// Make an agent's key in its own home, delegate to it, and record it
251
    Add {
252
        /// Lower-case letters, digits and dashes
253
        name: String,
254
        /// claude-code, antigravity, kimi-code, codex or other
255
        #[arg(long)]
256
        kind: String,
257
        /// The project it works in
258
        #[arg(long)]
259
        project: String,
260
        /// The agent's own identity home, where its key is made and kept
261
        #[arg(long)]
262
        home: PathBuf,
263
        /// Bind the key to this Unix user: a request from another is refused
264
        #[arg(long)]
265
        uid: Option<u32>,
266
        /// Bind the key to the Unix user of its first signed connection
267
        #[arg(long, conflicts_with = "uid")]
268
        learn_uid: bool,
269
        /// How long the agent may act for you: 30m, 8h, 7d (default: until disabled)
270
        #[arg(long)]
271
        expires: Option<String>,
272
        /// Delegate without asking (for scripts)
273
        #[arg(long, short = 'y')]
274
        yes: bool,
275
        /// The owner's identity home
276
        #[arg(long)]
277
        owner_home: Option<PathBuf>,
278
    },
279
    /// Give an agent more time: delegate to its key again with a new expiry
280
    Renew {
281
        name: String,
282
        /// 30m, 8h, 7d, or `never`
283
        #[arg(long)]
284
        expires: String,
285
        #[arg(long)]
286
        owner_home: Option<PathBuf>,
287
    },
288
    /// Every agent with its owner, key, delegation and state
289
    List {
290
        #[arg(long)]
291
        json: bool,
292
    },
293
    /// Refuse an agent's key from now on
294
    Disable {
295
        name: String,
296
        #[arg(long)]
297
        owner_home: Option<PathBuf>,
298
    },
299
}
300
301
#[derive(Subcommand)]
302
pub enum Admin {
303
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
304
    Verify {
305
        /// Machine-readable output
306
        #[arg(long)]
307
        json: bool,
308
    },
309
    /// Whether the projections equal a rebuild from the ledger
310
    Check,
311
    /// Replay the ledger into every projection
312
    Rebuild,
313
}
314
315
/// What every command needs: where the data lives.
316
pub struct Ctx {
317
    pub data: PathBuf,
318
}
319
320
impl Ctx {
321
    /// The ledger's database, which must exist.
322153×
    pub fn db(&self) -> Result<PathBuf> {
323153×
        let db = self.data.join("spectra.db");
324153×
        if !db.exists() {
3251×
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
326152×
        }
327152×
        Ok(db)
328153×
    }
329
330
    /// The ledger, for writing: the instance key signs its checkpoints, and
331
    /// the lock keeps any other writer out.
33251×
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
33351×
        let db = self.db()?;
33451×
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
33551×
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
33651×
    }
337
338
    /// The daemon's Unix socket.
339106×
    pub fn socket(&self) -> PathBuf {
340106×
        self.data.join("spectra.sock")
341106×
    }
342
343
    /// The Spectra on another machine this one uses, if any (spec §8.6).
344147×
    pub fn remote(&self) -> Result<Option<connect::ClientCfg>> {
345147×
        connect::load()
346147×
    }
347
348
    /// Where the daemon is: the configured server, else the local socket.
3490×
    pub fn endpoint(&self) -> Result<spectra_mcp::client::Endpoint> {
3500×
        Ok(match self.remote()? {
3510×
            Some(c) => c.endpoint()?,
3520×
            None => spectra_mcp::client::Endpoint::Unix(self.socket()),
353
        })
3540×
    }
355
356
    /// A client signing as the owner: to the configured server (spec §8.6),
357
    /// or to the local daemon when it is serving; none when neither. It
358
    /// signs with the owner identity the ledger records, since the daemon
359
    /// may not know a newer one yet.
36044×
    pub async fn owner_client(&self, owner_home: &std::path::Path) -> Result<Option<spectra_mcp::client::Client>> {
361
        use spectra_mcp::client::Client;
36244×
        if let Some(cfg) = self.remote()? {
36310×
            let ep = cfg.endpoint()?;
36410×
            let pair = remote_owner(&ep, owner_home).await?;
36510×
            return Ok(Some(Client::at(ep, Some(pair))));
36634×
        }
36734×
        if !self.data.join("spectra.db").exists() || !Client::alive(&self.socket()).await {
36830×
            return Ok(None);
3694×
        }
3704×
        let ledger = spectra_core::ledger::Ledger::open_read_only(&self.db()?).await?;
3714×
        Ok(Some(Client::new(&self.socket(), Some(identity::recorded_owner(&ledger, owner_home).await?))))
37244×
    }
373
374
    /// Somewhere to append the owner's signed events: the configured server,
375
    /// the local daemon when it is serving, else straight to the ledger
376
    /// (spec §8.5, §8.6).
37750×
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
37850×
        if let Some(cfg) = self.remote()? {
3794×
            let ep = cfg.endpoint()?;
3804×
            let pair = remote_owner(&ep, owner_home).await?;
3814×
            let recorded = pair.identity.clone();
3824×
            return Ok(Writer::Remote { client: Box::new(spectra_mcp::client::Client::at(ep, Some(pair))), recorded });
38346×
        }
38446×
        let db = self.db()?;
38546×
        if spectra_mcp::client::Client::alive(&self.socket()).await {
3862×
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
3872×
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
3882×
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
38944×
        }
39044×
        Ok(Writer::Direct(self.writer().await?))
39150×
    }
392
393
    /// Refuse a command that needs the ledger file on this machine.
3947×
    pub fn local_only(&self, what: &str) -> Result<()> {
3957×
        if self.remote()?.is_some() {
3961×
            bail!("{what} runs on the server, which holds the ledger (this machine uses {})", connect::path().display());
3976×
        }
3986×
        Ok(())
3997×
    }
400
401
    /// `[owner] home` from spectra.toml.
40294×
    pub fn config_owner_home(&self) -> Option<PathBuf> {
40394×
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
40462×
        let t: toml::Table = text.parse().ok()?;
40562×
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
40694×
    }
407
}
408
409
/// Where the owner's events go.
410
pub enum Writer {
411
    Direct(spectra_core::ledger::Ledger),
412
    Daemon {
413
        ledger: spectra_core::ledger::Ledger,
414
        client: Box<spectra_mcp::client::Client>,
415
    },
416
    /// A Spectra on another machine; `recorded` is the owner identity it knows.
417
    Remote {
418
        client: Box<spectra_mcp::client::Client>,
419
        recorded: spectra_core::identity::Identity,
420
    },
421
}
422
423
impl Writer {
424
    /// The ledger on this machine, for reading, if there is one.
42530×
    pub fn ledger(&self) -> Option<&spectra_core::ledger::Ledger> {
42630×
        match self {
42727×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => Some(l),
4283×
            Writer::Remote { .. } => None,
429
        }
43030×
    }
431
432
    /// A client of the daemon, when one is serving.
43330×
    pub fn client(&self) -> Option<&spectra_mcp::client::Client> {
43430×
        match self {
4354×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => Some(client),
43626×
            Writer::Direct(_) => None,
437
        }
43830×
    }
439
440
    /// The rotation that brings the recorded owner to the current identity
441
    /// in `home`, if it has moved.
44247×
    pub async fn refresh_event(&self, home: &std::path::Path) -> Result<Option<spectra_core::event::NewEvent>> {
44347×
        match self {
44443×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => identity::refresh_event(l, home).await,
4454×
            Writer::Remote { recorded, .. } => identity::rotation(recorded, home),
446
        }
44747×
    }
448
449
    /// Append events signed over their payload, then checkpoint.
45047×
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
45147×
        match self {
45241×
            Writer::Direct(l) => {
45341×
                l.append(events).await?;
45441×
                l.checkpoint().await?;
455
            }
4564×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => {
4576×
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
4586×
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
4596×
                if r.status != 200 {
4600×
                    let why = r
4610×
                        .json()
4620×
                        .ok()
4630×
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
4640×
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
4650×
                    bail!("the daemon refused: {why}");
4666×
                }
467
            }
468
        }
46947×
        Ok(())
47047×
    }
471
}
472
473
/// The owner identity a server records, as a key pair from the owner's
474
/// home: what requests to it are signed with.
47514×
async fn remote_owner(ep: &spectra_mcp::client::Endpoint, home: &std::path::Path) -> Result<spectra_core::keys::KeyPair> {
47614×
    let r = spectra_mcp::client::Client::at(ep.clone(), None).send("GET", "/api/v1/instance", &[], vec![]).await?;
47714×
    let info = r.json()?;
47814×
    let key = info.get("owner").and_then(|o| o.as_str()).context("the server has no owner yet: spectra connect claims it")?;
47914×
    let keys = home.join("keys");
48014×
    spectra_core::keys::KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity")))
48114×
        .with_context(|| format!("the server's owner {} is not an identity in {}", &key[..12.min(key.len())], home.display()))
48214×
}
483
484
/// An API call; a refusal becomes an error that says why.
48520×
pub async fn call(c: &spectra_mcp::client::Client, method: &str, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value> {
48620×
    let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?;
48720×
    let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null);
48820×
    if r.status >= 400 {
4890×
        let why = v
4900×
            .get("detail")
4910×
            .or(v.get("title"))
4920×
            .and_then(serde_json::Value::as_str)
4930×
            .map(String::from)
4940×
            .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
4950×
        bail!("{why}");
49620×
    }
49720×
    Ok(v)
49820×
}
499
500
/// A query string value, percent-encoded.
5014×
pub fn encode(s: &str) -> String {
5024×
    s.bytes()
50326×
        .map(|b| match b {
50426×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
5050×
            _ => format!("%{b:02X}"),
50626×
        })
5074×
        .collect()
5084×
}
509
51020×
fn init_logging() {
51120×
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
51220×
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
51320×
}
514
5150×
fn default_data() -> PathBuf {
5160×
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
5170×
        return PathBuf::from(x).join("spectra");
5180×
    }
5190×
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
5200×
    PathBuf::from(home).join(".local").join("share").join("spectra")
5210×
}
522
523163×
fn runtime() -> Result<tokio::runtime::Runtime> {
524163×
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
525163×
}
526
527164×
pub fn run() -> Result<i32> {
528164×
    let cli = Cli::parse();
529164×
    ui::init(cli.plain);
530164×
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
53127×
    match cli.command {
5321×
        Command::Version { json } => version(json),
5338×
        Command::Connect { url, owner_home, expect, yes } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home, expect, yes)),
5346×
        Command::Init { owner_home, name, owner: Some(key) } => {
5356×
            let _ = owner_home;
5366×
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
537
        }
53821×
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
5394×
        Command::Identity { command } => runtime()?.block_on(async {
5404×
            match command {
5412×
                IdentityCmd::Create { name, contact, home } => identity::create(&name, &contact, home),
5422×
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
5430×
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
544
            }
5454×
        }),
54620×
        Command::Serve { bind, tls, insecure, open } => {
54720×
            init_logging();
54820×
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
549
        }
5501×
        Command::Open { owner_home, no_browser, port } => runtime()?.block_on(async {
5511×
            if ctx.remote()?.is_some() {
5520×
                open::open_remote(&ctx, owner_home, !no_browser, port).await
553
            } else {
5541×
                open::open(&ctx, owner_home, !no_browser).await
555
            }
5561×
        }),
5572×
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
5583×
        Command::Say { body, channel, reply_to, json, owner_home } => {
5593×
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
560
        }
5612×
        Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)),
5623×
        Command::Search { query, channel, author, since, limit, json } => {
5633×
            runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json))
564
        }
5652×
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
56630×
        Command::Project { command } => runtime()?.block_on(async {
56730×
            match command {
56823×
                ProjectCmd::Add { target, name, path, description, url, owner_home } => {
56923×
                    project::add(&ctx, project::Add { target, name, path, description, url }, owner_home).await
570
                }
5713×
                ProjectCmd::Link { project, depends_on, remove, owner_home } => {
5723×
                    project::link(&ctx, &project, &depends_on, remove, owner_home).await
573
                }
5744×
                ProjectCmd::List { json } => project::list(&ctx, json).await,
575
            }
57630×
        }),
57733×
        Command::Agent { command } => runtime()?.block_on(async {
57833×
            match command {
57923×
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home } => {
58023×
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home }).await
581
                }
5821×
                AgentCmd::Renew { name, expires, owner_home } => agent::renew(&ctx, &name, &expires, owner_home).await,
5836×
                AgentCmd::List { json } => agent::list(&ctx, json).await,
5843×
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
585
            }
58633×
        }),
58728×
        Command::Admin { command } => runtime()?.block_on(async {
58828×
            match command {
58921×
                Admin::Verify { json } => admin::verify(&ctx, json).await,
5905×
                Admin::Check => admin::check(&ctx).await,
5912×
                Admin::Rebuild => admin::rebuild(&ctx).await,
592
            }
59328×
        }),
594
    }
595164×
}
596
5971×
fn version(as_json: bool) -> Result<i32> {
5981×
    if as_json {
5991×
        println!(
6001×
            "{}",
6011×
            serde_json::json!({
6021×
                "version": env!("CARGO_PKG_VERSION"),
6031×
                "build": env!("SPECTRA_BUILD_ID"),
6041×
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
6051×
                "ledger_format": spectra_core::LEDGER_FORMAT,
6061×
            })
6071×
        );
6081×
    } else {
6090×
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
6100×
    }
6111×
    Ok(0)
6121×
}