//! The `spectra` command line (spec §19). Output follows `oxy`: colour and //! glyphs on a UTF-8 terminal, plain when piped, with `--plain` or with //! `NO_COLOR`; `--json` prints one document on stdout and nothing else. pub mod admin; pub mod agent; pub mod format; pub mod human; pub mod identity; pub mod json; pub mod marks; pub mod mcp; pub mod open; pub mod project; pub mod serve; pub mod ui; use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; use std::path::PathBuf; #[derive(Parser)] #[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")] pub struct Cli { /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal) #[arg(long, global = true)] pub plain: bool, /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra) #[arg(long, global = true, env = "SPECTRA_DATA")] pub data: Option, #[command(subcommand)] pub command: Command, } #[derive(Subcommand)] pub enum Command { /// The version and build of this binary Version { /// Machine-readable output #[arg(long)] json: bool, }, /// Make this machine's Spectra: record the owner's identity (read from /// their OXY_HOME and proven by signing) and make the instance key Init { /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy) #[arg(long)] owner_home: Option, /// This instance's name (default: the host name) #[arg(long)] name: Option, }, /// The owner's and the instance's identities: show, refresh Identity { #[command(subcommand)] command: IdentityCmd, }, /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port Serve { /// The loopback address and port for browsers and HTTP clients #[arg(long, default_value = "127.0.0.1:7350")] bind: String, /// Allow a non-loopback address in plain HTTP #[arg(long)] insecure: bool, /// Open Radar in the browser once serving (signs a link as the owner) #[arg(long)] open: bool, }, /// Sign in to Radar: a one-time link signed with the owner's key Open { #[arg(long)] owner_home: Option, /// Print the link without opening a browser #[arg(long)] no_browser: bool, }, /// The stdio MCP server for one agent: signs with its key and forwards to the daemon Mcp { /// The agent it speaks for #[arg(long)] agent: String, /// The agent's home (default: the one recorded when it was added) #[arg(long)] home: Option, }, /// Post a message as yourself Say { /// What to say (Markdown; @name mentions) body: String, /// The channel (default: #general) #[arg(short, long)] channel: Option, /// Reply in the thread of this message #[arg(long)] reply_to: Option, #[arg(long)] json: bool, #[arg(long)] owner_home: Option, }, /// Your unread mentions and direct messages; --follow waits for more Inbox { #[arg(long)] follow: bool, #[arg(long)] json: bool, #[arg(long)] owner_home: Option, }, /// Search what was said: words, "phrases", prefix*, AND/OR/NOT Search { query: String, #[arg(long)] channel: Option, #[arg(long)] author: Option, /// 7d, 3h, 30m #[arg(long)] since: Option, #[arg(long, default_value_t = 20)] limit: u32, #[arg(long)] json: bool, }, /// Any API call, signed as the owner: JSON out Api { method: String, path: String, /// The request body (JSON) #[arg(short = 'd', long = "body")] body: Option, #[arg(long)] owner_home: Option, }, /// The checkouts agents work in: add, list Project { #[command(subcommand)] command: ProjectCmd, }, /// Agents, each its owner's delegate: add, list, disable Agent { #[command(subcommand)] command: AgentCmd, }, /// The ledger's own checks: verify, check, rebuild Admin { #[command(subcommand)] command: Admin, }, } #[derive(Subcommand)] pub enum IdentityCmd { /// The owner and the instance as the ledger knows them Show { #[arg(long)] json: bool, }, /// Record the owner's current identity after `oxy identity delegate add|remove` Refresh { #[arg(long)] owner_home: Option, }, } #[derive(Subcommand)] pub enum ProjectCmd { /// Register a checkout: its repository id, remote and pinned subrepos Add { path: PathBuf, /// Its name (default: the directory's) #[arg(long)] name: Option, /// Its remote URL (default: the checkout's origin) #[arg(long)] remote: Option, #[arg(long)] owner_home: Option, }, /// Every project with what it pins, what pins it, and its agents List { #[arg(long)] json: bool, }, } #[derive(Subcommand)] pub enum AgentCmd { /// Make an agent's key in its own home and record it once you delegate to it Add { /// Lower-case letters, digits and dashes name: String, /// claude-code, antigravity, kimi-code, codex or other #[arg(long)] kind: String, /// The project it works in #[arg(long)] project: String, /// The agent's own OXY_HOME, where its key is made and kept #[arg(long)] home: PathBuf, /// Bind the key to this Unix user: a request from another is refused #[arg(long)] uid: Option, /// Bind the key to the Unix user of its first signed connection #[arg(long, conflicts_with = "uid")] learn_uid: bool, /// How long to wait for your delegation, in seconds #[arg(long, default_value_t = 600)] wait: u64, /// The owner's OXY_HOME #[arg(long)] owner_home: Option, }, /// Every agent with its owner, key, delegation and state List { #[arg(long)] json: bool, }, /// Refuse an agent's key from now on Disable { name: String, #[arg(long)] owner_home: Option, }, } #[derive(Subcommand)] pub enum Admin { /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding) Verify { /// Machine-readable output #[arg(long)] json: bool, }, /// Whether the projections equal a rebuild from the ledger Check, /// Replay the ledger into every projection Rebuild, } /// What every command needs: where the data lives. pub struct Ctx { pub data: PathBuf, } impl Ctx { /// The ledger's database, which must exist. pub fn db(&self) -> Result { let db = self.data.join("spectra.db"); if !db.exists() { bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display()); } Ok(db) } /// The ledger, for writing: the instance key signs its checkpoints, and /// the lock keeps any other writer out. pub async fn writer(&self) -> Result { let db = self.db()?; let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?; spectra_core::ledger::Ledger::open(&db, Some(instance)).await } /// The daemon's Unix socket. pub fn socket(&self) -> PathBuf { self.data.join("spectra.sock") } /// Somewhere to append the owner's signed events: straight to the /// ledger, or through the daemon when it is serving (spec §8.5). pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result { let db = self.db()?; if spectra_mcp::client::Client::alive(&self.socket()).await { let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?; let transport = identity::recorded_owner(&ledger, owner_home).await?; return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) }); } Ok(Writer::Direct(self.writer().await?)) } /// `[owner] home` from spectra.toml. pub fn config_owner_home(&self) -> Option { let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?; let t: toml::Table = text.parse().ok()?; t.get("owner")?.get("home")?.as_str().map(PathBuf::from) } } /// Where the owner's events go. pub enum Writer { Direct(spectra_core::ledger::Ledger), Daemon { ledger: spectra_core::ledger::Ledger, client: Box }, } impl Writer { /// The ledger, for reading. pub fn ledger(&self) -> &spectra_core::ledger::Ledger { match self { Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => l, } } /// Append events signed over their payload, then checkpoint. pub async fn append(&self, events: Vec) -> Result<()> { match self { Writer::Direct(l) => { l.append(events).await?; l.checkpoint().await?; } Writer::Daemon { client, .. } => { let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect(); let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?; if r.status != 200 { let why = r .json() .ok() .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from)) .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into()); bail!("the daemon refused: {why}"); } } } Ok(()) } } fn init_logging() { let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")); tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init(); } fn default_data() -> PathBuf { if let Ok(x) = std::env::var("XDG_DATA_HOME") { return PathBuf::from(x).join("spectra"); } let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); PathBuf::from(home).join(".local").join("share").join("spectra") } fn runtime() -> Result { Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?) } pub fn run() -> Result { let cli = Cli::parse(); ui::init(cli.plain); let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) }; match cli.command { Command::Version { json } => version(json), Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)), Command::Identity { command } => runtime()?.block_on(async { match command { IdentityCmd::Show { json } => identity::show(&ctx, json).await, IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await, } }), Command::Serve { bind, insecure, open } => { init_logging(); runtime()?.block_on(serve::serve(&ctx, &bind, insecure, open)) } Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)), Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)), Command::Say { body, channel, reply_to, json, owner_home } => { runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json)) } Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)), Command::Search { query, channel, author, since, limit, json } => { runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json)) } Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)), Command::Project { command } => runtime()?.block_on(async { match command { ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await, ProjectCmd::List { json } => project::list(&ctx, json).await, } }), Command::Agent { command } => runtime()?.block_on(async { match command { AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => { agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await } AgentCmd::List { json } => agent::list(&ctx, json).await, AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await, } }), Command::Admin { command } => runtime()?.block_on(async { match command { Admin::Verify { json } => admin::verify(&ctx, json).await, Admin::Check => admin::check(&ctx).await, Admin::Rebuild => admin::rebuild(&ctx).await, } }), } } fn version(as_json: bool) -> Result { if as_json { println!( "{}", serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "build": env!("SPECTRA_BUILD_ID"), "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS, "ledger_format": spectra_core::LEDGER_FORMAT, }) ); } else { println!("spectra {}", env!("SPECTRA_VERSION_LONG")); } Ok(0) }