OXYGENHUB

Coverage / src/cli/mod.rs 83.9%

209 of 249 lines run, 37 of 90 functions · run #31 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 auto;
8
pub mod brief;
9
pub mod connect;
10
pub mod format;
11
pub mod human;
12
pub mod identity;
13
pub mod marks;
14
pub mod mcp;
15
pub mod open;
16
pub mod project;
17
pub mod serve;
18
pub mod setup;
19
pub mod ui;
20
21
use anyhow::{bail, Context, Result};
22
use clap::{Parser, Subcommand};
23
use std::path::PathBuf;
24
25
#[derive(Parser)]
26
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
27
pub struct Cli {
28
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
29
    #[arg(long, global = true)]
30
    pub plain: bool,
31
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
32
    #[arg(long, global = true, env = "SPECTRA_DATA")]
33
    pub data: Option<PathBuf>,
34
    #[command(subcommand)]
35
    pub command: Command,
36
}
37
38
#[derive(Subcommand)]
39
pub enum Command {
40
    /// The version and build of this binary
41
    Version {
42
        /// Machine-readable output
43
        #[arg(long)]
44
        json: bool,
45
    },
46
    /// Make this machine's Spectra: record the owner's identity (read from
47
    /// their OXY_HOME and proven by signing) and make the instance key
48
    Init {
49
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
50
        #[arg(long)]
51
        owner_home: Option<PathBuf>,
52
        /// This instance's name (default: the host name)
53
        #[arg(long)]
54
        name: Option<String>,
55
        /// Set up a server without the owner's secret key: name the owner's
56
        /// KeyId, and they claim it from their machine with `spectra
57
        /// connect` (spec §8.6)
58
        #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")]
59
        owner: Option<String>,
60
    },
61
    /// Use a Spectra on another machine: trust it through your delegation to
62
    /// its instance key, pin its certificate, and claim it if it is new
63
    Connect {
64
        /// The server, https://HOST[:PORT]
65
        url: String,
66
        /// Trust the server if its instance key starts with this, without asking
67
        #[arg(long, value_name = "KEYID-PREFIX")]
68
        expect: Option<String>,
69
        /// Trust it without asking (for scripts)
70
        #[arg(long, short = 'y')]
71
        yes: bool,
72
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
73
        #[arg(long)]
74
        owner_home: Option<PathBuf>,
75
    },
76
    /// What is set up here: your identity, and the Spectra you use
77
    Status {
78
        #[arg(long)]
79
        json: bool,
80
    },
81
    /// What a server is (its name and instance key), without trusting it
82
    Probe {
83
        /// The server, https://HOST[:PORT]
84
        url: String,
85
        #[arg(long)]
86
        json: bool,
87
    },
88
    /// Sign as you for a program that cannot (the VS Code extension): a
89
    /// loopback proxy that ends when its input closes
90
    Bridge {
91
        #[arg(long, default_value_t = 0)]
92
        port: u16,
93
        #[arg(long)]
94
        owner_home: Option<PathBuf>,
95
    },
96
    /// What the agents are told: the overall brief and each project's
97
    Brief {
98
        #[command(subcommand)]
99
        command: BriefCmd,
100
    },
101
    /// The owner's and the instance's identities: show, refresh
102
    Identity {
103
        #[command(subcommand)]
104
        command: IdentityCmd,
105
    },
106
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
107
    Serve {
108
        /// The address and port for browsers and HTTP clients (default:
109
        /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other
110
        /// machines connect over TLS (spec §8.6)
111
        #[arg(long)]
112
        bind: Option<String>,
113
        /// TLS on the TCP port: auto (beyond loopback only), on, or off
114
        /// (default: `[server] tls`, else auto)
115
        #[arg(long)]
116
        tls: Option<String>,
117
        /// Allow a non-loopback address in plain HTTP
118
        #[arg(long)]
119
        insecure: bool,
120
        /// Open Radar in the browser once serving (signs a link as the owner)
121
        #[arg(long)]
122
        open: bool,
123
    },
124
    /// Sign in to Radar: a one-time link signed with the owner's key
125
    Open {
126
        #[arg(long)]
127
        owner_home: Option<PathBuf>,
128
        /// Print the link without opening a browser
129
        #[arg(long)]
130
        no_browser: bool,
131
        /// With Spectra on another machine: the loopback port of the proxy
132
        /// Radar is served through (spec §8.6)
133
        #[arg(long, default_value_t = 7350)]
134
        port: u16,
135
        /// The page to land on: live, constellation, briefs, channels or log
136
        #[arg(long)]
137
        page: Option<String>,
138
    },
139
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
140
    Mcp {
141
        /// The agent it speaks for
142
        #[arg(long, required_unless_present = "auto")]
143
        agent: Option<String>,
144
        /// Speak for the agent of this session's folder, so one MCP entry
145
        /// serves every folder (spec §15.1)
146
        #[arg(long, conflicts_with = "agent")]
147
        auto: bool,
148
        /// With --auto: the client's kind (claude-code, antigravity, kimi-code, codex, other)
149
        #[arg(long, default_value = "claude-code")]
150
        kind: String,
151
        /// The agent's home (default: the one recorded when it was added)
152
        #[arg(long)]
153
        home: Option<PathBuf>,
154
    },
155
    /// Post a message as yourself
156
    Say {
157
        /// What to say (Markdown; @name mentions)
158
        body: String,
159
        /// The channel (default: #general)
160
        #[arg(short, long)]
161
        channel: Option<String>,
162
        /// Reply in the thread of this message
163
        #[arg(long)]
164
        reply_to: Option<String>,
165
        #[arg(long)]
166
        json: bool,
167
        #[arg(long)]
168
        owner_home: Option<PathBuf>,
169
    },
170
    /// Your unread mentions and direct messages; --follow waits for more
171
    Inbox {
172
        #[arg(long)]
173
        follow: bool,
174
        #[arg(long)]
175
        json: bool,
176
        #[arg(long)]
177
        owner_home: Option<PathBuf>,
178
    },
179
    /// Search what was said: words, "phrases", prefix*, AND/OR/NOT
180
    Search {
181
        query: String,
182
        #[arg(long)]
183
        channel: Option<String>,
184
        #[arg(long)]
185
        author: Option<String>,
186
        /// 7d, 3h, 30m
187
        #[arg(long)]
188
        since: Option<String>,
189
        #[arg(long, default_value_t = 20)]
190
        limit: u32,
191
        #[arg(long)]
192
        json: bool,
193
    },
194
    /// Any API call, signed as the owner: JSON out
195
    Api {
196
        method: String,
197
        path: String,
198
        /// The request body (JSON)
199
        #[arg(short = 'd', long = "body")]
200
        body: Option<String>,
201
        #[arg(long)]
202
        owner_home: Option<PathBuf>,
203
    },
204
    /// The checkouts agents work in: add, list
205
    Project {
206
        #[command(subcommand)]
207
        command: ProjectCmd,
208
    },
209
    /// Agents, each its owner's delegate: add, list, disable
210
    Agent {
211
        #[command(subcommand)]
212
        command: AgentCmd,
213
    },
214
    /// The ledger's own checks: verify, check, rebuild
215
    Admin {
216
        #[command(subcommand)]
217
        command: Admin,
218
    },
219
}
220
221
#[derive(Subcommand)]
222
pub enum IdentityCmd {
223
    /// Make your own identity: a key pair that signs as you (spec §8.0)
224
    Create {
225
        /// Your name, as agents and Radar show it
226
        #[arg(long)]
227
        name: String,
228
        /// An email or URL, if you want one shown
229
        #[arg(long, default_value = "")]
230
        contact: String,
231
        /// Where to keep it (default: $SPECTRA_HOME, else ~/.config/spectra)
232
        #[arg(long)]
233
        home: Option<PathBuf>,
234
        #[arg(long)]
235
        json: bool,
236
    },
237
    /// The owner and the instance as the ledger knows them
238
    Show {
239
        #[arg(long)]
240
        json: bool,
241
    },
242
    /// Record the owner's current identity if the ledger has missed a successor
243
    Refresh {
244
        #[arg(long)]
245
        owner_home: Option<PathBuf>,
246
    },
247
}
248
249
#[derive(Subcommand)]
250
pub enum BriefCmd {
251
    /// Show the overall brief, or a project's
252
    Show {
253
        #[arg(long)]
254
        project: Option<String>,
255
        #[arg(long)]
256
        json: bool,
257
    },
258
    /// Set the overall brief, or a project's (an empty text clears it)
259
    Set {
260
        #[arg(long)]
261
        project: Option<String>,
262
        /// The text
263
        text: Option<String>,
264
        /// Read the text from a file (- for standard input)
265
        #[arg(long, short = 'f')]
266
        file: Option<PathBuf>,
267
        #[arg(long)]
268
        owner_home: Option<PathBuf>,
269
    },
270
    /// Edit it in $VISUAL or $EDITOR
271
    Edit {
272
        #[arg(long)]
273
        project: Option<String>,
274
        #[arg(long)]
275
        owner_home: Option<PathBuf>,
276
    },
277
}
278
279
#[derive(Subcommand)]
280
pub enum ProjectCmd {
281
    /// Add a project: any kind of work, with a folder if it has one
282
    Add {
283
        /// A folder (its name becomes the project's), or just a name
284
        target: String,
285
        /// Its name, when TARGET is a folder
286
        #[arg(long)]
287
        name: Option<String>,
288
        /// The work's folder, when TARGET is a name; Spectra never reads it
289
        #[arg(long)]
290
        path: Option<PathBuf>,
291
        /// What the work is, for agents and Radar
292
        #[arg(long, short = 'd', default_value = "")]
293
        description: String,
294
        /// A link to it, if any
295
        #[arg(long)]
296
        url: Option<String>,
297
        #[arg(long)]
298
        owner_home: Option<PathBuf>,
299
    },
300
    /// Say that one project depends on another (`delegate to: downstream` follows links)
301
    Link {
302
        project: String,
303
        #[arg(long)]
304
        depends_on: String,
305
        /// Remove the link instead
306
        #[arg(long)]
307
        remove: bool,
308
        #[arg(long)]
309
        owner_home: Option<PathBuf>,
310
    },
311
    /// Every project with its links and agents
312
    List {
313
        #[arg(long)]
314
        json: bool,
315
    },
316
}
317
318
#[derive(Subcommand)]
319
pub enum AgentCmd {
320
    /// Make an agent's key in its own home, delegate to it, and record it
321
    Add {
322
        /// Lower-case letters, digits and dashes
323
        name: String,
324
        /// claude-code, antigravity, kimi-code, codex or other
325
        #[arg(long)]
326
        kind: String,
327
        /// The project it works in
328
        #[arg(long)]
329
        project: String,
330
        /// The agent's own identity home, where its key is made and kept
331
        #[arg(long)]
332
        home: PathBuf,
333
        /// Bind the key to this Unix user: a request from another is refused
334
        #[arg(long)]
335
        uid: Option<u32>,
336
        /// Bind the key to the Unix user of its first signed connection
337
        #[arg(long, conflicts_with = "uid")]
338
        learn_uid: bool,
339
        /// How long the agent may act for you: 30m, 8h, 7d (default: until disabled)
340
        #[arg(long)]
341
        expires: Option<String>,
342
        /// Delegate without asking (for scripts)
343
        #[arg(long, short = 'y')]
344
        yes: bool,
345
        /// The owner's identity home
346
        #[arg(long)]
347
        owner_home: Option<PathBuf>,
348
    },
349
    /// Give an agent more time: delegate to its key again with a new expiry
350
    Renew {
351
        name: String,
352
        /// 30m, 8h, 7d, or `never`
353
        #[arg(long)]
354
        expires: String,
355
        #[arg(long)]
356
        owner_home: Option<PathBuf>,
357
    },
358
    /// Every agent with its owner, key, delegation and state
359
    List {
360
        #[arg(long)]
361
        json: bool,
362
    },
363
    /// Refuse an agent's key from now on
364
    Disable {
365
        name: String,
366
        #[arg(long)]
367
        owner_home: Option<PathBuf>,
368
    },
369
}
370
371
#[derive(Subcommand)]
372
pub enum Admin {
373
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
374
    Verify {
375
        /// Machine-readable output
376
        #[arg(long)]
377
        json: bool,
378
    },
379
    /// Whether the projections equal a rebuild from the ledger
380
    Check,
381
    /// Replay the ledger into every projection
382
    Rebuild,
383
}
384
385
/// What every command needs: where the data lives.
386
pub struct Ctx {
387
    pub data: PathBuf,
388
}
389
390
impl Ctx {
391
    /// The ledger's database, which must exist.
392192×
    pub fn db(&self) -> Result<PathBuf> {
393192×
        let db = self.data.join("spectra.db");
394192×
        if !db.exists() {
3951×
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
396191×
        }
397191×
        Ok(db)
398192×
    }
399
400
    /// The ledger, for writing: the instance key signs its checkpoints, and
401
    /// the lock keeps any other writer out.
40265×
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
40365×
        let db = self.db()?;
40465×
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
40565×
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
40665×
    }
407
408
    /// The daemon's Unix socket.
409141×
    pub fn socket(&self) -> PathBuf {
410141×
        self.data.join("spectra.sock")
411141×
    }
412
413
    /// The Spectra on another machine this one uses, if any (spec §8.6).
414181×
    pub fn remote(&self) -> Result<Option<connect::ClientCfg>> {
415181×
        connect::load()
416181×
    }
417
418
    /// Where the daemon is: the configured server, else the local socket.
4190×
    pub fn endpoint(&self) -> Result<spectra_mcp::client::Endpoint> {
4200×
        Ok(match self.remote()? {
4210×
            Some(c) => c.endpoint()?,
4220×
            None => spectra_mcp::client::Endpoint::Unix(self.socket()),
423
        })
4240×
    }
425
426
    /// A client signing as the owner: to the configured server (spec §8.6),
427
    /// or to the local daemon when it is serving; none when neither. It
428
    /// signs with the owner identity the ledger records, since the daemon
429
    /// may not know a newer one yet.
43052×
    pub async fn owner_client(&self, owner_home: &std::path::Path) -> Result<Option<spectra_mcp::client::Client>> {
431
        use spectra_mcp::client::Client;
43252×
        if let Some(cfg) = self.remote()? {
43310×
            let ep = cfg.endpoint()?;
43410×
            let pair = remote_owner(&ep, owner_home).await?;
43510×
            return Ok(Some(Client::at(ep, Some(pair))));
43642×
        }
43742×
        if !self.data.join("spectra.db").exists() || !Client::alive(&self.socket()).await {
43835×
            return Ok(None);
4397×
        }
4407×
        let ledger = spectra_core::ledger::Ledger::open_read_only(&self.db()?).await?;
4417×
        Ok(Some(Client::new(&self.socket(), Some(identity::recorded_owner(&ledger, owner_home).await?))))
44252×
    }
443
444
    /// Somewhere to append the owner's signed events: the configured server,
445
    /// the local daemon when it is serving, else straight to the ledger
446
    /// (spec §8.5, §8.6).
44766×
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
44866×
        if let Some(cfg) = self.remote()? {
4494×
            let ep = cfg.endpoint()?;
4504×
            let pair = remote_owner(&ep, owner_home).await?;
4514×
            let recorded = pair.identity.clone();
4524×
            return Ok(Writer::Remote { client: Box::new(spectra_mcp::client::Client::at(ep, Some(pair))), recorded });
45362×
        }
45462×
        let db = self.db()?;
45562×
        if spectra_mcp::client::Client::alive(&self.socket()).await {
4566×
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
4576×
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
4586×
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
45956×
        }
46056×
        Ok(Writer::Direct(self.writer().await?))
46166×
    }
462
463
    /// Refuse a command that needs the ledger file on this machine.
4649×
    pub fn local_only(&self, what: &str) -> Result<()> {
4659×
        if self.remote()?.is_some() {
4661×
            bail!("{what} runs on the server, which holds the ledger (this machine uses {})", connect::path().display());
4678×
        }
4688×
        Ok(())
4699×
    }
470
471
    /// `[owner] home` from spectra.toml.
472119×
    pub fn config_owner_home(&self) -> Option<PathBuf> {
473119×
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
47482×
        let t: toml::Table = text.parse().ok()?;
47582×
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
476119×
    }
477
}
478
479
/// Where the owner's events go.
480
pub enum Writer {
481
    Direct(spectra_core::ledger::Ledger),
482
    Daemon {
483
        ledger: spectra_core::ledger::Ledger,
484
        client: Box<spectra_mcp::client::Client>,
485
    },
486
    /// A Spectra on another machine; `recorded` is the owner identity it knows.
487
    Remote {
488
        client: Box<spectra_mcp::client::Client>,
489
        recorded: spectra_core::identity::Identity,
490
    },
491
}
492
493
impl Writer {
494
    /// The ledger on this machine, for reading, if there is one.
49565×
    pub fn ledger(&self) -> Option<&spectra_core::ledger::Ledger> {
49665×
        match self {
49760×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => Some(l),
4985×
            Writer::Remote { .. } => None,
499
        }
50065×
    }
501
502
    /// A client of the daemon, when one is serving.
50365×
    pub fn client(&self) -> Option<&spectra_mcp::client::Client> {
50465×
        match self {
50510×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => Some(client),
50655×
            Writer::Direct(_) => None,
507
        }
50865×
    }
509
510
    /// The rotation that brings the recorded owner to the current identity
511
    /// in `home`, if it has moved.
51263×
    pub async fn refresh_event(&self, home: &std::path::Path) -> Result<Option<spectra_core::event::NewEvent>> {
51363×
        match self {
51459×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => identity::refresh_event(l, home).await,
5154×
            Writer::Remote { recorded, .. } => identity::rotation(recorded, home),
516
        }
51763×
    }
518
519
    /// Append events signed over their payload, then checkpoint.
52063×
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
52163×
        match self {
52253×
            Writer::Direct(l) => {
52353×
                l.append(events).await?;
52453×
                l.checkpoint().await?;
525
            }
5266×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => {
52710×
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
52810×
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
52910×
                if r.status != 200 {
5300×
                    let why = r
5310×
                        .json()
5320×
                        .ok()
5330×
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
5340×
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
5350×
                    bail!("the daemon refused: {why}");
53610×
                }
537
            }
538
        }
53963×
        Ok(())
54063×
    }
541
}
542
543
/// The owner identity a server records, as a key pair from the owner's
544
/// home: what requests to it are signed with.
54514×
async fn remote_owner(ep: &spectra_mcp::client::Endpoint, home: &std::path::Path) -> Result<spectra_core::keys::KeyPair> {
54614×
    let r = spectra_mcp::client::Client::at(ep.clone(), None).send("GET", "/api/v1/instance", &[], vec![]).await?;
54714×
    let info = r.json()?;
54814×
    let key = info.get("owner").and_then(|o| o.as_str()).context("the server has no owner yet: spectra connect claims it")?;
54914×
    let keys = home.join("keys");
55014×
    spectra_core::keys::KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity")))
55114×
        .with_context(|| format!("the server's owner {} is not an identity in {}", &key[..12.min(key.len())], home.display()))
55214×
}
553
554
/// An API call; a refusal becomes an error that says why.
55528×
pub async fn call(c: &spectra_mcp::client::Client, method: &str, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value> {
55628×
    let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?;
55728×
    let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null);
55828×
    if r.status >= 400 {
5590×
        let why = v
5600×
            .get("detail")
5610×
            .or(v.get("title"))
5620×
            .and_then(serde_json::Value::as_str)
5630×
            .map(String::from)
5640×
            .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
5650×
        bail!("{why}");
56628×
    }
56728×
    Ok(v)
56828×
}
569
570
/// A query string value, percent-encoded.
5714×
pub fn encode(s: &str) -> String {
5724×
    s.bytes()
57326×
        .map(|b| match b {
57426×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
5750×
            _ => format!("%{b:02X}"),
57626×
        })
5774×
        .collect()
5784×
}
579
58023×
fn init_logging() {
58123×
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
58223×
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
58323×
}
584
5850×
fn default_data() -> PathBuf {
5860×
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
5870×
        return PathBuf::from(x).join("spectra");
5880×
    }
5890×
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
5900×
    PathBuf::from(home).join(".local").join("share").join("spectra")
5910×
}
592
593197×
fn runtime() -> Result<tokio::runtime::Runtime> {
594197×
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
595197×
}
596
597198×
pub fn run() -> Result<i32> {
598198×
    let cli = Cli::parse();
599198×
    ui::init(cli.plain);
600198×
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
60131×
    match cli.command {
6021×
        Command::Version { json } => version(json),
6032×
        Command::Status { json } => runtime()?.block_on(setup::status(&ctx, json)),
6041×
        Command::Probe { url, json } => runtime()?.block_on(setup::probe(&url, json)),
6051×
        Command::Bridge { port, owner_home } => runtime()?.block_on(setup::bridge(&ctx, port, owner_home)),
6068×
        Command::Brief { command } => runtime()?.block_on(async {
6078×
            match command {
6082×
                BriefCmd::Show { project, json } => brief::show(&ctx, project, json).await,
6096×
                BriefCmd::Set { project, text, file, owner_home } => {
6106×
                    brief::set(&ctx, project, brief::text_of(text, file)?, owner_home).await
611
                }
6120×
                BriefCmd::Edit { project, owner_home } => brief::edit(&ctx, project, owner_home).await,
613
            }
6148×
        }),
6158×
        Command::Connect { url, owner_home, expect, yes } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home, expect, yes)),
6166×
        Command::Init { owner_home, name, owner: Some(key) } => {
6176×
            let _ = owner_home;
6186×
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
619
        }
62025×
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
6215×
        Command::Identity { command } => runtime()?.block_on(async {
6225×
            match command {
6233×
                IdentityCmd::Create { name, contact, home, json } => identity::create(&name, &contact, home, json),
6242×
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
6250×
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
626
            }
6275×
        }),
62823×
        Command::Serve { bind, tls, insecure, open } => {
62923×
            init_logging();
63023×
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
631
        }
6321×
        Command::Open { owner_home, no_browser, port, page } => runtime()?.block_on(async {
6331×
            if ctx.remote()?.is_some() {
6340×
                open::open_remote(&ctx, owner_home, !no_browser, port, page.as_deref()).await
635
            } else {
6361×
                open::open(&ctx, owner_home, !no_browser, page.as_deref()).await
637
            }
6381×
        }),
6392×
        Command::Mcp { agent, auto, kind, home } => runtime()?.block_on(async {
6402×
            match (agent, auto) {
6412×
                (Some(a), _) => mcp::run(&ctx, &a, home).await,
6420×
                (None, _) => match auto::resolve(&ctx, &kind).await? {
6430×
                    Some(l) => mcp::run(&ctx, &l.name, Some(l.home)).await,
6440×
                    None => auto::unconnected(&std::env::current_dir()?, &kind).await,
645
                },
646
            }
6472×
        }),
6483×
        Command::Say { body, channel, reply_to, json, owner_home } => {
6493×
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
650
        }
6512×
        Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)),
6523×
        Command::Search { query, channel, author, since, limit, json } => {
6533×
            runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json))
654
        }
6552×
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
65636×
        Command::Project { command } => runtime()?.block_on(async {
65736×
            match command {
65829×
                ProjectCmd::Add { target, name, path, description, url, owner_home } => {
65929×
                    project::add(&ctx, project::Add { target, name, path, description, url }, owner_home).await
660
                }
6613×
                ProjectCmd::Link { project, depends_on, remove, owner_home } => {
6623×
                    project::link(&ctx, &project, &depends_on, remove, owner_home).await
663
                }
6644×
                ProjectCmd::List { json } => project::list(&ctx, json).await,
665
            }
66636×
        }),
66738×
        Command::Agent { command } => runtime()?.block_on(async {
66838×
            match command {
66928×
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home } => {
67028×
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home }).await
671
                }
6721×
                AgentCmd::Renew { name, expires, owner_home } => agent::renew(&ctx, &name, &expires, owner_home).await,
6736×
                AgentCmd::List { json } => agent::list(&ctx, json).await,
6743×
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
675
            }
67638×
        }),
67731×
        Command::Admin { command } => runtime()?.block_on(async {
67831×
            match command {
67922×
                Admin::Verify { json } => admin::verify(&ctx, json).await,
6806×
                Admin::Check => admin::check(&ctx).await,
6813×
                Admin::Rebuild => admin::rebuild(&ctx).await,
682
            }
68331×
        }),
684
    }
685198×
}
686
6871×
fn version(as_json: bool) -> Result<i32> {
6881×
    if as_json {
6891×
        println!(
6901×
            "{}",
6911×
            serde_json::json!({
6921×
                "version": env!("CARGO_PKG_VERSION"),
6931×
                "build": env!("SPECTRA_BUILD_ID"),
6941×
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
6951×
                "ledger_format": spectra_core::LEDGER_FORMAT,
6961×
            })
6971×
        );
6981×
    } else {
6990×
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
7000×
    }
7011×
    Ok(0)
7021×
}