OXYGENHUB

Coverage / src/cli/mod.rs 84.5%

202 of 239 lines run, 36 of 88 functions · run #28 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 brief;
8
pub mod connect;
9
pub mod format;
10
pub mod human;
11
pub mod identity;
12
pub mod marks;
13
pub mod mcp;
14
pub mod open;
15
pub mod project;
16
pub mod serve;
17
pub mod ui;
18
19
use anyhow::{bail, Context, Result};
20
use clap::{Parser, Subcommand};
21
use std::path::PathBuf;
22
23
#[derive(Parser)]
24
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
25
pub struct Cli {
26
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
27
    #[arg(long, global = true)]
28
    pub plain: bool,
29
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
30
    #[arg(long, global = true, env = "SPECTRA_DATA")]
31
    pub data: Option<PathBuf>,
32
    #[command(subcommand)]
33
    pub command: Command,
34
}
35
36
#[derive(Subcommand)]
37
pub enum Command {
38
    /// The version and build of this binary
39
    Version {
40
        /// Machine-readable output
41
        #[arg(long)]
42
        json: bool,
43
    },
44
    /// Make this machine's Spectra: record the owner's identity (read from
45
    /// their OXY_HOME and proven by signing) and make the instance key
46
    Init {
47
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
48
        #[arg(long)]
49
        owner_home: Option<PathBuf>,
50
        /// This instance's name (default: the host name)
51
        #[arg(long)]
52
        name: Option<String>,
53
        /// Set up a server without the owner's secret key: name the owner's
54
        /// KeyId, and they claim it from their machine with `spectra
55
        /// connect` (spec §8.6)
56
        #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")]
57
        owner: Option<String>,
58
    },
59
    /// Use a Spectra on another machine: trust it through your delegation to
60
    /// its instance key, pin its certificate, and claim it if it is new
61
    Connect {
62
        /// The server, https://HOST[:PORT]
63
        url: String,
64
        /// Trust the server if its instance key starts with this, without asking
65
        #[arg(long, value_name = "KEYID-PREFIX")]
66
        expect: Option<String>,
67
        /// Trust it without asking (for scripts)
68
        #[arg(long, short = 'y')]
69
        yes: bool,
70
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
71
        #[arg(long)]
72
        owner_home: Option<PathBuf>,
73
    },
74
    /// What the agents are told: the overall brief and each project's
75
    Brief {
76
        #[command(subcommand)]
77
        command: BriefCmd,
78
    },
79
    /// The owner's and the instance's identities: show, refresh
80
    Identity {
81
        #[command(subcommand)]
82
        command: IdentityCmd,
83
    },
84
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
85
    Serve {
86
        /// The address and port for browsers and HTTP clients (default:
87
        /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other
88
        /// machines connect over TLS (spec §8.6)
89
        #[arg(long)]
90
        bind: Option<String>,
91
        /// TLS on the TCP port: auto (beyond loopback only), on, or off
92
        /// (default: `[server] tls`, else auto)
93
        #[arg(long)]
94
        tls: Option<String>,
95
        /// Allow a non-loopback address in plain HTTP
96
        #[arg(long)]
97
        insecure: bool,
98
        /// Open Radar in the browser once serving (signs a link as the owner)
99
        #[arg(long)]
100
        open: bool,
101
    },
102
    /// Sign in to Radar: a one-time link signed with the owner's key
103
    Open {
104
        #[arg(long)]
105
        owner_home: Option<PathBuf>,
106
        /// Print the link without opening a browser
107
        #[arg(long)]
108
        no_browser: bool,
109
        /// With Spectra on another machine: the loopback port of the proxy
110
        /// Radar is served through (spec §8.6)
111
        #[arg(long, default_value_t = 7350)]
112
        port: u16,
113
    },
114
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
115
    Mcp {
116
        /// The agent it speaks for
117
        #[arg(long)]
118
        agent: String,
119
        /// The agent's home (default: the one recorded when it was added)
120
        #[arg(long)]
121
        home: Option<PathBuf>,
122
    },
123
    /// Post a message as yourself
124
    Say {
125
        /// What to say (Markdown; @name mentions)
126
        body: String,
127
        /// The channel (default: #general)
128
        #[arg(short, long)]
129
        channel: Option<String>,
130
        /// Reply in the thread of this message
131
        #[arg(long)]
132
        reply_to: Option<String>,
133
        #[arg(long)]
134
        json: bool,
135
        #[arg(long)]
136
        owner_home: Option<PathBuf>,
137
    },
138
    /// Your unread mentions and direct messages; --follow waits for more
139
    Inbox {
140
        #[arg(long)]
141
        follow: bool,
142
        #[arg(long)]
143
        json: bool,
144
        #[arg(long)]
145
        owner_home: Option<PathBuf>,
146
    },
147
    /// Search what was said: words, "phrases", prefix*, AND/OR/NOT
148
    Search {
149
        query: String,
150
        #[arg(long)]
151
        channel: Option<String>,
152
        #[arg(long)]
153
        author: Option<String>,
154
        /// 7d, 3h, 30m
155
        #[arg(long)]
156
        since: Option<String>,
157
        #[arg(long, default_value_t = 20)]
158
        limit: u32,
159
        #[arg(long)]
160
        json: bool,
161
    },
162
    /// Any API call, signed as the owner: JSON out
163
    Api {
164
        method: String,
165
        path: String,
166
        /// The request body (JSON)
167
        #[arg(short = 'd', long = "body")]
168
        body: Option<String>,
169
        #[arg(long)]
170
        owner_home: Option<PathBuf>,
171
    },
172
    /// The checkouts agents work in: add, list
173
    Project {
174
        #[command(subcommand)]
175
        command: ProjectCmd,
176
    },
177
    /// Agents, each its owner's delegate: add, list, disable
178
    Agent {
179
        #[command(subcommand)]
180
        command: AgentCmd,
181
    },
182
    /// The ledger's own checks: verify, check, rebuild
183
    Admin {
184
        #[command(subcommand)]
185
        command: Admin,
186
    },
187
}
188
189
#[derive(Subcommand)]
190
pub enum IdentityCmd {
191
    /// Make your own identity: a key pair that signs as you (spec §8.0)
192
    Create {
193
        /// Your name, as agents and Radar show it
194
        #[arg(long)]
195
        name: String,
196
        /// An email or URL, if you want one shown
197
        #[arg(long, default_value = "")]
198
        contact: String,
199
        /// Where to keep it (default: $SPECTRA_HOME, else ~/.config/spectra)
200
        #[arg(long)]
201
        home: Option<PathBuf>,
202
    },
203
    /// The owner and the instance as the ledger knows them
204
    Show {
205
        #[arg(long)]
206
        json: bool,
207
    },
208
    /// Record the owner's current identity if the ledger has missed a successor
209
    Refresh {
210
        #[arg(long)]
211
        owner_home: Option<PathBuf>,
212
    },
213
}
214
215
#[derive(Subcommand)]
216
pub enum BriefCmd {
217
    /// Show the overall brief, or a project's
218
    Show {
219
        #[arg(long)]
220
        project: Option<String>,
221
        #[arg(long)]
222
        json: bool,
223
    },
224
    /// Set the overall brief, or a project's (an empty text clears it)
225
    Set {
226
        #[arg(long)]
227
        project: Option<String>,
228
        /// The text
229
        text: Option<String>,
230
        /// Read the text from a file (- for standard input)
231
        #[arg(long, short = 'f')]
232
        file: Option<PathBuf>,
233
        #[arg(long)]
234
        owner_home: Option<PathBuf>,
235
    },
236
    /// Edit it in $VISUAL or $EDITOR
237
    Edit {
238
        #[arg(long)]
239
        project: Option<String>,
240
        #[arg(long)]
241
        owner_home: Option<PathBuf>,
242
    },
243
}
244
245
#[derive(Subcommand)]
246
pub enum ProjectCmd {
247
    /// Add a project: any kind of work, with a folder if it has one
248
    Add {
249
        /// A folder (its name becomes the project's), or just a name
250
        target: String,
251
        /// Its name, when TARGET is a folder
252
        #[arg(long)]
253
        name: Option<String>,
254
        /// The work's folder, when TARGET is a name; Spectra never reads it
255
        #[arg(long)]
256
        path: Option<PathBuf>,
257
        /// What the work is, for agents and Radar
258
        #[arg(long, short = 'd', default_value = "")]
259
        description: String,
260
        /// A link to it, if any
261
        #[arg(long)]
262
        url: Option<String>,
263
        #[arg(long)]
264
        owner_home: Option<PathBuf>,
265
    },
266
    /// Say that one project depends on another (`delegate to: downstream` follows links)
267
    Link {
268
        project: String,
269
        #[arg(long)]
270
        depends_on: String,
271
        /// Remove the link instead
272
        #[arg(long)]
273
        remove: bool,
274
        #[arg(long)]
275
        owner_home: Option<PathBuf>,
276
    },
277
    /// Every project with its links and agents
278
    List {
279
        #[arg(long)]
280
        json: bool,
281
    },
282
}
283
284
#[derive(Subcommand)]
285
pub enum AgentCmd {
286
    /// Make an agent's key in its own home, delegate to it, and record it
287
    Add {
288
        /// Lower-case letters, digits and dashes
289
        name: String,
290
        /// claude-code, antigravity, kimi-code, codex or other
291
        #[arg(long)]
292
        kind: String,
293
        /// The project it works in
294
        #[arg(long)]
295
        project: String,
296
        /// The agent's own identity home, where its key is made and kept
297
        #[arg(long)]
298
        home: PathBuf,
299
        /// Bind the key to this Unix user: a request from another is refused
300
        #[arg(long)]
301
        uid: Option<u32>,
302
        /// Bind the key to the Unix user of its first signed connection
303
        #[arg(long, conflicts_with = "uid")]
304
        learn_uid: bool,
305
        /// How long the agent may act for you: 30m, 8h, 7d (default: until disabled)
306
        #[arg(long)]
307
        expires: Option<String>,
308
        /// Delegate without asking (for scripts)
309
        #[arg(long, short = 'y')]
310
        yes: bool,
311
        /// The owner's identity home
312
        #[arg(long)]
313
        owner_home: Option<PathBuf>,
314
    },
315
    /// Give an agent more time: delegate to its key again with a new expiry
316
    Renew {
317
        name: String,
318
        /// 30m, 8h, 7d, or `never`
319
        #[arg(long)]
320
        expires: String,
321
        #[arg(long)]
322
        owner_home: Option<PathBuf>,
323
    },
324
    /// Every agent with its owner, key, delegation and state
325
    List {
326
        #[arg(long)]
327
        json: bool,
328
    },
329
    /// Refuse an agent's key from now on
330
    Disable {
331
        name: String,
332
        #[arg(long)]
333
        owner_home: Option<PathBuf>,
334
    },
335
}
336
337
#[derive(Subcommand)]
338
pub enum Admin {
339
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
340
    Verify {
341
        /// Machine-readable output
342
        #[arg(long)]
343
        json: bool,
344
    },
345
    /// Whether the projections equal a rebuild from the ledger
346
    Check,
347
    /// Replay the ledger into every projection
348
    Rebuild,
349
}
350
351
/// What every command needs: where the data lives.
352
pub struct Ctx {
353
    pub data: PathBuf,
354
}
355
356
impl Ctx {
357
    /// The ledger's database, which must exist.
358173×
    pub fn db(&self) -> Result<PathBuf> {
359173×
        let db = self.data.join("spectra.db");
360173×
        if !db.exists() {
3611×
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
362172×
        }
363172×
        Ok(db)
364173×
    }
365
366
    /// The ledger, for writing: the instance key signs its checkpoints, and
367
    /// the lock keeps any other writer out.
36858×
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
36958×
        let db = self.db()?;
37058×
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
37158×
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
37258×
    }
373
374
    /// The daemon's Unix socket.
375124×
    pub fn socket(&self) -> PathBuf {
376124×
        self.data.join("spectra.sock")
377124×
    }
378
379
    /// The Spectra on another machine this one uses, if any (spec §8.6).
380164×
    pub fn remote(&self) -> Result<Option<connect::ClientCfg>> {
381164×
        connect::load()
382164×
    }
383
384
    /// Where the daemon is: the configured server, else the local socket.
3850×
    pub fn endpoint(&self) -> Result<spectra_mcp::client::Endpoint> {
3860×
        Ok(match self.remote()? {
3870×
            Some(c) => c.endpoint()?,
3880×
            None => spectra_mcp::client::Endpoint::Unix(self.socket()),
389
        })
3900×
    }
391
392
    /// A client signing as the owner: to the configured server (spec §8.6),
393
    /// or to the local daemon when it is serving; none when neither. It
394
    /// signs with the owner identity the ledger records, since the daemon
395
    /// may not know a newer one yet.
39647×
    pub async fn owner_client(&self, owner_home: &std::path::Path) -> Result<Option<spectra_mcp::client::Client>> {
397
        use spectra_mcp::client::Client;
39847×
        if let Some(cfg) = self.remote()? {
39910×
            let ep = cfg.endpoint()?;
40010×
            let pair = remote_owner(&ep, owner_home).await?;
40110×
            return Ok(Some(Client::at(ep, Some(pair))));
40237×
        }
40337×
        if !self.data.join("spectra.db").exists() || !Client::alive(&self.socket()).await {
40432×
            return Ok(None);
4055×
        }
4065×
        let ledger = spectra_core::ledger::Ledger::open_read_only(&self.db()?).await?;
4075×
        Ok(Some(Client::new(&self.socket(), Some(identity::recorded_owner(&ledger, owner_home).await?))))
40847×
    }
409
410
    /// Somewhere to append the owner's signed events: the configured server,
411
    /// the local daemon when it is serving, else straight to the ledger
412
    /// (spec §8.5, §8.6).
41359×
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
41459×
        if let Some(cfg) = self.remote()? {
4154×
            let ep = cfg.endpoint()?;
4164×
            let pair = remote_owner(&ep, owner_home).await?;
4174×
            let recorded = pair.identity.clone();
4184×
            return Ok(Writer::Remote { client: Box::new(spectra_mcp::client::Client::at(ep, Some(pair))), recorded });
41955×
        }
42055×
        let db = self.db()?;
42155×
        if spectra_mcp::client::Client::alive(&self.socket()).await {
4226×
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
4236×
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
4246×
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
42549×
        }
42649×
        Ok(Writer::Direct(self.writer().await?))
42759×
    }
428
429
    /// Refuse a command that needs the ledger file on this machine.
4309×
    pub fn local_only(&self, what: &str) -> Result<()> {
4319×
        if self.remote()?.is_some() {
4321×
            bail!("{what} runs on the server, which holds the ledger (this machine uses {})", connect::path().display());
4338×
        }
4348×
        Ok(())
4359×
    }
436
437
    /// `[owner] home` from spectra.toml.
438105×
    pub fn config_owner_home(&self) -> Option<PathBuf> {
439105×
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
44072×
        let t: toml::Table = text.parse().ok()?;
44172×
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
442105×
    }
443
}
444
445
/// Where the owner's events go.
446
pub enum Writer {
447
    Direct(spectra_core::ledger::Ledger),
448
    Daemon {
449
        ledger: spectra_core::ledger::Ledger,
450
        client: Box<spectra_mcp::client::Client>,
451
    },
452
    /// A Spectra on another machine; `recorded` is the owner identity it knows.
453
    Remote {
454
        client: Box<spectra_mcp::client::Client>,
455
        recorded: spectra_core::identity::Identity,
456
    },
457
}
458
459
impl Writer {
460
    /// The ledger on this machine, for reading, if there is one.
46135×
    pub fn ledger(&self) -> Option<&spectra_core::ledger::Ledger> {
46235×
        match self {
46332×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => Some(l),
4643×
            Writer::Remote { .. } => None,
465
        }
46635×
    }
467
468
    /// A client of the daemon, when one is serving.
46935×
    pub fn client(&self) -> Option<&spectra_mcp::client::Client> {
47035×
        match self {
4717×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => Some(client),
47228×
            Writer::Direct(_) => None,
473
        }
47435×
    }
475
476
    /// The rotation that brings the recorded owner to the current identity
477
    /// in `home`, if it has moved.
47856×
    pub async fn refresh_event(&self, home: &std::path::Path) -> Result<Option<spectra_core::event::NewEvent>> {
47956×
        match self {
48052×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => identity::refresh_event(l, home).await,
4814×
            Writer::Remote { recorded, .. } => identity::rotation(recorded, home),
482
        }
48356×
    }
484
485
    /// Append events signed over their payload, then checkpoint.
48656×
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
48756×
        match self {
48846×
            Writer::Direct(l) => {
48946×
                l.append(events).await?;
49046×
                l.checkpoint().await?;
491
            }
4926×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => {
49310×
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
49410×
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
49510×
                if r.status != 200 {
4960×
                    let why = r
4970×
                        .json()
4980×
                        .ok()
4990×
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
5000×
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
5010×
                    bail!("the daemon refused: {why}");
50210×
                }
503
            }
504
        }
50556×
        Ok(())
50656×
    }
507
}
508
509
/// The owner identity a server records, as a key pair from the owner's
510
/// home: what requests to it are signed with.
51114×
async fn remote_owner(ep: &spectra_mcp::client::Endpoint, home: &std::path::Path) -> Result<spectra_core::keys::KeyPair> {
51214×
    let r = spectra_mcp::client::Client::at(ep.clone(), None).send("GET", "/api/v1/instance", &[], vec![]).await?;
51314×
    let info = r.json()?;
51414×
    let key = info.get("owner").and_then(|o| o.as_str()).context("the server has no owner yet: spectra connect claims it")?;
51514×
    let keys = home.join("keys");
51614×
    spectra_core::keys::KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity")))
51714×
        .with_context(|| format!("the server's owner {} is not an identity in {}", &key[..12.min(key.len())], home.display()))
51814×
}
519
520
/// An API call; a refusal becomes an error that says why.
52124×
pub async fn call(c: &spectra_mcp::client::Client, method: &str, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value> {
52224×
    let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?;
52324×
    let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null);
52424×
    if r.status >= 400 {
5250×
        let why = v
5260×
            .get("detail")
5270×
            .or(v.get("title"))
5280×
            .and_then(serde_json::Value::as_str)
5290×
            .map(String::from)
5300×
            .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
5310×
        bail!("{why}");
53224×
    }
53324×
    Ok(v)
53424×
}
535
536
/// A query string value, percent-encoded.
5374×
pub fn encode(s: &str) -> String {
5384×
    s.bytes()
53926×
        .map(|b| match b {
54026×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
5410×
            _ => format!("%{b:02X}"),
54226×
        })
5434×
        .collect()
5444×
}
545
54621×
fn init_logging() {
54721×
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
54821×
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
54921×
}
550
5510×
fn default_data() -> PathBuf {
5520×
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
5530×
        return PathBuf::from(x).join("spectra");
5540×
    }
5550×
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
5560×
    PathBuf::from(home).join(".local").join("share").join("spectra")
5570×
}
558
559179×
fn runtime() -> Result<tokio::runtime::Runtime> {
560179×
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
561179×
}
562
563180×
pub fn run() -> Result<i32> {
564180×
    let cli = Cli::parse();
565180×
    ui::init(cli.plain);
566180×
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
56728×
    match cli.command {
5681×
        Command::Version { json } => version(json),
5697×
        Command::Brief { command } => runtime()?.block_on(async {
5707×
            match command {
5711×
                BriefCmd::Show { project, json } => brief::show(&ctx, project, json).await,
5726×
                BriefCmd::Set { project, text, file, owner_home } => {
5736×
                    brief::set(&ctx, project, brief::text_of(text, file)?, owner_home).await
574
                }
5750×
                BriefCmd::Edit { project, owner_home } => brief::edit(&ctx, project, owner_home).await,
576
            }
5777×
        }),
5788×
        Command::Connect { url, owner_home, expect, yes } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home, expect, yes)),
5796×
        Command::Init { owner_home, name, owner: Some(key) } => {
5806×
            let _ = owner_home;
5816×
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
582
        }
58322×
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
5844×
        Command::Identity { command } => runtime()?.block_on(async {
5854×
            match command {
5862×
                IdentityCmd::Create { name, contact, home } => identity::create(&name, &contact, home),
5872×
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
5880×
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
589
            }
5904×
        }),
59121×
        Command::Serve { bind, tls, insecure, open } => {
59221×
            init_logging();
59321×
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
594
        }
5951×
        Command::Open { owner_home, no_browser, port } => runtime()?.block_on(async {
5961×
            if ctx.remote()?.is_some() {
5970×
                open::open_remote(&ctx, owner_home, !no_browser, port).await
598
            } else {
5991×
                open::open(&ctx, owner_home, !no_browser).await
600
            }
6011×
        }),
6022×
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
6033×
        Command::Say { body, channel, reply_to, json, owner_home } => {
6043×
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
605
        }
6062×
        Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)),
6073×
        Command::Search { query, channel, author, since, limit, json } => {
6083×
            runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json))
609
        }
6102×
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
61132×
        Command::Project { command } => runtime()?.block_on(async {
61232×
            match command {
61325×
                ProjectCmd::Add { target, name, path, description, url, owner_home } => {
61425×
                    project::add(&ctx, project::Add { target, name, path, description, url }, owner_home).await
615
                }
6163×
                ProjectCmd::Link { project, depends_on, remove, owner_home } => {
6173×
                    project::link(&ctx, &project, &depends_on, remove, owner_home).await
618
                }
6194×
                ProjectCmd::List { json } => project::list(&ctx, json).await,
620
            }
62132×
        }),
62235×
        Command::Agent { command } => runtime()?.block_on(async {
62335×
            match command {
62425×
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home } => {
62525×
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home }).await
626
                }
6271×
                AgentCmd::Renew { name, expires, owner_home } => agent::renew(&ctx, &name, &expires, owner_home).await,
6286×
                AgentCmd::List { json } => agent::list(&ctx, json).await,
6293×
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
630
            }
63135×
        }),
63231×
        Command::Admin { command } => runtime()?.block_on(async {
63331×
            match command {
63422×
                Admin::Verify { json } => admin::verify(&ctx, json).await,
6356×
                Admin::Check => admin::check(&ctx).await,
6363×
                Admin::Rebuild => admin::rebuild(&ctx).await,
637
            }
63831×
        }),
639
    }
640180×
}
641
6421×
fn version(as_json: bool) -> Result<i32> {
6431×
    if as_json {
6441×
        println!(
6451×
            "{}",
6461×
            serde_json::json!({
6471×
                "version": env!("CARGO_PKG_VERSION"),
6481×
                "build": env!("SPECTRA_BUILD_ID"),
6491×
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
6501×
                "ledger_format": spectra_core::LEDGER_FORMAT,
6511×
            })
6521×
        );
6531×
    } else {
6540×
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
6550×
    }
6561×
    Ok(0)
6571×
}