OXYGENHUB

Coverage / src/cli/mod.rs 85.5%

118 of 138 lines run, 19 of 48 functions · run #19 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 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
        /// 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>,
57
    },
58
    /// The owner's and the instance's identities: show, refresh
59
    Identity {
60
        #[command(subcommand)]
61
        command: IdentityCmd,
62
    },
63
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
64
    Serve {
65
        /// The address and port for browsers and HTTP clients (default:
66
        /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other
67
        /// machines connect over TLS (spec §8.6)
68
        #[arg(long)]
69
        bind: Option<String>,
70
        /// TLS on the TCP port: auto (beyond loopback only), on, or off
71
        /// (default: `[server] tls`, else auto)
72
        #[arg(long)]
73
        tls: Option<String>,
74
        /// Allow a non-loopback address in plain HTTP
75
        #[arg(long)]
76
        insecure: bool,
77
        /// Open Radar in the browser once serving (signs a link as the owner)
78
        #[arg(long)]
79
        open: bool,
80
    },
81
    /// Sign in to Radar: a one-time link signed with the owner's key
82
    Open {
83
        #[arg(long)]
84
        owner_home: Option<PathBuf>,
85
        /// Print the link without opening a browser
86
        #[arg(long)]
87
        no_browser: bool,
88
    },
89
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
90
    Mcp {
91
        /// The agent it speaks for
92
        #[arg(long)]
93
        agent: String,
94
        /// The agent's home (default: the one recorded when it was added)
95
        #[arg(long)]
96
        home: Option<PathBuf>,
97
    },
98
    /// Post a message as yourself
99
    Say {
100
        /// What to say (Markdown; @name mentions)
101
        body: String,
102
        /// The channel (default: #general)
103
        #[arg(short, long)]
104
        channel: Option<String>,
105
        /// Reply in the thread of this message
106
        #[arg(long)]
107
        reply_to: Option<String>,
108
        #[arg(long)]
109
        json: bool,
110
        #[arg(long)]
111
        owner_home: Option<PathBuf>,
112
    },
113
    /// Your unread mentions and direct messages; --follow waits for more
114
    Inbox {
115
        #[arg(long)]
116
        follow: bool,
117
        #[arg(long)]
118
        json: bool,
119
        #[arg(long)]
120
        owner_home: Option<PathBuf>,
121
    },
122
    /// Search what was said: words, "phrases", prefix*, AND/OR/NOT
123
    Search {
124
        query: String,
125
        #[arg(long)]
126
        channel: Option<String>,
127
        #[arg(long)]
128
        author: Option<String>,
129
        /// 7d, 3h, 30m
130
        #[arg(long)]
131
        since: Option<String>,
132
        #[arg(long, default_value_t = 20)]
133
        limit: u32,
134
        #[arg(long)]
135
        json: bool,
136
    },
137
    /// Any API call, signed as the owner: JSON out
138
    Api {
139
        method: String,
140
        path: String,
141
        /// The request body (JSON)
142
        #[arg(short = 'd', long = "body")]
143
        body: Option<String>,
144
        #[arg(long)]
145
        owner_home: Option<PathBuf>,
146
    },
147
    /// The checkouts agents work in: add, list
148
    Project {
149
        #[command(subcommand)]
150
        command: ProjectCmd,
151
    },
152
    /// Agents, each its owner's delegate: add, list, disable
153
    Agent {
154
        #[command(subcommand)]
155
        command: AgentCmd,
156
    },
157
    /// The ledger's own checks: verify, check, rebuild
158
    Admin {
159
        #[command(subcommand)]
160
        command: Admin,
161
    },
162
}
163
164
#[derive(Subcommand)]
165
pub enum IdentityCmd {
166
    /// The owner and the instance as the ledger knows them
167
    Show {
168
        #[arg(long)]
169
        json: bool,
170
    },
171
    /// Record the owner's current identity after `oxy identity delegate add|remove`
172
    Refresh {
173
        #[arg(long)]
174
        owner_home: Option<PathBuf>,
175
    },
176
}
177
178
#[derive(Subcommand)]
179
pub enum ProjectCmd {
180
    /// Register a checkout: its repository id, remote and pinned subrepos
181
    Add {
182
        path: PathBuf,
183
        /// Its name (default: the directory's)
184
        #[arg(long)]
185
        name: Option<String>,
186
        /// Its remote URL (default: the checkout's origin)
187
        #[arg(long)]
188
        remote: Option<String>,
189
        #[arg(long)]
190
        owner_home: Option<PathBuf>,
191
    },
192
    /// Every project with what it pins, what pins it, and its agents
193
    List {
194
        #[arg(long)]
195
        json: bool,
196
    },
197
}
198
199
#[derive(Subcommand)]
200
pub enum AgentCmd {
201
    /// Make an agent's key in its own home and record it once you delegate to it
202
    Add {
203
        /// Lower-case letters, digits and dashes
204
        name: String,
205
        /// claude-code, antigravity, kimi-code, codex or other
206
        #[arg(long)]
207
        kind: String,
208
        /// The project it works in
209
        #[arg(long)]
210
        project: String,
211
        /// The agent's own OXY_HOME, where its key is made and kept
212
        #[arg(long)]
213
        home: PathBuf,
214
        /// Bind the key to this Unix user: a request from another is refused
215
        #[arg(long)]
216
        uid: Option<u32>,
217
        /// Bind the key to the Unix user of its first signed connection
218
        #[arg(long, conflicts_with = "uid")]
219
        learn_uid: bool,
220
        /// How long to wait for your delegation, in seconds
221
        #[arg(long, default_value_t = 600)]
222
        wait: u64,
223
        /// The owner's OXY_HOME
224
        #[arg(long)]
225
        owner_home: Option<PathBuf>,
226
    },
227
    /// Every agent with its owner, key, delegation and state
228
    List {
229
        #[arg(long)]
230
        json: bool,
231
    },
232
    /// Refuse an agent's key from now on
233
    Disable {
234
        name: String,
235
        #[arg(long)]
236
        owner_home: Option<PathBuf>,
237
    },
238
}
239
240
#[derive(Subcommand)]
241
pub enum Admin {
242
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
243
    Verify {
244
        /// Machine-readable output
245
        #[arg(long)]
246
        json: bool,
247
    },
248
    /// Whether the projections equal a rebuild from the ledger
249
    Check,
250
    /// Replay the ledger into every projection
251
    Rebuild,
252
}
253
254
/// What every command needs: where the data lives.
255
pub struct Ctx {
256
    pub data: PathBuf,
257
}
258
259
impl Ctx {
260
    /// The ledger's database, which must exist.
261147×
    pub fn db(&self) -> Result<PathBuf> {
262147×
        let db = self.data.join("spectra.db");
263147×
        if !db.exists() {
2641×
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
265146×
        }
266146×
        Ok(db)
267147×
    }
268
269
    /// The ledger, for writing: the instance key signs its checkpoints, and
270
    /// the lock keeps any other writer out.
27150×
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
27250×
        let db = self.db()?;
27350×
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
27450×
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
27550×
    }
276
277
    /// The daemon's Unix socket.
27866×
    pub fn socket(&self) -> PathBuf {
27966×
        self.data.join("spectra.sock")
28066×
    }
281
282
    /// Somewhere to append the owner's signed events: straight to the
283
    /// ledger, or through the daemon when it is serving (spec §8.5).
28444×
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
28544×
        let db = self.db()?;
28644×
        if spectra_mcp::client::Client::alive(&self.socket()).await {
2872×
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
2882×
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
2892×
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
29042×
        }
29142×
        Ok(Writer::Direct(self.writer().await?))
29244×
    }
293
294
    /// `[owner] home` from spectra.toml.
29571×
    pub fn config_owner_home(&self) -> Option<PathBuf> {
29671×
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
29750×
        let t: toml::Table = text.parse().ok()?;
29850×
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
29971×
    }
300
}
301
302
/// Where the owner's events go.
303
pub enum Writer {
304
    Direct(spectra_core::ledger::Ledger),
305
    Daemon { ledger: spectra_core::ledger::Ledger, client: Box<spectra_mcp::client::Client> },
306
}
307
308
impl Writer {
309
    /// The ledger, for reading.
31044×
    pub fn ledger(&self) -> &spectra_core::ledger::Ledger {
31144×
        match self {
31244×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => l,
313
        }
31444×
    }
315
316
    /// Append events signed over their payload, then checkpoint.
31742×
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
31842×
        match self {
31940×
            Writer::Direct(l) => {
32040×
                l.append(events).await?;
32140×
                l.checkpoint().await?;
322
            }
3232×
            Writer::Daemon { client, .. } => {
3242×
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
3252×
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
3262×
                if r.status != 200 {
3270×
                    let why = r
3280×
                        .json()
3290×
                        .ok()
3300×
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
3310×
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
3320×
                    bail!("the daemon refused: {why}");
3332×
                }
334
            }
335
        }
33642×
        Ok(())
33742×
    }
338
}
339
34015×
fn init_logging() {
34115×
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
34215×
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
34315×
}
344
3450×
fn default_data() -> PathBuf {
3460×
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
3470×
        return PathBuf::from(x).join("spectra");
3480×
    }
3490×
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
3500×
    PathBuf::from(home).join(".local").join("share").join("spectra")
3510×
}
352
353126×
fn runtime() -> Result<tokio::runtime::Runtime> {
354126×
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
355126×
}
356
357127×
pub fn run() -> Result<i32> {
358127×
    let cli = Cli::parse();
359127×
    ui::init(cli.plain);
360127×
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
36124×
    match cli.command {
3621×
        Command::Version { json } => version(json),
3632×
        Command::Init { owner_home, name, owner: Some(key) } => {
3642×
            let _ = owner_home;
3652×
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
366
        }
36722×
        Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
3681×
        Command::Identity { command } => runtime()?.block_on(async {
3691×
            match command {
3701×
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
3710×
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
372
            }
3731×
        }),
37415×
        Command::Serve { bind, tls, insecure, open } => {
37515×
            init_logging();
37615×
            runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open))
377
        }
3781×
        Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)),
3792×
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
3802×
        Command::Say { body, channel, reply_to, json, owner_home } => {
3812×
            runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json))
382
        }
3831×
        Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)),
3841×
        Command::Search { query, channel, author, since, limit, json } => {
3851×
            runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json))
386
        }
3871×
        Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)),
38826×
        Command::Project { command } => runtime()?.block_on(async {
38926×
            match command {
39023×
                ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await,
3913×
                ProjectCmd::List { json } => project::list(&ctx, json).await,
392
            }
39326×
        }),
39428×
        Command::Agent { command } => runtime()?.block_on(async {
39528×
            match command {
39621×
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
39721×
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
398
                }
3995×
                AgentCmd::List { json } => agent::list(&ctx, json).await,
4002×
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
401
            }
40228×
        }),
40324×
        Command::Admin { command } => runtime()?.block_on(async {
40424×
            match command {
40517×
                Admin::Verify { json } => admin::verify(&ctx, json).await,
4065×
                Admin::Check => admin::check(&ctx).await,
4072×
                Admin::Rebuild => admin::rebuild(&ctx).await,
408
            }
40924×
        }),
410
    }
411127×
}
412
4131×
fn version(as_json: bool) -> Result<i32> {
4141×
    if as_json {
4151×
        println!(
4161×
            "{}",
4171×
            serde_json::json!({
4181×
                "version": env!("CARGO_PKG_VERSION"),
4191×
                "build": env!("SPECTRA_BUILD_ID"),
4201×
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
4211×
                "ledger_format": spectra_core::LEDGER_FORMAT,
4221×
            })
4231×
        );
4241×
    } else {
4250×
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
4260×
    }
4271×
    Ok(0)
4281×
}