OXYGENHUB
◆ v0.1.0 spectra / src / cli / mod.rs
22.6 KiB · Rust · last changed Spectra stands alone: its own identities, signatures and CBOR; projects of any kind of work (#26, #27) 1 day ago source blame history raw
98a0fb9f Andy McCall ↶ 1
//! The `spectra` command line (spec §19). Output is modern: colour and
258bb382 Andy McCall ↶ 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
36537112 Andy McCall ↶ 5
pub mod admin;
09752034 Andy McCall ↶ 6
pub mod agent;
eca5af92 Andy McCall ↶ 7
pub mod connect;
258bb382 Andy McCall ↶ 8
pub mod format;
da84e98f Andy McCall ↶ 9
pub mod human;
09752034 Andy McCall ↶ 10
pub mod identity;
5d695d86 Andy McCall ↶ 11
pub mod marks;
eb675dfa Andy McCall ↶ 12
pub mod mcp;
39f5c913 Andy McCall ↶ 13
pub mod open;
2fc88a7b Andy McCall ↶ 14
pub mod project;
571572f5 Andy McCall ↶ 15
pub mod serve;
258bb382 Andy McCall ↶ 16
pub mod ui;
17
67bfa06a Andy McCall ↶ 18
use anyhow::{bail, Context, Result};
258bb382 Andy McCall ↶ 19
use clap::{Parser, Subcommand};
36537112 Andy McCall ↶ 20
use std::path::PathBuf;
258bb382 Andy McCall ↶ 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,
36537112 Andy McCall ↶ 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>,
258bb382 Andy McCall ↶ 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
    },
09752034 Andy McCall ↶ 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 {
98a0fb9f Andy McCall ↶ 46
        /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra)
09752034 Andy McCall ↶ 47
        #[arg(long)]
48
        owner_home: Option<PathBuf>,
49
        /// This instance's name (default: the host name)
50
        #[arg(long)]
51
        name: Option<String>,
77df591d Andy McCall ↶ 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>,
09752034 Andy McCall ↶ 57
    },
eca5af92 Andy McCall ↶ 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,
98a0fb9f Andy McCall ↶ 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)
eca5af92 Andy McCall ↶ 70
        #[arg(long)]
71
        owner_home: Option<PathBuf>,
72
    },
09752034 Andy McCall ↶ 73
    /// The owner's and the instance's identities: show, refresh
74
    Identity {
75
        #[command(subcommand)]
76
        command: IdentityCmd,
77
    },
571572f5 Andy McCall ↶ 78
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
79
    Serve {
b576d05c Andy McCall ↶ 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>,
571572f5 Andy McCall ↶ 89
        /// Allow a non-loopback address in plain HTTP
90
        #[arg(long)]
91
        insecure: bool,
39f5c913 Andy McCall ↶ 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,
01320eef Andy McCall ↶ 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,
571572f5 Andy McCall ↶ 107
    },
eb675dfa Andy McCall ↶ 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
    },
da84e98f Andy McCall ↶ 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
    },
2fc88a7b Andy McCall ↶ 166
    /// The checkouts agents work in: add, list
167
    Project {
168
        #[command(subcommand)]
169
        command: ProjectCmd,
170
    },
09752034 Andy McCall ↶ 171
    /// Agents, each its owner's delegate: add, list, disable
172
    Agent {
173
        #[command(subcommand)]
174
        command: AgentCmd,
175
    },
36537112 Andy McCall ↶ 176
    /// The ledger's own checks: verify, check, rebuild
177
    Admin {
178
        #[command(subcommand)]
179
        command: Admin,
09752034 Andy McCall ↶ 180
    },
181
}
182
183
#[derive(Subcommand)]
184
pub enum IdentityCmd {
98a0fb9f Andy McCall ↶ 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
    },
09752034 Andy McCall ↶ 197
    /// The owner and the instance as the ledger knows them
198
    Show {
199
        #[arg(long)]
200
        json: bool,
201
    },
98a0fb9f Andy McCall ↶ 202
    /// Record the owner's current identity if the ledger has missed a successor
09752034 Andy McCall ↶ 203
    Refresh {
204
        #[arg(long)]
205
        owner_home: Option<PathBuf>,
36537112 Andy McCall ↶ 206
    },
207
}
208
209
#[derive(Subcommand)]
2fc88a7b Andy McCall ↶ 210
pub enum ProjectCmd {
98a0fb9f Andy McCall ↶ 211
    /// Add a project: any kind of work, with a folder if it has one
2fc88a7b Andy McCall ↶ 212
    Add {
98a0fb9f Andy McCall ↶ 213
        /// A folder (its name becomes the project's), or just a name
214
        target: String,
215
        /// Its name, when TARGET is a folder
2fc88a7b Andy McCall ↶ 216
        #[arg(long)]
217
        name: Option<String>,
98a0fb9f Andy McCall ↶ 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
2fc88a7b Andy McCall ↶ 236
        #[arg(long)]
98a0fb9f Andy McCall ↶ 237
        remove: bool,
2fc88a7b Andy McCall ↶ 238
        #[arg(long)]
239
        owner_home: Option<PathBuf>,
240
    },
98a0fb9f Andy McCall ↶ 241
    /// Every project with its links and agents
2fc88a7b Andy McCall ↶ 242
    List {
243
        #[arg(long)]
244
        json: bool,
245
    },
246
}
247
248
#[derive(Subcommand)]
09752034 Andy McCall ↶ 249
pub enum AgentCmd {
98a0fb9f Andy McCall ↶ 250
    /// Make an agent's key in its own home, delegate to it, and record it
09752034 Andy McCall ↶ 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,
98a0fb9f Andy McCall ↶ 260
        /// The agent's own identity home, where its key is made and kept
09752034 Andy McCall ↶ 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,
98a0fb9f Andy McCall ↶ 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,
09752034 Andy McCall ↶ 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)]
36537112 Andy McCall ↶ 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.
322
    pub fn db(&self) -> Result<PathBuf> {
323
        let db = self.data.join("spectra.db");
324
        if !db.exists() {
325
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
326
        }
327
        Ok(db)
328
    }
09752034 Andy McCall ↶ 329
330
    /// The ledger, for writing: the instance key signs its checkpoints, and
331
    /// the lock keeps any other writer out.
332
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
333
        let db = self.db()?;
334
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
335
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
571572f5 Andy McCall ↶ 336
    }
337
338
    /// The daemon's Unix socket.
339
    pub fn socket(&self) -> PathBuf {
340
        self.data.join("spectra.sock")
eca5af92 Andy McCall ↶ 341
    }
342
343
    /// The Spectra on another machine this one uses, if any (spec §8.6).
344
    pub fn remote(&self) -> Result<Option<connect::ClientCfg>> {
345
        connect::load()
346
    }
347
348
    /// Where the daemon is: the configured server, else the local socket.
349
    pub fn endpoint(&self) -> Result<spectra_mcp::client::Endpoint> {
350
        Ok(match self.remote()? {
351
            Some(c) => c.endpoint()?,
352
            None => spectra_mcp::client::Endpoint::Unix(self.socket()),
353
        })
571572f5 Andy McCall ↶ 354
    }
355
67bfa06a Andy McCall ↶ 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.
360
    pub async fn owner_client(&self, owner_home: &std::path::Path) -> Result<Option<spectra_mcp::client::Client>> {
361
        use spectra_mcp::client::Client;
362
        if let Some(cfg) = self.remote()? {
363
            let ep = cfg.endpoint()?;
364
            let pair = remote_owner(&ep, owner_home).await?;
365
            return Ok(Some(Client::at(ep, Some(pair))));
366
        }
367
        if !self.data.join("spectra.db").exists() || !Client::alive(&self.socket()).await {
368
            return Ok(None);
369
        }
370
        let ledger = spectra_core::ledger::Ledger::open_read_only(&self.db()?).await?;
371
        Ok(Some(Client::new(&self.socket(), Some(identity::recorded_owner(&ledger, owner_home).await?))))
372
    }
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).
571572f5 Andy McCall ↶ 377
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
67bfa06a Andy McCall ↶ 378
        if let Some(cfg) = self.remote()? {
379
            let ep = cfg.endpoint()?;
380
            let pair = remote_owner(&ep, owner_home).await?;
381
            let recorded = pair.identity.clone();
382
            return Ok(Writer::Remote { client: Box::new(spectra_mcp::client::Client::at(ep, Some(pair))), recorded });
383
        }
571572f5 Andy McCall ↶ 384
        let db = self.db()?;
385
        if spectra_mcp::client::Client::alive(&self.socket()).await {
386
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
387
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
388
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
389
        }
390
        Ok(Writer::Direct(self.writer().await?))
67bfa06a Andy McCall ↶ 391
    }
392
393
    /// Refuse a command that needs the ledger file on this machine.
394
    pub fn local_only(&self, what: &str) -> Result<()> {
395
        if self.remote()?.is_some() {
396
            bail!("{what} runs on the server, which holds the ledger (this machine uses {})", connect::path().display());
397
        }
398
        Ok(())
09752034 Andy McCall ↶ 399
    }
400
401
    /// `[owner] home` from spectra.toml.
402
    pub fn config_owner_home(&self) -> Option<PathBuf> {
403
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
404
        let t: toml::Table = text.parse().ok()?;
405
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
406
    }
571572f5 Andy McCall ↶ 407
}
408
409
/// Where the owner's events go.
410
pub enum Writer {
411
    Direct(spectra_core::ledger::Ledger),
67bfa06a Andy McCall ↶ 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>,
98a0fb9f Andy McCall ↶ 419
        recorded: spectra_core::identity::Identity,
67bfa06a Andy McCall ↶ 420
    },
36537112 Andy McCall ↶ 421
}
422
571572f5 Andy McCall ↶ 423
impl Writer {
67bfa06a Andy McCall ↶ 424
    /// The ledger on this machine, for reading, if there is one.
425
    pub fn ledger(&self) -> Option<&spectra_core::ledger::Ledger> {
571572f5 Andy McCall ↶ 426
        match self {
67bfa06a Andy McCall ↶ 427
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => Some(l),
428
            Writer::Remote { .. } => None,
571572f5 Andy McCall ↶ 429
        }
430
    }
431
67bfa06a Andy McCall ↶ 432
    /// A client of the daemon, when one is serving.
433
    pub fn client(&self) -> Option<&spectra_mcp::client::Client> {
434
        match self {
435
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => Some(client),
436
            Writer::Direct(_) => None,
437
        }
438
    }
439
440
    /// The rotation that brings the recorded owner to the current identity
441
    /// in `home`, if it has moved.
442
    pub async fn refresh_event(&self, home: &std::path::Path) -> Result<Option<spectra_core::event::NewEvent>> {
443
        match self {
444
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => identity::refresh_event(l, home).await,
445
            Writer::Remote { recorded, .. } => identity::rotation(recorded, home),
446
        }
447
    }
448
571572f5 Andy McCall ↶ 449
    /// Append events signed over their payload, then checkpoint.
450
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
451
        match self {
452
            Writer::Direct(l) => {
453
                l.append(events).await?;
454
                l.checkpoint().await?;
455
            }
67bfa06a Andy McCall ↶ 456
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => {
571572f5 Andy McCall ↶ 457
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
458
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
459
                if r.status != 200 {
460
                    let why = r
461
                        .json()
462
                        .ok()
463
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
464
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
465
                    bail!("the daemon refused: {why}");
466
                }
467
            }
468
        }
469
        Ok(())
470
    }
67bfa06a Andy McCall ↶ 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.
475
async fn remote_owner(ep: &spectra_mcp::client::Endpoint, home: &std::path::Path) -> Result<spectra_core::keys::KeyPair> {
476
    let r = spectra_mcp::client::Client::at(ep.clone(), None).send("GET", "/api/v1/instance", &[], vec![]).await?;
477
    let info = r.json()?;
478
    let key = info.get("owner").and_then(|o| o.as_str()).context("the server has no owner yet: spectra connect claims it")?;
479
    let keys = home.join("keys");
480
    spectra_core::keys::KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity")))
481
        .with_context(|| format!("the server's owner {} is not an identity in {}", &key[..12.min(key.len())], home.display()))
482
}
483
484
/// An API call; a refusal becomes an error that says why.
485
pub async fn call(c: &spectra_mcp::client::Client, method: &str, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value> {
486
    let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?;
487
    let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null);
488
    if r.status >= 400 {
489
        let why = v
490
            .get("detail")
491
            .or(v.get("title"))
492
            .and_then(serde_json::Value::as_str)
493
            .map(String::from)
494
            .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
495
        bail!("{why}");
496
    }
497
    Ok(v)
498
}
499
500
/// A query string value, percent-encoded.
501
pub fn encode(s: &str) -> String {
502
    s.bytes()
503
        .map(|b| match b {
504
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
505
            _ => format!("%{b:02X}"),
506
        })
507
        .collect()
571572f5 Andy McCall ↶ 508
}
509
510
fn init_logging() {
511
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
512
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
513
}
514
36537112 Andy McCall ↶ 515
fn default_data() -> PathBuf {
516
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
517
        return PathBuf::from(x).join("spectra");
518
    }
519
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
520
    PathBuf::from(home).join(".local").join("share").join("spectra")
521
}
522
523
fn runtime() -> Result<tokio::runtime::Runtime> {
524
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
258bb382 Andy McCall ↶ 525
}
526
527
pub fn run() -> Result<i32> {
528
    let cli = Cli::parse();
529
    ui::init(cli.plain);
36537112 Andy McCall ↶ 530
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
258bb382 Andy McCall ↶ 531
    match cli.command {
532
        Command::Version { json } => version(json),
98a0fb9f Andy McCall ↶ 533
        Command::Connect { url, owner_home, expect, yes } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home, expect, yes)),
77df591d Andy McCall ↶ 534
        Command::Init { owner_home, name, owner: Some(key) } => {
535
            let _ = owner_home;
536
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
537
        }
538
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
09752034 Andy McCall ↶ 539
        Command::Identity { command } => runtime()?.block_on(async {
540
            match command {
98a0fb9f Andy McCall ↶ 541
                IdentityCmd::Create { name, contact, home } => identity::create(&name, &contact, home),
09752034 Andy McCall ↶ 542
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
543
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
2fc88a7b Andy McCall ↶ 544
            }
545
        }),
b576d05c Andy McCall ↶ 546
        Command::Serve { bind, tls, insecure, open } => {
571572f5 Andy McCall ↶ 547
            init_logging();
b576d05c Andy McCall ↶ 548
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
571572f5 Andy McCall ↶ 549
        }
01320eef Andy McCall ↶ 550
        Command::Open { owner_home, no_browser, port } => runtime()?.block_on(async {
551
            if ctx.remote()?.is_some() {
552
                open::open_remote(&ctx, owner_home, !no_browser, port).await
553
            } else {
554
                open::open(&ctx, owner_home, !no_browser).await
555
            }
556
        }),
eb675dfa Andy McCall ↶ 557
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
da84e98f Andy McCall ↶ 558
        Command::Say { body, channel, reply_to, json, owner_home } => {
559
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
560
        }
561
        Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)),
562
        Command::Search { query, channel, author, since, limit, json } => {
563
            runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json))
564
        }
565
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
2fc88a7b Andy McCall ↶ 566
        Command::Project { command } => runtime()?.block_on(async {
567
            match command {
98a0fb9f Andy McCall ↶ 568
                ProjectCmd::Add { target, name, path, description, url, owner_home } => {
569
                    project::add(&ctx, project::Add { target, name, path, description, url }, owner_home).await
570
                }
571
                ProjectCmd::Link { project, depends_on, remove, owner_home } => {
572
                    project::link(&ctx, &project, &depends_on, remove, owner_home).await
573
                }
2fc88a7b Andy McCall ↶ 574
                ProjectCmd::List { json } => project::list(&ctx, json).await,
09752034 Andy McCall ↶ 575
            }
576
        }),
577
        Command::Agent { command } => runtime()?.block_on(async {
578
            match command {
98a0fb9f Andy McCall ↶ 579
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home } => {
580
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home }).await
09752034 Andy McCall ↶ 581
                }
98a0fb9f Andy McCall ↶ 582
                AgentCmd::Renew { name, expires, owner_home } => agent::renew(&ctx, &name, &expires, owner_home).await,
09752034 Andy McCall ↶ 583
                AgentCmd::List { json } => agent::list(&ctx, json).await,
584
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
585
            }
586
        }),
36537112 Andy McCall ↶ 587
        Command::Admin { command } => runtime()?.block_on(async {
588
            match command {
589
                Admin::Verify { json } => admin::verify(&ctx, json).await,
590
                Admin::Check => admin::check(&ctx).await,
591
                Admin::Rebuild => admin::rebuild(&ctx).await,
592
            }
593
        }),
258bb382 Andy McCall ↶ 594
    }
595
}
596
597
fn version(as_json: bool) -> Result<i32> {
598
    if as_json {
599
        println!(
600
            "{}",
601
            serde_json::json!({
602
                "version": env!("CARGO_PKG_VERSION"),
603
                "build": env!("SPECTRA_BUILD_ID"),
604
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
605
                "ledger_format": spectra_core::LEDGER_FORMAT,
606
            })
607
        );
608
    } else {
609
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
610
    }
611
    Ok(0)
612
}