OXYGENHUB
# b576d05cf862 spectra / src / cli / mod.rs
14.3 KiB · Rust · last changed Serve other machines over TLS, trusted through the instance key (#20) 1 day ago source blame history raw
258bb382 Andy McCall ↶ 1
//! The `spectra` command line (spec §19). Output follows `oxy`: 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
36537112 Andy McCall ↶ 5
pub mod admin;
09752034 Andy McCall ↶ 6
pub mod agent;
258bb382 Andy McCall ↶ 7
pub mod format;
da84e98f Andy McCall ↶ 8
pub mod human;
09752034 Andy McCall ↶ 9
pub mod identity;
258bb382 Andy McCall ↶ 10
pub mod json;
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
36537112 Andy McCall ↶ 18
use anyhow::{bail, 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 {
46
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
47
        #[arg(long)]
48
        owner_home: Option<PathBuf>,
49
        /// This instance's name (default: the host name)
50
        #[arg(long)]
51
        name: Option<String>,
52
    },
53
    /// The owner's and the instance's identities: show, refresh
54
    Identity {
55
        #[command(subcommand)]
56
        command: IdentityCmd,
57
    },
571572f5 Andy McCall ↶ 58
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
59
    Serve {
b576d05c Andy McCall ↶ 60
        /// The address and port for browsers and HTTP clients (default:
61
        /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other
62
        /// machines connect over TLS (spec §8.6)
63
        #[arg(long)]
64
        bind: Option<String>,
65
        /// TLS on the TCP port: auto (beyond loopback only), on, or off
66
        /// (default: `[server] tls`, else auto)
67
        #[arg(long)]
68
        tls: Option<String>,
571572f5 Andy McCall ↶ 69
        /// Allow a non-loopback address in plain HTTP
70
        #[arg(long)]
71
        insecure: bool,
39f5c913 Andy McCall ↶ 72
        /// Open Radar in the browser once serving (signs a link as the owner)
73
        #[arg(long)]
74
        open: bool,
75
    },
76
    /// Sign in to Radar: a one-time link signed with the owner's key
77
    Open {
78
        #[arg(long)]
79
        owner_home: Option<PathBuf>,
80
        /// Print the link without opening a browser
81
        #[arg(long)]
82
        no_browser: bool,
571572f5 Andy McCall ↶ 83
    },
eb675dfa Andy McCall ↶ 84
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
85
    Mcp {
86
        /// The agent it speaks for
87
        #[arg(long)]
88
        agent: String,
89
        /// The agent's home (default: the one recorded when it was added)
90
        #[arg(long)]
91
        home: Option<PathBuf>,
92
    },
da84e98f Andy McCall ↶ 93
    /// Post a message as yourself
94
    Say {
95
        /// What to say (Markdown; @name mentions)
96
        body: String,
97
        /// The channel (default: #general)
98
        #[arg(short, long)]
99
        channel: Option<String>,
100
        /// Reply in the thread of this message
101
        #[arg(long)]
102
        reply_to: Option<String>,
103
        #[arg(long)]
104
        json: bool,
105
        #[arg(long)]
106
        owner_home: Option<PathBuf>,
107
    },
108
    /// Your unread mentions and direct messages; --follow waits for more
109
    Inbox {
110
        #[arg(long)]
111
        follow: bool,
112
        #[arg(long)]
113
        json: bool,
114
        #[arg(long)]
115
        owner_home: Option<PathBuf>,
116
    },
117
    /// Search what was said: words, "phrases", prefix*, AND/OR/NOT
118
    Search {
119
        query: String,
120
        #[arg(long)]
121
        channel: Option<String>,
122
        #[arg(long)]
123
        author: Option<String>,
124
        /// 7d, 3h, 30m
125
        #[arg(long)]
126
        since: Option<String>,
127
        #[arg(long, default_value_t = 20)]
128
        limit: u32,
129
        #[arg(long)]
130
        json: bool,
131
    },
132
    /// Any API call, signed as the owner: JSON out
133
    Api {
134
        method: String,
135
        path: String,
136
        /// The request body (JSON)
137
        #[arg(short = 'd', long = "body")]
138
        body: Option<String>,
139
        #[arg(long)]
140
        owner_home: Option<PathBuf>,
141
    },
2fc88a7b Andy McCall ↶ 142
    /// The checkouts agents work in: add, list
143
    Project {
144
        #[command(subcommand)]
145
        command: ProjectCmd,
146
    },
09752034 Andy McCall ↶ 147
    /// Agents, each its owner's delegate: add, list, disable
148
    Agent {
149
        #[command(subcommand)]
150
        command: AgentCmd,
151
    },
36537112 Andy McCall ↶ 152
    /// The ledger's own checks: verify, check, rebuild
153
    Admin {
154
        #[command(subcommand)]
155
        command: Admin,
09752034 Andy McCall ↶ 156
    },
157
}
158
159
#[derive(Subcommand)]
160
pub enum IdentityCmd {
161
    /// The owner and the instance as the ledger knows them
162
    Show {
163
        #[arg(long)]
164
        json: bool,
165
    },
166
    /// Record the owner's current identity after `oxy identity delegate add|remove`
167
    Refresh {
168
        #[arg(long)]
169
        owner_home: Option<PathBuf>,
36537112 Andy McCall ↶ 170
    },
171
}
172
173
#[derive(Subcommand)]
2fc88a7b Andy McCall ↶ 174
pub enum ProjectCmd {
175
    /// Register a checkout: its repository id, remote and pinned subrepos
176
    Add {
177
        path: PathBuf,
178
        /// Its name (default: the directory's)
179
        #[arg(long)]
180
        name: Option<String>,
181
        /// Its remote URL (default: the checkout's origin)
182
        #[arg(long)]
183
        remote: Option<String>,
184
        #[arg(long)]
185
        owner_home: Option<PathBuf>,
186
    },
187
    /// Every project with what it pins, what pins it, and its agents
188
    List {
189
        #[arg(long)]
190
        json: bool,
191
    },
192
}
193
194
#[derive(Subcommand)]
09752034 Andy McCall ↶ 195
pub enum AgentCmd {
196
    /// Make an agent's key in its own home and record it once you delegate to it
197
    Add {
198
        /// Lower-case letters, digits and dashes
199
        name: String,
200
        /// claude-code, antigravity, kimi-code, codex or other
201
        #[arg(long)]
202
        kind: String,
203
        /// The project it works in
204
        #[arg(long)]
205
        project: String,
206
        /// The agent's own OXY_HOME, where its key is made and kept
207
        #[arg(long)]
208
        home: PathBuf,
209
        /// Bind the key to this Unix user: a request from another is refused
210
        #[arg(long)]
211
        uid: Option<u32>,
212
        /// Bind the key to the Unix user of its first signed connection
213
        #[arg(long, conflicts_with = "uid")]
214
        learn_uid: bool,
215
        /// How long to wait for your delegation, in seconds
216
        #[arg(long, default_value_t = 600)]
217
        wait: u64,
218
        /// The owner's OXY_HOME
219
        #[arg(long)]
220
        owner_home: Option<PathBuf>,
221
    },
222
    /// Every agent with its owner, key, delegation and state
223
    List {
224
        #[arg(long)]
225
        json: bool,
226
    },
227
    /// Refuse an agent's key from now on
228
    Disable {
229
        name: String,
230
        #[arg(long)]
231
        owner_home: Option<PathBuf>,
232
    },
233
}
234
235
#[derive(Subcommand)]
36537112 Andy McCall ↶ 236
pub enum Admin {
237
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
238
    Verify {
239
        /// Machine-readable output
240
        #[arg(long)]
241
        json: bool,
242
    },
243
    /// Whether the projections equal a rebuild from the ledger
244
    Check,
245
    /// Replay the ledger into every projection
246
    Rebuild,
247
}
248
249
/// What every command needs: where the data lives.
250
pub struct Ctx {
251
    pub data: PathBuf,
252
}
253
254
impl Ctx {
255
    /// The ledger's database, which must exist.
256
    pub fn db(&self) -> Result<PathBuf> {
257
        let db = self.data.join("spectra.db");
258
        if !db.exists() {
259
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
260
        }
261
        Ok(db)
262
    }
09752034 Andy McCall ↶ 263
264
    /// The ledger, for writing: the instance key signs its checkpoints, and
265
    /// the lock keeps any other writer out.
266
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
267
        let db = self.db()?;
268
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
269
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
571572f5 Andy McCall ↶ 270
    }
271
272
    /// The daemon's Unix socket.
273
    pub fn socket(&self) -> PathBuf {
274
        self.data.join("spectra.sock")
275
    }
276
277
    /// Somewhere to append the owner's signed events: straight to the
278
    /// ledger, or through the daemon when it is serving (spec §8.5).
279
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
280
        let db = self.db()?;
281
        if spectra_mcp::client::Client::alive(&self.socket()).await {
282
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
283
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
284
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
285
        }
286
        Ok(Writer::Direct(self.writer().await?))
09752034 Andy McCall ↶ 287
    }
288
289
    /// `[owner] home` from spectra.toml.
290
    pub fn config_owner_home(&self) -> Option<PathBuf> {
291
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
292
        let t: toml::Table = text.parse().ok()?;
293
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
294
    }
571572f5 Andy McCall ↶ 295
}
296
297
/// Where the owner's events go.
298
pub enum Writer {
299
    Direct(spectra_core::ledger::Ledger),
300
    Daemon { ledger: spectra_core::ledger::Ledger, client: Box<spectra_mcp::client::Client> },
36537112 Andy McCall ↶ 301
}
302
571572f5 Andy McCall ↶ 303
impl Writer {
304
    /// The ledger, for reading.
305
    pub fn ledger(&self) -> &spectra_core::ledger::Ledger {
306
        match self {
307
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => l,
308
        }
309
    }
310
311
    /// Append events signed over their payload, then checkpoint.
312
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
313
        match self {
314
            Writer::Direct(l) => {
315
                l.append(events).await?;
316
                l.checkpoint().await?;
317
            }
318
            Writer::Daemon { client, .. } => {
319
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
320
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
321
                if r.status != 200 {
322
                    let why = r
323
                        .json()
324
                        .ok()
325
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
326
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
327
                    bail!("the daemon refused: {why}");
328
                }
329
            }
330
        }
331
        Ok(())
332
    }
333
}
334
335
fn init_logging() {
336
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
337
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
338
}
339
36537112 Andy McCall ↶ 340
fn default_data() -> PathBuf {
341
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
342
        return PathBuf::from(x).join("spectra");
343
    }
344
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
345
    PathBuf::from(home).join(".local").join("share").join("spectra")
346
}
347
348
fn runtime() -> Result<tokio::runtime::Runtime> {
349
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
258bb382 Andy McCall ↶ 350
}
351
352
pub fn run() -> Result<i32> {
353
    let cli = Cli::parse();
354
    ui::init(cli.plain);
36537112 Andy McCall ↶ 355
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
258bb382 Andy McCall ↶ 356
    match cli.command {
357
        Command::Version { json } => version(json),
09752034 Andy McCall ↶ 358
        Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
359
        Command::Identity { command } => runtime()?.block_on(async {
360
            match command {
361
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
362
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
2fc88a7b Andy McCall ↶ 363
            }
364
        }),
b576d05c Andy McCall ↶ 365
        Command::Serve { bind, tls, insecure, open } => {
571572f5 Andy McCall ↶ 366
            init_logging();
b576d05c Andy McCall ↶ 367
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
571572f5 Andy McCall ↶ 368
        }
39f5c913 Andy McCall ↶ 369
        Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)),
eb675dfa Andy McCall ↶ 370
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
da84e98f Andy McCall ↶ 371
        Command::Say { body, channel, reply_to, json, owner_home } => {
372
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
373
        }
374
        Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)),
375
        Command::Search { query, channel, author, since, limit, json } => {
376
            runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json))
377
        }
378
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
2fc88a7b Andy McCall ↶ 379
        Command::Project { command } => runtime()?.block_on(async {
380
            match command {
381
                ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await,
382
                ProjectCmd::List { json } => project::list(&ctx, json).await,
09752034 Andy McCall ↶ 383
            }
384
        }),
385
        Command::Agent { command } => runtime()?.block_on(async {
386
            match command {
387
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
388
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
389
                }
390
                AgentCmd::List { json } => agent::list(&ctx, json).await,
391
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
392
            }
393
        }),
36537112 Andy McCall ↶ 394
        Command::Admin { command } => runtime()?.block_on(async {
395
            match command {
396
                Admin::Verify { json } => admin::verify(&ctx, json).await,
397
                Admin::Check => admin::check(&ctx).await,
398
                Admin::Rebuild => admin::rebuild(&ctx).await,
399
            }
400
        }),
258bb382 Andy McCall ↶ 401
    }
402
}
403
404
fn version(as_json: bool) -> Result<i32> {
405
    if as_json {
406
        println!(
407
            "{}",
408
            serde_json::json!({
409
                "version": env!("CARGO_PKG_VERSION"),
410
                "build": env!("SPECTRA_BUILD_ID"),
411
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
412
                "ledger_format": spectra_core::LEDGER_FORMAT,
413
            })
414
        );
415
    } else {
416
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
417
    }
418
    Ok(0)
419
}