OXYGENHUB

Coverage / src/cli/mod.rs 84.0%

205 of 244 lines run, 36 of 88 functions · run #36 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 project;
16
pub mod serve;
17
pub mod setup;
18
pub mod ui;
19
20
use anyhow::{bail, Context, Result};
21
use clap::{Parser, Subcommand};
22
use std::path::PathBuf;
23
24
#[derive(Parser)]
25
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
26
pub struct Cli {
27
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
28
    #[arg(long, global = true)]
29
    pub plain: bool,
30
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
31
    #[arg(long, global = true, env = "SPECTRA_DATA")]
32
    pub data: Option<PathBuf>,
33
    #[command(subcommand)]
34
    pub command: Command,
35
}
36
37
#[derive(Subcommand)]
38
pub enum Command {
39
    /// The version and build of this binary
40
    Version {
41
        /// Machine-readable output
42
        #[arg(long)]
43
        json: bool,
44
    },
45
    /// Make this machine's Spectra: record the owner's identity (read from
46
    /// their OXY_HOME and proven by signing) and make the instance key
47
    Init {
48
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
49
        #[arg(long)]
50
        owner_home: Option<PathBuf>,
51
        /// This instance's name (default: the host name)
52
        #[arg(long)]
53
        name: Option<String>,
54
        /// Set up a server without the owner's secret key: name the owner's
55
        /// KeyId, and they claim it from their machine with `spectra
56
        /// connect` (spec §8.6)
57
        #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")]
58
        owner: Option<String>,
59
    },
60
    /// Use a Spectra on another machine: trust it through your delegation to
61
    /// its instance key, pin its certificate, and claim it if it is new
62
    Connect {
63
        /// The server, https://HOST[:PORT]
64
        url: String,
65
        /// Trust the server if its instance key starts with this, without asking
66
        #[arg(long, value_name = "KEYID-PREFIX")]
67
        expect: Option<String>,
68
        /// Trust it without asking (for scripts)
69
        #[arg(long, short = 'y')]
70
        yes: bool,
71
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
72
        #[arg(long)]
73
        owner_home: Option<PathBuf>,
74
    },
75
    /// What is set up here: your identity, and the Spectra you use
76
    Status {
77
        #[arg(long)]
78
        json: bool,
79
    },
80
    /// What a server is (its name and instance key), without trusting it
81
    Probe {
82
        /// The server, https://HOST[:PORT]
83
        url: String,
84
        #[arg(long)]
85
        json: bool,
86
    },
87
    /// Sign as you for a program that cannot (the VS Code extension): a
88
    /// loopback proxy that ends when its input closes
89
    Bridge {
90
        #[arg(long, default_value_t = 0)]
91
        port: u16,
92
        #[arg(long)]
93
        owner_home: Option<PathBuf>,
94
    },
95
    /// What the agents are told: the overall brief and each project's
96
    Brief {
97
        #[command(subcommand)]
98
        command: BriefCmd,
99
    },
100
    /// The owner's and the instance's identities: show, refresh
101
    Identity {
102
        #[command(subcommand)]
103
        command: IdentityCmd,
104
    },
105
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
106
    Serve {
107
        /// The address and port for browsers and HTTP clients (default:
108
        /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other
109
        /// machines connect over TLS (spec §8.6)
110
        #[arg(long)]
111
        bind: Option<String>,
112
        /// TLS on the TCP port: auto (beyond loopback only), on, or off
113
        /// (default: `[server] tls`, else auto)
114
        #[arg(long)]
115
        tls: Option<String>,
116
        /// Allow a non-loopback address in plain HTTP
117
        #[arg(long)]
118
        insecure: bool,
119
    },
120
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
121
    Mcp {
122
        /// The agent it speaks for
123
        #[arg(long, required_unless_present = "auto")]
124
        agent: Option<String>,
125
        /// Speak for the agent of this session's folder, so one MCP entry
126
        /// serves every folder (spec §15.1)
127
        #[arg(long, conflicts_with = "agent")]
128
        auto: bool,
129
        /// With --auto: the client's kind (claude-code, antigravity, kimi-code, codex, other)
130
        #[arg(long, default_value = "claude-code")]
131
        kind: String,
132
        /// The agent's home (default: the one recorded when it was added)
133
        #[arg(long)]
134
        home: Option<PathBuf>,
135
    },
136
    /// Post a message as yourself
137
    Say {
138
        /// What to say (Markdown; @name mentions)
139
        body: String,
140
        /// The channel (default: #general)
141
        #[arg(short, long)]
142
        channel: Option<String>,
143
        /// Reply in the thread of this message
144
        #[arg(long)]
145
        reply_to: Option<String>,
146
        #[arg(long)]
147
        json: bool,
148
        #[arg(long)]
149
        owner_home: Option<PathBuf>,
150
    },
151
    /// Your unread mentions and direct messages; --follow waits for more
152
    Inbox {
153
        #[arg(long)]
154
        follow: bool,
155
        #[arg(long)]
156
        json: bool,
157
        #[arg(long)]
158
        owner_home: Option<PathBuf>,
159
    },
160
    /// Search what was said: words, "phrases", prefix*, AND/OR/NOT
161
    Search {
162
        query: String,
163
        #[arg(long)]
164
        channel: Option<String>,
165
        #[arg(long)]
166
        author: Option<String>,
167
        /// 7d, 3h, 30m
168
        #[arg(long)]
169
        since: Option<String>,
170
        #[arg(long, default_value_t = 20)]
171
        limit: u32,
172
        #[arg(long)]
173
        json: bool,
174
    },
175
    /// Any API call, signed as the owner: JSON out
176
    Api {
177
        method: String,
178
        path: String,
179
        /// The request body (JSON)
180
        #[arg(short = 'd', long = "body")]
181
        body: Option<String>,
182
        #[arg(long)]
183
        owner_home: Option<PathBuf>,
184
    },
185
    /// The checkouts agents work in: add, list
186
    Project {
187
        #[command(subcommand)]
188
        command: ProjectCmd,
189
    },
190
    /// Agents, each its owner's delegate: add, list, disable
191
    Agent {
192
        #[command(subcommand)]
193
        command: AgentCmd,
194
    },
195
    /// The ledger's own checks: verify, check, rebuild
196
    Admin {
197
        #[command(subcommand)]
198
        command: Admin,
199
    },
200
}
201
202
#[derive(Subcommand)]
203
pub enum IdentityCmd {
204
    /// Make your own identity: a key pair that signs as you (spec §8.0)
205
    Create {
206
        /// Your name, as agents and Radar show it
207
        #[arg(long)]
208
        name: String,
209
        /// An email or URL, if you want one shown
210
        #[arg(long, default_value = "")]
211
        contact: String,
212
        /// Where to keep it (default: $SPECTRA_HOME, else ~/.config/spectra)
213
        #[arg(long)]
214
        home: Option<PathBuf>,
215
        #[arg(long)]
216
        json: bool,
217
    },
218
    /// The owner and the instance as the ledger knows them
219
    Show {
220
        #[arg(long)]
221
        json: bool,
222
    },
223
    /// Record the owner's current identity if the ledger has missed a successor
224
    Refresh {
225
        #[arg(long)]
226
        owner_home: Option<PathBuf>,
227
    },
228
}
229
230
#[derive(Subcommand)]
231
pub enum BriefCmd {
232
    /// Show the overall brief, or a project's
233
    Show {
234
        #[arg(long)]
235
        project: Option<String>,
236
        #[arg(long)]
237
        json: bool,
238
    },
239
    /// Set the overall brief, or a project's (an empty text clears it)
240
    Set {
241
        #[arg(long)]
242
        project: Option<String>,
243
        /// The text
244
        text: Option<String>,
245
        /// Read the text from a file (- for standard input)
246
        #[arg(long, short = 'f')]
247
        file: Option<PathBuf>,
248
        #[arg(long)]
249
        owner_home: Option<PathBuf>,
250
    },
251
    /// Edit it in $VISUAL or $EDITOR
252
    Edit {
253
        #[arg(long)]
254
        project: Option<String>,
255
        #[arg(long)]
256
        owner_home: Option<PathBuf>,
257
    },
258
}
259
260
#[derive(Subcommand)]
261
pub enum ProjectCmd {
262
    /// Add a project: any kind of work, with a folder if it has one
263
    Add {
264
        /// A folder (its name becomes the project's), or just a name
265
        target: String,
266
        /// Its name, when TARGET is a folder
267
        #[arg(long)]
268
        name: Option<String>,
269
        /// The work's folder, when TARGET is a name; Spectra never reads it
270
        #[arg(long)]
271
        path: Option<PathBuf>,
272
        /// What the work is, for agents and Radar
273
        #[arg(long, short = 'd', default_value = "")]
274
        description: String,
275
        /// A link to it, if any
276
        #[arg(long)]
277
        url: Option<String>,
278
        #[arg(long)]
279
        owner_home: Option<PathBuf>,
280
    },
281
    /// Say that one project depends on another (`delegate to: downstream` follows links)
282
    Link {
283
        project: String,
284
        #[arg(long)]
285
        depends_on: String,
286
        /// Remove the link instead
287
        #[arg(long)]
288
        remove: bool,
289
        #[arg(long)]
290
        owner_home: Option<PathBuf>,
291
    },
292
    /// Every project with its links and agents
293
    List {
294
        #[arg(long)]
295
        json: bool,
296
    },
297
}
298
299
#[derive(Subcommand)]
300
pub enum AgentCmd {
301
    /// Make an agent's key in its own home, delegate to it, and record it
302
    Add {
303
        /// Lower-case letters, digits and dashes
304
        name: String,
305
        /// claude-code, antigravity, kimi-code, codex or other
306
        #[arg(long)]
307
        kind: String,
308
        /// The project it works in
309
        #[arg(long)]
310
        project: String,
311
        /// The agent's own identity home, where its key is made and kept
312
        #[arg(long)]
313
        home: PathBuf,
314
        /// Bind the key to this Unix user: a request from another is refused
315
        #[arg(long)]
316
        uid: Option<u32>,
317
        /// Bind the key to the Unix user of its first signed connection
318
        #[arg(long, conflicts_with = "uid")]
319
        learn_uid: bool,
320
        /// How long the agent may act for you: 30m, 8h, 7d (default: until disabled)
321
        #[arg(long)]
322
        expires: Option<String>,
323
        /// Delegate without asking (for scripts)
324
        #[arg(long, short = 'y')]
325
        yes: bool,
326
        /// The owner's identity home
327
        #[arg(long)]
328
        owner_home: Option<PathBuf>,
329
    },
330
    /// Let an agent post in another project's channel (or take it back)
331
    Allow {
332
        name: String,
333
        /// The channel, without the #
334
        #[arg(long)]
335
        channel: String,
336
        /// Take the permission back
337
        #[arg(long)]
338
        remove: bool,
339
        #[arg(long)]
340
        owner_home: Option<PathBuf>,
341
    },
342
    /// Give an agent more time: delegate to its key again with a new expiry
343
    Renew {
344
        name: String,
345
        /// 30m, 8h, 7d, or `never`
346
        #[arg(long)]
347
        expires: String,
348
        #[arg(long)]
349
        owner_home: Option<PathBuf>,
350
    },
351
    /// Every agent with its owner, key, delegation and state
352
    List {
353
        #[arg(long)]
354
        json: bool,
355
    },
356
    /// Refuse an agent's key from now on
357
    Disable {
358
        name: String,
359
        #[arg(long)]
360
        owner_home: Option<PathBuf>,
361
    },
362
}
363
364
#[derive(Subcommand)]
365
pub enum Admin {
366
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
367
    Verify {
368
        /// Machine-readable output
369
        #[arg(long)]
370
        json: bool,
371
    },
372
    /// Whether the projections equal a rebuild from the ledger
373
    Check,
374
    /// Replay the ledger into every projection
375
    Rebuild,
376
}
377
378
/// What every command needs: where the data lives.
379
pub struct Ctx {
380
    pub data: PathBuf,
381
}
382
383
impl Ctx {
384
    /// The ledger's database, which must exist.
385213×
    pub fn db(&self) -> Result<PathBuf> {
386213×
        let db = self.data.join("spectra.db");
387213×
        if !db.exists() {
3881×
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
389212×
        }
390212×
        Ok(db)
391213×
    }
392
393
    /// The ledger, for writing: the instance key signs its checkpoints, and
394
    /// the lock keeps any other writer out.
39574×
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
39674×
        let db = self.db()?;
39774×
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
39874×
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
39974×
    }
400
401
    /// The daemon's Unix socket.
402158×
    pub fn socket(&self) -> PathBuf {
403158×
        self.data.join("spectra.sock")
404158×
    }
405
406
    /// The Spectra on another machine this one uses, if any (spec §8.6).
407197×
    pub fn remote(&self) -> Result<Option<connect::ClientCfg>> {
408197×
        connect::load()
409197×
    }
410
411
    /// Where the daemon is: the configured server, else the local socket.
4120×
    pub fn endpoint(&self) -> Result<spectra_mcp::client::Endpoint> {
4130×
        Ok(match self.remote()? {
4140×
            Some(c) => c.endpoint()?,
4150×
            None => spectra_mcp::client::Endpoint::Unix(self.socket()),
416
        })
4170×
    }
418
419
    /// A client signing as the owner: to the configured server (spec §8.6),
420
    /// or to the local daemon when it is serving; none when neither. It
421
    /// signs with the owner identity the ledger records, since the daemon
422
    /// may not know a newer one yet.
42355×
    pub async fn owner_client(&self, owner_home: &std::path::Path) -> Result<Option<spectra_mcp::client::Client>> {
424
        use spectra_mcp::client::Client;
42555×
        if let Some(cfg) = self.remote()? {
4269×
            let ep = cfg.endpoint()?;
4279×
            let pair = remote_owner(&ep, owner_home).await?;
4289×
            return Ok(Some(Client::at(ep, Some(pair))));
42946×
        }
43046×
        if !self.data.join("spectra.db").exists() || !Client::alive(&self.socket()).await {
43138×
            return Ok(None);
4328×
        }
4338×
        let ledger = spectra_core::ledger::Ledger::open_read_only(&self.db()?).await?;
4348×
        Ok(Some(Client::new(&self.socket(), Some(identity::recorded_owner(&ledger, owner_home).await?))))
43555×
    }
436
437
    /// Somewhere to append the owner's signed events: the configured server,
438
    /// the local daemon when it is serving, else straight to the ledger
439
    /// (spec §8.5, §8.6).
44076×
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
44176×
        if let Some(cfg) = self.remote()? {
4424×
            let ep = cfg.endpoint()?;
4434×
            let pair = remote_owner(&ep, owner_home).await?;
4444×
            let recorded = pair.identity.clone();
4454×
            return Ok(Writer::Remote { client: Box::new(spectra_mcp::client::Client::at(ep, Some(pair))), recorded });
44672×
        }
44772×
        let db = self.db()?;
44872×
        if spectra_mcp::client::Client::alive(&self.socket()).await {
4499×
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
4509×
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
4519×
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
45263×
        }
45363×
        Ok(Writer::Direct(self.writer().await?))
45476×
    }
455
456
    /// Refuse a command that needs the ledger file on this machine.
45711×
    pub fn local_only(&self, what: &str) -> Result<()> {
45811×
        if self.remote()?.is_some() {
4591×
            bail!("{what} runs on the server, which holds the ledger (this machine uses {})", connect::path().display());
46010×
        }
46110×
        Ok(())
46211×
    }
463
464
    /// `[owner] home` from spectra.toml.
465129×
    pub fn config_owner_home(&self) -> Option<PathBuf> {
466129×
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
46792×
        let t: toml::Table = text.parse().ok()?;
46892×
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
469129×
    }
470
}
471
472
/// Where the owner's events go.
473
pub enum Writer {
474
    Direct(spectra_core::ledger::Ledger),
475
    Daemon {
476
        ledger: spectra_core::ledger::Ledger,
477
        client: Box<spectra_mcp::client::Client>,
478
    },
479
    /// A Spectra on another machine; `recorded` is the owner identity it knows.
480
    Remote {
481
        client: Box<spectra_mcp::client::Client>,
482
        recorded: spectra_core::identity::Identity,
483
    },
484
}
485
486
impl Writer {
487
    /// The ledger on this machine, for reading, if there is one.
48875×
    pub fn ledger(&self) -> Option<&spectra_core::ledger::Ledger> {
48975×
        match self {
49070×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => Some(l),
4915×
            Writer::Remote { .. } => None,
492
        }
49375×
    }
494
495
    /// A client of the daemon, when one is serving.
49675×
    pub fn client(&self) -> Option<&spectra_mcp::client::Client> {
49775×
        match self {
49813×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => Some(client),
49962×
            Writer::Direct(_) => None,
500
        }
50175×
    }
502
503
    /// The rotation that brings the recorded owner to the current identity
504
    /// in `home`, if it has moved.
50573×
    pub async fn refresh_event(&self, home: &std::path::Path) -> Result<Option<spectra_core::event::NewEvent>> {
50673×
        match self {
50769×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => identity::refresh_event(l, home).await,
5084×
            Writer::Remote { recorded, .. } => identity::rotation(recorded, home),
509
        }
51073×
    }
511
512
    /// Append events signed over their payload, then checkpoint.
51373×
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
51473×
        match self {
51560×
            Writer::Direct(l) => {
51660×
                l.append(events).await?;
51760×
                l.checkpoint().await?;
518
            }
5199×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => {
52013×
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
52113×
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
52213×
                if r.status != 200 {
5230×
                    let why = r
5240×
                        .json()
5250×
                        .ok()
5260×
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
5270×
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
5280×
                    bail!("the daemon refused: {why}");
52913×
                }
530
            }
531
        }
53273×
        Ok(())
53373×
    }
534
}
535
536
/// The owner identity a server records, as a key pair from the owner's
537
/// home: what requests to it are signed with.
53813×
async fn remote_owner(ep: &spectra_mcp::client::Endpoint, home: &std::path::Path) -> Result<spectra_core::keys::KeyPair> {
53913×
    let r = spectra_mcp::client::Client::at(ep.clone(), None).send("GET", "/api/v1/instance", &[], vec![]).await?;
54013×
    let info = r.json()?;
54113×
    let key = info.get("owner").and_then(|o| o.as_str()).context("the server has no owner yet: spectra connect claims it")?;
54213×
    let keys = home.join("keys");
54313×
    spectra_core::keys::KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity")))
54413×
        .with_context(|| format!("the server's owner {} is not an identity in {}", &key[..12.min(key.len())], home.display()))
54513×
}
546
547
/// An API call; a refusal becomes an error that says why.
54831×
pub async fn call(c: &spectra_mcp::client::Client, method: &str, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value> {
54931×
    let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?;
55031×
    let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null);
55131×
    if r.status >= 400 {
5520×
        let why = v
5530×
            .get("detail")
5540×
            .or(v.get("title"))
5550×
            .and_then(serde_json::Value::as_str)
5560×
            .map(String::from)
5570×
            .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
5580×
        bail!("{why}");
55931×
    }
56031×
    Ok(v)
56131×
}
562
563
/// A query string value, percent-encoded.
5643×
pub fn encode(s: &str) -> String {
5653×
    s.bytes()
56621×
        .map(|b| match b {
56721×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
5680×
            _ => format!("%{b:02X}"),
56921×
        })
5703×
        .collect()
5713×
}
572
57323×
fn init_logging() {
57423×
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
57523×
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
57623×
}
577
5780×
fn default_data() -> PathBuf {
5790×
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
5800×
        return PathBuf::from(x).join("spectra");
5810×
    }
5820×
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
5830×
    PathBuf::from(home).join(".local").join("share").join("spectra")
5840×
}
585
586207×
fn runtime() -> Result<tokio::runtime::Runtime> {
587207×
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
588207×
}
589
590208×
pub fn run() -> Result<i32> {
591208×
    let cli = Cli::parse();
592208×
    ui::init(cli.plain);
593208×
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
59431×
    match cli.command {
5951×
        Command::Version { json } => version(json),
5962×
        Command::Status { json } => runtime()?.block_on(setup::status(&ctx, json)),
5971×
        Command::Probe { url, json } => runtime()?.block_on(setup::probe(&url, json)),
5981×
        Command::Bridge { port, owner_home } => runtime()?.block_on(setup::bridge(&ctx, port, owner_home)),
5998×
        Command::Brief { command } => runtime()?.block_on(async {
6008×
            match command {
6012×
                BriefCmd::Show { project, json } => brief::show(&ctx, project, json).await,
6026×
                BriefCmd::Set { project, text, file, owner_home } => {
6036×
                    brief::set(&ctx, project, brief::text_of(text, file)?, owner_home).await
604
                }
6050×
                BriefCmd::Edit { project, owner_home } => brief::edit(&ctx, project, owner_home).await,
606
            }
6078×
        }),
6087×
        Command::Connect { url, owner_home, expect, yes } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home, expect, yes)),
6095×
        Command::Init { owner_home, name, owner: Some(key) } => {
6105×
            let _ = owner_home;
6115×
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
612
        }
61326×
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
6145×
        Command::Identity { command } => runtime()?.block_on(async {
6155×
            match command {
6163×
                IdentityCmd::Create { name, contact, home, json } => identity::create(&name, &contact, home, json),
6172×
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
6180×
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
619
            }
6205×
        }),
62123×
        Command::Serve { bind, tls, insecure } => {
62223×
            init_logging();
62323×
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure))
624
        }
6252×
        Command::Mcp { agent, auto, kind, home } => runtime()?.block_on(async {
6262×
            match (agent, auto) {
6272×
                (Some(a), _) => mcp::run(&ctx, &a, home).await,
6280×
                (None, _) => match auto::resolve(&ctx, &kind).await? {
6290×
                    Some(l) => mcp::run(&ctx, &l.name, Some(l.home)).await,
6300×
                    None => auto::unconnected(&std::env::current_dir()?, &kind).await,
631
                },
632
            }
6332×
        }),
6343×
        Command::Say { body, channel, reply_to, json, owner_home } => {
6353×
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
636
        }
6372×
        Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)),
6382×
        Command::Search { query, channel, author, since, limit, json } => {
6392×
            runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json))
640
        }
6412×
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
64240×
        Command::Project { command } => runtime()?.block_on(async {
64340×
            match command {
64433×
                ProjectCmd::Add { target, name, path, description, url, owner_home } => {
64533×
                    project::add(&ctx, project::Add { target, name, path, description, url }, owner_home).await
646
                }
6473×
                ProjectCmd::Link { project, depends_on, remove, owner_home } => {
6483×
                    project::link(&ctx, &project, &depends_on, remove, owner_home).await
649
                }
6504×
                ProjectCmd::List { json } => project::list(&ctx, json).await,
651
            }
65240×
        }),
65346×
        Command::Agent { command } => runtime()?.block_on(async {
65446×
            match command {
65531×
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home } => {
65631×
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home }).await
657
                }
6584×
                AgentCmd::Allow { name, channel, remove, owner_home } => agent::allow(&ctx, &name, &channel, remove, owner_home).await,
6591×
                AgentCmd::Renew { name, expires, owner_home } => agent::renew(&ctx, &name, &expires, owner_home).await,
6607×
                AgentCmd::List { json } => agent::list(&ctx, json).await,
6613×
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
662
            }
66346×
        }),
66432×
        Command::Admin { command } => runtime()?.block_on(async {
66532×
            match command {
66621×
                Admin::Verify { json } => admin::verify(&ctx, json).await,
6677×
                Admin::Check => admin::check(&ctx).await,
6684×
                Admin::Rebuild => admin::rebuild(&ctx).await,
669
            }
67032×
        }),
671
    }
672208×
}
673
6741×
fn version(as_json: bool) -> Result<i32> {
6751×
    if as_json {
6761×
        println!(
6771×
            "{}",
6781×
            serde_json::json!({
6791×
                "version": env!("CARGO_PKG_VERSION"),
6801×
                "build": env!("SPECTRA_BUILD_ID"),
6811×
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
6821×
                "ledger_format": spectra_core::LEDGER_FORMAT,
6831×
            })
6841×
        );
6851×
    } else {
6860×
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
6870×
    }
6881×
    Ok(0)
6891×
}