OXYGENHUB

Coverage / src/cli/mod.rs 83.0%

122 of 147 lines run, 20 of 52 functions · run #20 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, 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
    },
99
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
100
    Mcp {
101
        /// The agent it speaks for
102
        #[arg(long)]
103
        agent: String,
104
        /// The agent's home (default: the one recorded when it was added)
105
        #[arg(long)]
106
        home: Option<PathBuf>,
107
    },
108
    /// Post a message as yourself
109
    Say {
110
        /// What to say (Markdown; @name mentions)
111
        body: String,
112
        /// The channel (default: #general)
113
        #[arg(short, long)]
114
        channel: Option<String>,
115
        /// Reply in the thread of this message
116
        #[arg(long)]
117
        reply_to: Option<String>,
118
        #[arg(long)]
119
        json: bool,
120
        #[arg(long)]
121
        owner_home: Option<PathBuf>,
122
    },
123
    /// Your unread mentions and direct messages; --follow waits for more
124
    Inbox {
125
        #[arg(long)]
126
        follow: bool,
127
        #[arg(long)]
128
        json: bool,
129
        #[arg(long)]
130
        owner_home: Option<PathBuf>,
131
    },
132
    /// Search what was said: words, "phrases", prefix*, AND/OR/NOT
133
    Search {
134
        query: String,
135
        #[arg(long)]
136
        channel: Option<String>,
137
        #[arg(long)]
138
        author: Option<String>,
139
        /// 7d, 3h, 30m
140
        #[arg(long)]
141
        since: Option<String>,
142
        #[arg(long, default_value_t = 20)]
143
        limit: u32,
144
        #[arg(long)]
145
        json: bool,
146
    },
147
    /// Any API call, signed as the owner: JSON out
148
    Api {
149
        method: String,
150
        path: String,
151
        /// The request body (JSON)
152
        #[arg(short = 'd', long = "body")]
153
        body: Option<String>,
154
        #[arg(long)]
155
        owner_home: Option<PathBuf>,
156
    },
157
    /// The checkouts agents work in: add, list
158
    Project {
159
        #[command(subcommand)]
160
        command: ProjectCmd,
161
    },
162
    /// Agents, each its owner's delegate: add, list, disable
163
    Agent {
164
        #[command(subcommand)]
165
        command: AgentCmd,
166
    },
167
    /// The ledger's own checks: verify, check, rebuild
168
    Admin {
169
        #[command(subcommand)]
170
        command: Admin,
171
    },
172
}
173
174
#[derive(Subcommand)]
175
pub enum IdentityCmd {
176
    /// The owner and the instance as the ledger knows them
177
    Show {
178
        #[arg(long)]
179
        json: bool,
180
    },
181
    /// Record the owner's current identity after `oxy identity delegate add|remove`
182
    Refresh {
183
        #[arg(long)]
184
        owner_home: Option<PathBuf>,
185
    },
186
}
187
188
#[derive(Subcommand)]
189
pub enum ProjectCmd {
190
    /// Register a checkout: its repository id, remote and pinned subrepos
191
    Add {
192
        path: PathBuf,
193
        /// Its name (default: the directory's)
194
        #[arg(long)]
195
        name: Option<String>,
196
        /// Its remote URL (default: the checkout's origin)
197
        #[arg(long)]
198
        remote: Option<String>,
199
        #[arg(long)]
200
        owner_home: Option<PathBuf>,
201
    },
202
    /// Every project with what it pins, what pins it, and its agents
203
    List {
204
        #[arg(long)]
205
        json: bool,
206
    },
207
}
208
209
#[derive(Subcommand)]
210
pub enum AgentCmd {
211
    /// Make an agent's key in its own home and record it once you delegate to it
212
    Add {
213
        /// Lower-case letters, digits and dashes
214
        name: String,
215
        /// claude-code, antigravity, kimi-code, codex or other
216
        #[arg(long)]
217
        kind: String,
218
        /// The project it works in
219
        #[arg(long)]
220
        project: String,
221
        /// The agent's own OXY_HOME, where its key is made and kept
222
        #[arg(long)]
223
        home: PathBuf,
224
        /// Bind the key to this Unix user: a request from another is refused
225
        #[arg(long)]
226
        uid: Option<u32>,
227
        /// Bind the key to the Unix user of its first signed connection
228
        #[arg(long, conflicts_with = "uid")]
229
        learn_uid: bool,
230
        /// How long to wait for your delegation, in seconds
231
        #[arg(long, default_value_t = 600)]
232
        wait: u64,
233
        /// The owner's OXY_HOME
234
        #[arg(long)]
235
        owner_home: Option<PathBuf>,
236
    },
237
    /// Every agent with its owner, key, delegation and state
238
    List {
239
        #[arg(long)]
240
        json: bool,
241
    },
242
    /// Refuse an agent's key from now on
243
    Disable {
244
        name: String,
245
        #[arg(long)]
246
        owner_home: Option<PathBuf>,
247
    },
248
}
249
250
#[derive(Subcommand)]
251
pub enum Admin {
252
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
253
    Verify {
254
        /// Machine-readable output
255
        #[arg(long)]
256
        json: bool,
257
    },
258
    /// Whether the projections equal a rebuild from the ledger
259
    Check,
260
    /// Replay the ledger into every projection
261
    Rebuild,
262
}
263
264
/// What every command needs: where the data lives.
265
pub struct Ctx {
266
    pub data: PathBuf,
267
}
268
269
impl Ctx {
270
    /// The ledger's database, which must exist.
271148×
    pub fn db(&self) -> Result<PathBuf> {
272148×
        let db = self.data.join("spectra.db");
273148×
        if !db.exists() {
2741×
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
275147×
        }
276147×
        Ok(db)
277148×
    }
278
279
    /// The ledger, for writing: the instance key signs its checkpoints, and
280
    /// the lock keeps any other writer out.
28150×
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
28250×
        let db = self.db()?;
28350×
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
28450×
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
28550×
    }
286
287
    /// The daemon's Unix socket.
28869×
    pub fn socket(&self) -> PathBuf {
28969×
        self.data.join("spectra.sock")
29069×
    }
291
292
    /// The Spectra on another machine this one uses, if any (spec §8.6).
2931×
    pub fn remote(&self) -> Result<Option<connect::ClientCfg>> {
2941×
        connect::load()
2951×
    }
296
297
    /// Where the daemon is: the configured server, else the local socket.
2980×
    pub fn endpoint(&self) -> Result<spectra_mcp::client::Endpoint> {
2990×
        Ok(match self.remote()? {
3000×
            Some(c) => c.endpoint()?,
3010×
            None => spectra_mcp::client::Endpoint::Unix(self.socket()),
302
        })
3030×
    }
304
305
    /// Somewhere to append the owner's signed events: straight to the
306
    /// ledger, or through the daemon when it is serving (spec §8.5).
30744×
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
30844×
        let db = self.db()?;
30944×
        if spectra_mcp::client::Client::alive(&self.socket()).await {
3102×
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
3112×
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
3122×
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
31342×
        }
31442×
        Ok(Writer::Direct(self.writer().await?))
31544×
    }
316
317
    /// `[owner] home` from spectra.toml.
31871×
    pub fn config_owner_home(&self) -> Option<PathBuf> {
31971×
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
32050×
        let t: toml::Table = text.parse().ok()?;
32150×
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
32271×
    }
323
}
324
325
/// Where the owner's events go.
326
pub enum Writer {
327
    Direct(spectra_core::ledger::Ledger),
328
    Daemon { ledger: spectra_core::ledger::Ledger, client: Box<spectra_mcp::client::Client> },
329
}
330
331
impl Writer {
332
    /// The ledger, for reading.
33344×
    pub fn ledger(&self) -> &spectra_core::ledger::Ledger {
33444×
        match self {
33544×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => l,
336
        }
33744×
    }
338
339
    /// Append events signed over their payload, then checkpoint.
34042×
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
34142×
        match self {
34240×
            Writer::Direct(l) => {
34340×
                l.append(events).await?;
34440×
                l.checkpoint().await?;
345
            }
3462×
            Writer::Daemon { client, .. } => {
3472×
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
3482×
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
3492×
                if r.status != 200 {
3500×
                    let why = r
3510×
                        .json()
3520×
                        .ok()
3530×
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
3540×
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
3550×
                    bail!("the daemon refused: {why}");
3562×
                }
357
            }
358
        }
35942×
        Ok(())
36042×
    }
361
}
362
36318×
fn init_logging() {
36418×
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
36518×
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
36618×
}
367
3680×
fn default_data() -> PathBuf {
3690×
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
3700×
        return PathBuf::from(x).join("spectra");
3710×
    }
3720×
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
3730×
    PathBuf::from(home).join(".local").join("share").join("spectra")
3740×
}
375
376137×
fn runtime() -> Result<tokio::runtime::Runtime> {
377137×
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
378137×
}
379
380138×
pub fn run() -> Result<i32> {
381138×
    let cli = Cli::parse();
382138×
    ui::init(cli.plain);
383138×
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
38426×
    match cli.command {
3851×
        Command::Version { json } => version(json),
3865×
        Command::Connect { url, owner_home } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home)),
3874×
        Command::Init { owner_home, name, owner: Some(key) } => {
3884×
            let _ = owner_home;
3894×
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
390
        }
39122×
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
3921×
        Command::Identity { command } => runtime()?.block_on(async {
3931×
            match command {
3941×
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
3950×
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
396
            }
3971×
        }),
39818×
        Command::Serve { bind, tls, insecure, open } => {
39918×
            init_logging();
40018×
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
401
        }
4021×
        Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)),
4032×
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
4042×
        Command::Say { body, channel, reply_to, json, owner_home } => {
4052×
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
406
        }
4071×
        Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)),
4081×
        Command::Search { query, channel, author, since, limit, json } => {
4091×
            runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json))
410
        }
4111×
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
41226×
        Command::Project { command } => runtime()?.block_on(async {
41326×
            match command {
41423×
                ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await,
4153×
                ProjectCmd::List { json } => project::list(&ctx, json).await,
416
            }
41726×
        }),
41828×
        Command::Agent { command } => runtime()?.block_on(async {
41928×
            match command {
42021×
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
42121×
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
422
                }
4235×
                AgentCmd::List { json } => agent::list(&ctx, json).await,
4242×
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
425
            }
42628×
        }),
42725×
        Command::Admin { command } => runtime()?.block_on(async {
42825×
            match command {
42918×
                Admin::Verify { json } => admin::verify(&ctx, json).await,
4305×
                Admin::Check => admin::check(&ctx).await,
4312×
                Admin::Rebuild => admin::rebuild(&ctx).await,
432
            }
43325×
        }),
434
    }
435138×
}
436
4371×
fn version(as_json: bool) -> Result<i32> {
4381×
    if as_json {
4391×
        println!(
4401×
            "{}",
4411×
            serde_json::json!({
4421×
                "version": env!("CARGO_PKG_VERSION"),
4431×
                "build": env!("SPECTRA_BUILD_ID"),
4441×
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
4451×
                "ledger_format": spectra_core::LEDGER_FORMAT,
4461×
            })
4471×
        );
4481×
    } else {
4490×
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
4500×
    }
4511×
    Ok(0)
4521×
}