OXYGENHUB
# 8cfb9269bb14 spectra / src / cli / mod.rs
12.1 KiB · Rust · last changed Radar: Live, channels, threads and the log for the owner, signed in by a CLI-signed link; ledger writes survive a cancelled caller (#10) 2 days 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;
09752034 Andy McCall ↶ 8
pub mod identity;
258bb382 Andy McCall ↶ 9
pub mod json;
5d695d86 Andy McCall ↶ 10
pub mod marks;
eb675dfa Andy McCall ↶ 11
pub mod mcp;
39f5c913 Andy McCall ↶ 12
pub mod open;
2fc88a7b Andy McCall ↶ 13
pub mod project;
571572f5 Andy McCall ↶ 14
pub mod serve;
258bb382 Andy McCall ↶ 15
pub mod ui;
16
36537112 Andy McCall ↶ 17
use anyhow::{bail, Result};
258bb382 Andy McCall ↶ 18
use clap::{Parser, Subcommand};
36537112 Andy McCall ↶ 19
use std::path::PathBuf;
258bb382 Andy McCall ↶ 20
21
#[derive(Parser)]
22
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
23
pub struct Cli {
24
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
25
    #[arg(long, global = true)]
26
    pub plain: bool,
36537112 Andy McCall ↶ 27
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
28
    #[arg(long, global = true, env = "SPECTRA_DATA")]
29
    pub data: Option<PathBuf>,
258bb382 Andy McCall ↶ 30
    #[command(subcommand)]
31
    pub command: Command,
32
}
33
34
#[derive(Subcommand)]
35
pub enum Command {
36
    /// The version and build of this binary
37
    Version {
38
        /// Machine-readable output
39
        #[arg(long)]
40
        json: bool,
41
    },
09752034 Andy McCall ↶ 42
    /// Make this machine's Spectra: record the owner's identity (read from
43
    /// their OXY_HOME and proven by signing) and make the instance key
44
    Init {
45
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
46
        #[arg(long)]
47
        owner_home: Option<PathBuf>,
48
        /// This instance's name (default: the host name)
49
        #[arg(long)]
50
        name: Option<String>,
51
    },
52
    /// The owner's and the instance's identities: show, refresh
53
    Identity {
54
        #[command(subcommand)]
55
        command: IdentityCmd,
56
    },
571572f5 Andy McCall ↶ 57
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
58
    Serve {
59
        /// The loopback address and port for browsers and HTTP clients
60
        #[arg(long, default_value = "127.0.0.1:7350")]
61
        bind: String,
62
        /// Allow a non-loopback address in plain HTTP
63
        #[arg(long)]
64
        insecure: bool,
39f5c913 Andy McCall ↶ 65
        /// Open Radar in the browser once serving (signs a link as the owner)
66
        #[arg(long)]
67
        open: bool,
68
    },
69
    /// Sign in to Radar: a one-time link signed with the owner's key
70
    Open {
71
        #[arg(long)]
72
        owner_home: Option<PathBuf>,
73
        /// Print the link without opening a browser
74
        #[arg(long)]
75
        no_browser: bool,
571572f5 Andy McCall ↶ 76
    },
eb675dfa Andy McCall ↶ 77
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
78
    Mcp {
79
        /// The agent it speaks for
80
        #[arg(long)]
81
        agent: String,
82
        /// The agent's home (default: the one recorded when it was added)
83
        #[arg(long)]
84
        home: Option<PathBuf>,
85
    },
2fc88a7b Andy McCall ↶ 86
    /// The checkouts agents work in: add, list
87
    Project {
88
        #[command(subcommand)]
89
        command: ProjectCmd,
90
    },
09752034 Andy McCall ↶ 91
    /// Agents, each its owner's delegate: add, list, disable
92
    Agent {
93
        #[command(subcommand)]
94
        command: AgentCmd,
95
    },
36537112 Andy McCall ↶ 96
    /// The ledger's own checks: verify, check, rebuild
97
    Admin {
98
        #[command(subcommand)]
99
        command: Admin,
09752034 Andy McCall ↶ 100
    },
101
}
102
103
#[derive(Subcommand)]
104
pub enum IdentityCmd {
105
    /// The owner and the instance as the ledger knows them
106
    Show {
107
        #[arg(long)]
108
        json: bool,
109
    },
110
    /// Record the owner's current identity after `oxy identity delegate add|remove`
111
    Refresh {
112
        #[arg(long)]
113
        owner_home: Option<PathBuf>,
36537112 Andy McCall ↶ 114
    },
115
}
116
117
#[derive(Subcommand)]
2fc88a7b Andy McCall ↶ 118
pub enum ProjectCmd {
119
    /// Register a checkout: its repository id, remote and pinned subrepos
120
    Add {
121
        path: PathBuf,
122
        /// Its name (default: the directory's)
123
        #[arg(long)]
124
        name: Option<String>,
125
        /// Its remote URL (default: the checkout's origin)
126
        #[arg(long)]
127
        remote: Option<String>,
128
        #[arg(long)]
129
        owner_home: Option<PathBuf>,
130
    },
131
    /// Every project with what it pins, what pins it, and its agents
132
    List {
133
        #[arg(long)]
134
        json: bool,
135
    },
136
}
137
138
#[derive(Subcommand)]
09752034 Andy McCall ↶ 139
pub enum AgentCmd {
140
    /// Make an agent's key in its own home and record it once you delegate to it
141
    Add {
142
        /// Lower-case letters, digits and dashes
143
        name: String,
144
        /// claude-code, antigravity, kimi-code, codex or other
145
        #[arg(long)]
146
        kind: String,
147
        /// The project it works in
148
        #[arg(long)]
149
        project: String,
150
        /// The agent's own OXY_HOME, where its key is made and kept
151
        #[arg(long)]
152
        home: PathBuf,
153
        /// Bind the key to this Unix user: a request from another is refused
154
        #[arg(long)]
155
        uid: Option<u32>,
156
        /// Bind the key to the Unix user of its first signed connection
157
        #[arg(long, conflicts_with = "uid")]
158
        learn_uid: bool,
159
        /// How long to wait for your delegation, in seconds
160
        #[arg(long, default_value_t = 600)]
161
        wait: u64,
162
        /// The owner's OXY_HOME
163
        #[arg(long)]
164
        owner_home: Option<PathBuf>,
165
    },
166
    /// Every agent with its owner, key, delegation and state
167
    List {
168
        #[arg(long)]
169
        json: bool,
170
    },
171
    /// Refuse an agent's key from now on
172
    Disable {
173
        name: String,
174
        #[arg(long)]
175
        owner_home: Option<PathBuf>,
176
    },
177
}
178
179
#[derive(Subcommand)]
36537112 Andy McCall ↶ 180
pub enum Admin {
181
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
182
    Verify {
183
        /// Machine-readable output
184
        #[arg(long)]
185
        json: bool,
186
    },
187
    /// Whether the projections equal a rebuild from the ledger
188
    Check,
189
    /// Replay the ledger into every projection
190
    Rebuild,
191
}
192
193
/// What every command needs: where the data lives.
194
pub struct Ctx {
195
    pub data: PathBuf,
196
}
197
198
impl Ctx {
199
    /// The ledger's database, which must exist.
200
    pub fn db(&self) -> Result<PathBuf> {
201
        let db = self.data.join("spectra.db");
202
        if !db.exists() {
203
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
204
        }
205
        Ok(db)
206
    }
09752034 Andy McCall ↶ 207
208
    /// The ledger, for writing: the instance key signs its checkpoints, and
209
    /// the lock keeps any other writer out.
210
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
211
        let db = self.db()?;
212
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
213
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
571572f5 Andy McCall ↶ 214
    }
215
216
    /// The daemon's Unix socket.
217
    pub fn socket(&self) -> PathBuf {
218
        self.data.join("spectra.sock")
219
    }
220
221
    /// Somewhere to append the owner's signed events: straight to the
222
    /// ledger, or through the daemon when it is serving (spec §8.5).
223
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
224
        let db = self.db()?;
225
        if spectra_mcp::client::Client::alive(&self.socket()).await {
226
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
227
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
228
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
229
        }
230
        Ok(Writer::Direct(self.writer().await?))
09752034 Andy McCall ↶ 231
    }
232
233
    /// `[owner] home` from spectra.toml.
234
    pub fn config_owner_home(&self) -> Option<PathBuf> {
235
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
236
        let t: toml::Table = text.parse().ok()?;
237
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
238
    }
571572f5 Andy McCall ↶ 239
}
240
241
/// Where the owner's events go.
242
pub enum Writer {
243
    Direct(spectra_core::ledger::Ledger),
244
    Daemon { ledger: spectra_core::ledger::Ledger, client: Box<spectra_mcp::client::Client> },
36537112 Andy McCall ↶ 245
}
246
571572f5 Andy McCall ↶ 247
impl Writer {
248
    /// The ledger, for reading.
249
    pub fn ledger(&self) -> &spectra_core::ledger::Ledger {
250
        match self {
251
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => l,
252
        }
253
    }
254
255
    /// Append events signed over their payload, then checkpoint.
256
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
257
        match self {
258
            Writer::Direct(l) => {
259
                l.append(events).await?;
260
                l.checkpoint().await?;
261
            }
262
            Writer::Daemon { client, .. } => {
263
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
264
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
265
                if r.status != 200 {
266
                    let why = r
267
                        .json()
268
                        .ok()
269
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
270
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
271
                    bail!("the daemon refused: {why}");
272
                }
273
            }
274
        }
275
        Ok(())
276
    }
277
}
278
279
fn init_logging() {
280
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
281
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
282
}
283
36537112 Andy McCall ↶ 284
fn default_data() -> PathBuf {
285
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
286
        return PathBuf::from(x).join("spectra");
287
    }
288
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
289
    PathBuf::from(home).join(".local").join("share").join("spectra")
290
}
291
292
fn runtime() -> Result<tokio::runtime::Runtime> {
293
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
258bb382 Andy McCall ↶ 294
}
295
296
pub fn run() -> Result<i32> {
297
    let cli = Cli::parse();
298
    ui::init(cli.plain);
36537112 Andy McCall ↶ 299
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
258bb382 Andy McCall ↶ 300
    match cli.command {
301
        Command::Version { json } => version(json),
09752034 Andy McCall ↶ 302
        Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
303
        Command::Identity { command } => runtime()?.block_on(async {
304
            match command {
305
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
306
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
2fc88a7b Andy McCall ↶ 307
            }
308
        }),
39f5c913 Andy McCall ↶ 309
        Command::Serve { bind, insecure, open } => {
571572f5 Andy McCall ↶ 310
            init_logging();
39f5c913 Andy McCall ↶ 311
            runtime()?.block_on(serve::serve(&ctx, &bind, insecure, open))
571572f5 Andy McCall ↶ 312
        }
39f5c913 Andy McCall ↶ 313
        Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)),
eb675dfa Andy McCall ↶ 314
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
2fc88a7b Andy McCall ↶ 315
        Command::Project { command } => runtime()?.block_on(async {
316
            match command {
317
                ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await,
318
                ProjectCmd::List { json } => project::list(&ctx, json).await,
09752034 Andy McCall ↶ 319
            }
320
        }),
321
        Command::Agent { command } => runtime()?.block_on(async {
322
            match command {
323
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
324
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
325
                }
326
                AgentCmd::List { json } => agent::list(&ctx, json).await,
327
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
328
            }
329
        }),
36537112 Andy McCall ↶ 330
        Command::Admin { command } => runtime()?.block_on(async {
331
            match command {
332
                Admin::Verify { json } => admin::verify(&ctx, json).await,
333
                Admin::Check => admin::check(&ctx).await,
334
                Admin::Rebuild => admin::rebuild(&ctx).await,
335
            }
336
        }),
258bb382 Andy McCall ↶ 337
    }
338
}
339
340
fn version(as_json: bool) -> Result<i32> {
341
    if as_json {
342
        println!(
343
            "{}",
344
            serde_json::json!({
345
                "version": env!("CARGO_PKG_VERSION"),
346
                "build": env!("SPECTRA_BUILD_ID"),
347
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
348
                "ledger_format": spectra_core::LEDGER_FORMAT,
349
            })
350
        );
351
    } else {
352
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
353
    }
354
    Ok(0)
355
}