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