OXYGENHUB

Coverage / src/cli/mod.rs 84.1%

190 of 226 lines run, 35 of 86 functions · run #24 on main · run missed

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 connect;
8
pub mod format;
9
pub mod human;
10
pub mod identity;
11
pub mod json;
12
pub mod marks;
13
pub mod mcp;
14
pub mod open;
15
pub mod project;
16
pub mod serve;
17
pub mod ui;
18
19
use anyhow::{bail, Context, Result};
20
use clap::{Parser, Subcommand};
21
use std::path::PathBuf;
22
23
#[derive(Parser)]
24
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
25
pub struct Cli {
26
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
27
    #[arg(long, global = true)]
28
    pub plain: bool,
29
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
30
    #[arg(long, global = true, env = "SPECTRA_DATA")]
31
    pub data: Option<PathBuf>,
32
    #[command(subcommand)]
33
    pub command: Command,
34
}
35
36
#[derive(Subcommand)]
37
pub enum Command {
38
    /// The version and build of this binary
39
    Version {
40
        /// Machine-readable output
41
        #[arg(long)]
42
        json: bool,
43
    },
44
    /// Make this machine's Spectra: record the owner's identity (read from
45
    /// their OXY_HOME and proven by signing) and make the instance key
46
    Init {
47
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
48
        #[arg(long)]
49
        owner_home: Option<PathBuf>,
50
        /// This instance's name (default: the host name)
51
        #[arg(long)]
52
        name: Option<String>,
53
        /// Set up a server without the owner's secret key: name the owner's
54
        /// KeyId, and they claim it from their machine with `spectra
55
        /// connect` (spec §8.6)
56
        #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")]
57
        owner: Option<String>,
58
    },
59
    /// Use a Spectra on another machine: trust it through your delegation to
60
    /// its instance key, pin its certificate, and claim it if it is new
61
    Connect {
62
        /// The server, https://HOST[:PORT]
63
        url: String,
64
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
65
        #[arg(long)]
66
        owner_home: Option<PathBuf>,
67
    },
68
    /// The owner's and the instance's identities: show, refresh
69
    Identity {
70
        #[command(subcommand)]
71
        command: IdentityCmd,
72
    },
73
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
74
    Serve {
75
        /// The address and port for browsers and HTTP clients (default:
76
        /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other
77
        /// machines connect over TLS (spec §8.6)
78
        #[arg(long)]
79
        bind: Option<String>,
80
        /// TLS on the TCP port: auto (beyond loopback only), on, or off
81
        /// (default: `[server] tls`, else auto)
82
        #[arg(long)]
83
        tls: Option<String>,
84
        /// Allow a non-loopback address in plain HTTP
85
        #[arg(long)]
86
        insecure: bool,
87
        /// Open Radar in the browser once serving (signs a link as the owner)
88
        #[arg(long)]
89
        open: bool,
90
    },
91
    /// Sign in to Radar: a one-time link signed with the owner's key
92
    Open {
93
        #[arg(long)]
94
        owner_home: Option<PathBuf>,
95
        /// Print the link without opening a browser
96
        #[arg(long)]
97
        no_browser: bool,
98
        /// With Spectra on another machine: the loopback port of the proxy
99
        /// Radar is served through (spec §8.6)
100
        #[arg(long, default_value_t = 7350)]
101
        port: u16,
102
    },
103
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
104
    Mcp {
105
        /// The agent it speaks for
106
        #[arg(long)]
107
        agent: String,
108
        /// The agent's home (default: the one recorded when it was added)
109
        #[arg(long)]
110
        home: Option<PathBuf>,
111
    },
112
    /// Post a message as yourself
113
    Say {
114
        /// What to say (Markdown; @name mentions)
115
        body: String,
116
        /// The channel (default: #general)
117
        #[arg(short, long)]
118
        channel: Option<String>,
119
        /// Reply in the thread of this message
120
        #[arg(long)]
121
        reply_to: Option<String>,
122
        #[arg(long)]
123
        json: bool,
124
        #[arg(long)]
125
        owner_home: Option<PathBuf>,
126
    },
127
    /// Your unread mentions and direct messages; --follow waits for more
128
    Inbox {
129
        #[arg(long)]
130
        follow: bool,
131
        #[arg(long)]
132
        json: bool,
133
        #[arg(long)]
134
        owner_home: Option<PathBuf>,
135
    },
136
    /// Search what was said: words, "phrases", prefix*, AND/OR/NOT
137
    Search {
138
        query: String,
139
        #[arg(long)]
140
        channel: Option<String>,
141
        #[arg(long)]
142
        author: Option<String>,
143
        /// 7d, 3h, 30m
144
        #[arg(long)]
145
        since: Option<String>,
146
        #[arg(long, default_value_t = 20)]
147
        limit: u32,
148
        #[arg(long)]
149
        json: bool,
150
    },
151
    /// Any API call, signed as the owner: JSON out
152
    Api {
153
        method: String,
154
        path: String,
155
        /// The request body (JSON)
156
        #[arg(short = 'd', long = "body")]
157
        body: Option<String>,
158
        #[arg(long)]
159
        owner_home: Option<PathBuf>,
160
    },
161
    /// The checkouts agents work in: add, list
162
    Project {
163
        #[command(subcommand)]
164
        command: ProjectCmd,
165
    },
166
    /// Agents, each its owner's delegate: add, list, disable
167
    Agent {
168
        #[command(subcommand)]
169
        command: AgentCmd,
170
    },
171
    /// The ledger's own checks: verify, check, rebuild
172
    Admin {
173
        #[command(subcommand)]
174
        command: Admin,
175
    },
176
}
177
178
#[derive(Subcommand)]
179
pub enum IdentityCmd {
180
    /// The owner and the instance as the ledger knows them
181
    Show {
182
        #[arg(long)]
183
        json: bool,
184
    },
185
    /// Record the owner's current identity after `oxy identity delegate add|remove`
186
    Refresh {
187
        #[arg(long)]
188
        owner_home: Option<PathBuf>,
189
    },
190
}
191
192
#[derive(Subcommand)]
193
pub enum ProjectCmd {
194
    /// Register a checkout: its repository id, remote and pinned subrepos
195
    Add {
196
        path: PathBuf,
197
        /// Its name (default: the directory's)
198
        #[arg(long)]
199
        name: Option<String>,
200
        /// Its remote URL (default: the checkout's origin)
201
        #[arg(long)]
202
        remote: Option<String>,
203
        #[arg(long)]
204
        owner_home: Option<PathBuf>,
205
    },
206
    /// Every project with what it pins, what pins it, and its agents
207
    List {
208
        #[arg(long)]
209
        json: bool,
210
    },
211
}
212
213
#[derive(Subcommand)]
214
pub enum AgentCmd {
215
    /// Make an agent's key in its own home and record it once you delegate to it
216
    Add {
217
        /// Lower-case letters, digits and dashes
218
        name: String,
219
        /// claude-code, antigravity, kimi-code, codex or other
220
        #[arg(long)]
221
        kind: String,
222
        /// The project it works in
223
        #[arg(long)]
224
        project: String,
225
        /// The agent's own OXY_HOME, where its key is made and kept
226
        #[arg(long)]
227
        home: PathBuf,
228
        /// Bind the key to this Unix user: a request from another is refused
229
        #[arg(long)]
230
        uid: Option<u32>,
231
        /// Bind the key to the Unix user of its first signed connection
232
        #[arg(long, conflicts_with = "uid")]
233
        learn_uid: bool,
234
        /// How long to wait for your delegation, in seconds
235
        #[arg(long, default_value_t = 600)]
236
        wait: u64,
237
        /// The owner's OXY_HOME
238
        #[arg(long)]
239
        owner_home: Option<PathBuf>,
240
    },
241
    /// Every agent with its owner, key, delegation and state
242
    List {
243
        #[arg(long)]
244
        json: bool,
245
    },
246
    /// Refuse an agent's key from now on
247
    Disable {
248
        name: String,
249
        #[arg(long)]
250
        owner_home: Option<PathBuf>,
251
    },
252
}
253
254
#[derive(Subcommand)]
255
pub enum Admin {
256
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
257
    Verify {
258
        /// Machine-readable output
259
        #[arg(long)]
260
        json: bool,
261
    },
262
    /// Whether the projections equal a rebuild from the ledger
263
    Check,
264
    /// Replay the ledger into every projection
265
    Rebuild,
266
}
267
268
/// What every command needs: where the data lives.
269
pub struct Ctx {
270
    pub data: PathBuf,
271
}
272
273
impl Ctx {
274
    /// The ledger's database, which must exist.
275150×
    pub fn db(&self) -> Result<PathBuf> {
276150×
        let db = self.data.join("spectra.db");
277150×
        if !db.exists() {
2781×
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
279149×
        }
280149×
        Ok(db)
281150×
    }
282
283
    /// The ledger, for writing: the instance key signs its checkpoints, and
284
    /// the lock keeps any other writer out.
28550×
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
28650×
        let db = self.db()?;
28750×
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
28850×
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
28950×
    }
290
291
    /// The daemon's Unix socket.
292104×
    pub fn socket(&self) -> PathBuf {
293104×
        self.data.join("spectra.sock")
294104×
    }
295
296
    /// The Spectra on another machine this one uses, if any (spec §8.6).
297146×
    pub fn remote(&self) -> Result<Option<connect::ClientCfg>> {
298146×
        connect::load()
299146×
    }
300
301
    /// Where the daemon is: the configured server, else the local socket.
3020×
    pub fn endpoint(&self) -> Result<spectra_mcp::client::Endpoint> {
3030×
        Ok(match self.remote()? {
3040×
            Some(c) => c.endpoint()?,
3050×
            None => spectra_mcp::client::Endpoint::Unix(self.socket()),
306
        })
3070×
    }
308
309
    /// A client signing as the owner: to the configured server (spec §8.6),
310
    /// or to the local daemon when it is serving; none when neither. It
311
    /// signs with the owner identity the ledger records, since the daemon
312
    /// may not know a newer one yet.
31344×
    pub async fn owner_client(&self, owner_home: &std::path::Path) -> Result<Option<spectra_mcp::client::Client>> {
314
        use spectra_mcp::client::Client;
31544×
        if let Some(cfg) = self.remote()? {
31610×
            let ep = cfg.endpoint()?;
31710×
            let pair = remote_owner(&ep, owner_home).await?;
31810×
            return Ok(Some(Client::at(ep, Some(pair))));
31934×
        }
32034×
        if !self.data.join("spectra.db").exists() || !Client::alive(&self.socket()).await {
32130×
            return Ok(None);
3224×
        }
3234×
        let ledger = spectra_core::ledger::Ledger::open_read_only(&self.db()?).await?;
3244×
        Ok(Some(Client::new(&self.socket(), Some(identity::recorded_owner(&ledger, owner_home).await?))))
32544×
    }
326
327
    /// Somewhere to append the owner's signed events: the configured server,
328
    /// the local daemon when it is serving, else straight to the ledger
329
    /// (spec §8.5, §8.6).
33048×
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
33148×
        if let Some(cfg) = self.remote()? {
3324×
            let ep = cfg.endpoint()?;
3334×
            let pair = remote_owner(&ep, owner_home).await?;
3344×
            let recorded = pair.identity.clone();
3354×
            return Ok(Writer::Remote { client: Box::new(spectra_mcp::client::Client::at(ep, Some(pair))), recorded });
33644×
        }
33744×
        let db = self.db()?;
33844×
        if spectra_mcp::client::Client::alive(&self.socket()).await {
3392×
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
3402×
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
3412×
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
34242×
        }
34342×
        Ok(Writer::Direct(self.writer().await?))
34448×
    }
345
346
    /// Refuse a command that needs the ledger file on this machine.
3478×
    pub fn local_only(&self, what: &str) -> Result<()> {
3488×
        if self.remote()?.is_some() {
3491×
            bail!("{what} runs on the server, which holds the ledger (this machine uses {})", connect::path().display());
3507×
        }
3517×
        Ok(())
3528×
    }
353
354
    /// `[owner] home` from spectra.toml.
35593×
    pub fn config_owner_home(&self) -> Option<PathBuf> {
35693×
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
35760×
        let t: toml::Table = text.parse().ok()?;
35860×
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
35993×
    }
360
}
361
362
/// Where the owner's events go.
363
pub enum Writer {
364
    Direct(spectra_core::ledger::Ledger),
365
    Daemon {
366
        ledger: spectra_core::ledger::Ledger,
367
        client: Box<spectra_mcp::client::Client>,
368
    },
369
    /// A Spectra on another machine; `recorded` is the owner identity it knows.
370
    Remote {
371
        client: Box<spectra_mcp::client::Client>,
372
        recorded: oxy_core::identity::Identity,
373
    },
374
}
375
376
impl Writer {
377
    /// The ledger on this machine, for reading, if there is one.
37849×
    pub fn ledger(&self) -> Option<&spectra_core::ledger::Ledger> {
37949×
        match self {
38046×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => Some(l),
3813×
            Writer::Remote { .. } => None,
382
        }
38349×
    }
384
385
    /// A client of the daemon, when one is serving.
38649×
    pub fn client(&self) -> Option<&spectra_mcp::client::Client> {
38749×
        match self {
3885×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => Some(client),
38944×
            Writer::Direct(_) => None,
390
        }
39149×
    }
392
393
    /// The rotation that brings the recorded owner to the current identity
394
    /// in `home`, if it has moved.
39546×
    pub async fn refresh_event(&self, home: &std::path::Path) -> Result<Option<spectra_core::event::NewEvent>> {
39646×
        match self {
39742×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => identity::refresh_event(l, home).await,
3984×
            Writer::Remote { recorded, .. } => identity::rotation(recorded, home),
399
        }
40046×
    }
401
402
    /// Append events signed over their payload, then checkpoint.
40346×
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
40446×
        match self {
40540×
            Writer::Direct(l) => {
40640×
                l.append(events).await?;
40740×
                l.checkpoint().await?;
408
            }
4094×
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => {
4106×
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
4116×
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
4126×
                if r.status != 200 {
4130×
                    let why = r
4140×
                        .json()
4150×
                        .ok()
4160×
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
4170×
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
4180×
                    bail!("the daemon refused: {why}");
4196×
                }
420
            }
421
        }
42246×
        Ok(())
42346×
    }
424
}
425
426
/// The owner identity a server records, as a key pair from the owner's
427
/// home: what requests to it are signed with.
42814×
async fn remote_owner(ep: &spectra_mcp::client::Endpoint, home: &std::path::Path) -> Result<spectra_core::keys::KeyPair> {
42914×
    let r = spectra_mcp::client::Client::at(ep.clone(), None).send("GET", "/api/v1/instance", &[], vec![]).await?;
43014×
    let info = r.json()?;
43114×
    let key = info.get("owner").and_then(|o| o.as_str()).context("the server has no owner yet: spectra connect claims it")?;
43214×
    let keys = home.join("keys");
43314×
    spectra_core::keys::KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity")))
43414×
        .with_context(|| format!("the server's owner {} is not an identity in {}", &key[..12.min(key.len())], home.display()))
43514×
}
436
437
/// An API call; a refusal becomes an error that says why.
43821×
pub async fn call(c: &spectra_mcp::client::Client, method: &str, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value> {
43921×
    let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?;
44021×
    let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null);
44121×
    if r.status >= 400 {
4420×
        let why = v
4430×
            .get("detail")
4440×
            .or(v.get("title"))
4450×
            .and_then(serde_json::Value::as_str)
4460×
            .map(String::from)
4470×
            .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
4480×
        bail!("{why}");
44921×
    }
45021×
    Ok(v)
45121×
}
452
453
/// A query string value, percent-encoded.
4544×
pub fn encode(s: &str) -> String {
4554×
    s.bytes()
45626×
        .map(|b| match b {
45726×
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
4580×
            _ => format!("%{b:02X}"),
45926×
        })
4604×
        .collect()
4614×
}
462
46320×
fn init_logging() {
46420×
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
46520×
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
46620×
}
467
4680×
fn default_data() -> PathBuf {
4690×
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
4700×
        return PathBuf::from(x).join("spectra");
4710×
    }
4720×
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
4730×
    PathBuf::from(home).join(".local").join("share").join("spectra")
4740×
}
475
476161×
fn runtime() -> Result<tokio::runtime::Runtime> {
477161×
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
478161×
}
479
480162×
pub fn run() -> Result<i32> {
481162×
    let cli = Cli::parse();
482162×
    ui::init(cli.plain);
483162×
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
48428×
    match cli.command {
4851×
        Command::Version { json } => version(json),
4869×
        Command::Connect { url, owner_home } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home)),
4876×
        Command::Init { owner_home, name, owner: Some(key) } => {
4886×
            let _ = owner_home;
4896×
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
490
        }
49122×
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
4922×
        Command::Identity { command } => runtime()?.block_on(async {
4932×
            match command {
4942×
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
4950×
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
496
            }
4972×
        }),
49820×
        Command::Serve { bind, tls, insecure, open } => {
49920×
            init_logging();
50020×
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
501
        }
5021×
        Command::Open { owner_home, no_browser, port } => runtime()?.block_on(async {
5031×
            if ctx.remote()?.is_some() {
5040×
                open::open_remote(&ctx, owner_home, !no_browser, port).await
505
            } else {
5061×
                open::open(&ctx, owner_home, !no_browser).await
507
            }
5081×
        }),
5092×
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
5103×
        Command::Say { body, channel, reply_to, json, owner_home } => {
5113×
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
512
        }
5132×
        Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)),
5143×
        Command::Search { query, channel, author, since, limit, json } => {
5153×
            runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json))
516
        }
5172×
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
51828×
        Command::Project { command } => runtime()?.block_on(async {
51928×
            match command {
52024×
                ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await,
5214×
                ProjectCmd::List { json } => project::list(&ctx, json).await,
522
            }
52328×
        }),
52432×
        Command::Agent { command } => runtime()?.block_on(async {
52532×
            match command {
52623×
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
52723×
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
528
                }
5296×
                AgentCmd::List { json } => agent::list(&ctx, json).await,
5303×
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
531
            }
53232×
        }),
53329×
        Command::Admin { command } => runtime()?.block_on(async {
53429×
            match command {
53521×
                Admin::Verify { json } => admin::verify(&ctx, json).await,
5366×
                Admin::Check => admin::check(&ctx).await,
5372×
                Admin::Rebuild => admin::rebuild(&ctx).await,
538
            }
53929×
        }),
540
    }
541162×
}
542
5431×
fn version(as_json: bool) -> Result<i32> {
5441×
    if as_json {
5451×
        println!(
5461×
            "{}",
5471×
            serde_json::json!({
5481×
                "version": env!("CARGO_PKG_VERSION"),
5491×
                "build": env!("SPECTRA_BUILD_ID"),
5501×
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
5511×
                "ledger_format": spectra_core::LEDGER_FORMAT,
5521×
            })
5531×
        );
5541×
    } else {
5550×
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
5560×
    }
5571×
    Ok(0)
5581×
}