//! The `spectra` command line (spec §19). Output is modern: 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 connect; pub mod format; pub mod human; pub mod identity; pub mod marks; pub mod mcp; pub mod open; pub mod project; pub mod serve; pub mod ui; use anyhow::{bail, Context, 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 identity home (default: $SPECTRA_HOME, else ~/.config/spectra) #[arg(long)] owner_home: Option, /// This instance's name (default: the host name) #[arg(long)] name: Option, /// Set up a server without the owner's secret key: name the owner's /// KeyId, and they claim it from their machine with `spectra /// connect` (spec §8.6) #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")] owner: Option, }, /// Use a Spectra on another machine: trust it through your delegation to /// its instance key, pin its certificate, and claim it if it is new Connect { /// The server, https://HOST[:PORT] url: String, /// Trust the server if its instance key starts with this, without asking #[arg(long, value_name = "KEYID-PREFIX")] expect: Option, /// Trust it without asking (for scripts) #[arg(long, short = 'y')] yes: bool, /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra) #[arg(long)] owner_home: 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 address and port for browsers and HTTP clients (default: /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other /// machines connect over TLS (spec §8.6) #[arg(long)] bind: Option, /// TLS on the TCP port: auto (beyond loopback only), on, or off /// (default: `[server] tls`, else auto) #[arg(long)] tls: Option, /// 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, /// With Spectra on another machine: the loopback port of the proxy /// Radar is served through (spec §8.6) #[arg(long, default_value_t = 7350)] port: u16, }, /// 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 { /// Make your own identity: a key pair that signs as you (spec §8.0) Create { /// Your name, as agents and Radar show it #[arg(long)] name: String, /// An email or URL, if you want one shown #[arg(long, default_value = "")] contact: String, /// Where to keep it (default: $SPECTRA_HOME, else ~/.config/spectra) #[arg(long)] home: Option, }, /// The owner and the instance as the ledger knows them Show { #[arg(long)] json: bool, }, /// Record the owner's current identity if the ledger has missed a successor Refresh { #[arg(long)] owner_home: Option, }, } #[derive(Subcommand)] pub enum ProjectCmd { /// Add a project: any kind of work, with a folder if it has one Add { /// A folder (its name becomes the project's), or just a name target: String, /// Its name, when TARGET is a folder #[arg(long)] name: Option, /// The work's folder, when TARGET is a name; Spectra never reads it #[arg(long)] path: Option, /// What the work is, for agents and Radar #[arg(long, short = 'd', default_value = "")] description: String, /// A link to it, if any #[arg(long)] url: Option, #[arg(long)] owner_home: Option, }, /// Say that one project depends on another (`delegate to: downstream` follows links) Link { project: String, #[arg(long)] depends_on: String, /// Remove the link instead #[arg(long)] remove: bool, #[arg(long)] owner_home: Option, }, /// Every project with its links and agents List { #[arg(long)] json: bool, }, } #[derive(Subcommand)] pub enum AgentCmd { /// Make an agent's key in its own home, delegate to it, and record 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 identity 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 the agent may act for you: 30m, 8h, 7d (default: until disabled) #[arg(long)] expires: Option, /// Delegate without asking (for scripts) #[arg(long, short = 'y')] yes: bool, /// The owner's identity home #[arg(long)] owner_home: Option, }, /// Give an agent more time: delegate to its key again with a new expiry Renew { name: String, /// 30m, 8h, 7d, or `never` #[arg(long)] expires: String, #[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") } /// The Spectra on another machine this one uses, if any (spec §8.6). pub fn remote(&self) -> Result> { connect::load() } /// Where the daemon is: the configured server, else the local socket. pub fn endpoint(&self) -> Result { Ok(match self.remote()? { Some(c) => c.endpoint()?, None => spectra_mcp::client::Endpoint::Unix(self.socket()), }) } /// A client signing as the owner: to the configured server (spec §8.6), /// or to the local daemon when it is serving; none when neither. It /// signs with the owner identity the ledger records, since the daemon /// may not know a newer one yet. pub async fn owner_client(&self, owner_home: &std::path::Path) -> Result> { use spectra_mcp::client::Client; if let Some(cfg) = self.remote()? { let ep = cfg.endpoint()?; let pair = remote_owner(&ep, owner_home).await?; return Ok(Some(Client::at(ep, Some(pair)))); } if !self.data.join("spectra.db").exists() || !Client::alive(&self.socket()).await { return Ok(None); } let ledger = spectra_core::ledger::Ledger::open_read_only(&self.db()?).await?; Ok(Some(Client::new(&self.socket(), Some(identity::recorded_owner(&ledger, owner_home).await?)))) } /// Somewhere to append the owner's signed events: the configured server, /// the local daemon when it is serving, else straight to the ledger /// (spec §8.5, §8.6). pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result { if let Some(cfg) = self.remote()? { let ep = cfg.endpoint()?; let pair = remote_owner(&ep, owner_home).await?; let recorded = pair.identity.clone(); return Ok(Writer::Remote { client: Box::new(spectra_mcp::client::Client::at(ep, Some(pair))), recorded }); } 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?)) } /// Refuse a command that needs the ledger file on this machine. pub fn local_only(&self, what: &str) -> Result<()> { if self.remote()?.is_some() { bail!("{what} runs on the server, which holds the ledger (this machine uses {})", connect::path().display()); } Ok(()) } /// `[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, }, /// A Spectra on another machine; `recorded` is the owner identity it knows. Remote { client: Box, recorded: spectra_core::identity::Identity, }, } impl Writer { /// The ledger on this machine, for reading, if there is one. pub fn ledger(&self) -> Option<&spectra_core::ledger::Ledger> { match self { Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => Some(l), Writer::Remote { .. } => None, } } /// A client of the daemon, when one is serving. pub fn client(&self) -> Option<&spectra_mcp::client::Client> { match self { Writer::Daemon { client, .. } | Writer::Remote { client, .. } => Some(client), Writer::Direct(_) => None, } } /// The rotation that brings the recorded owner to the current identity /// in `home`, if it has moved. pub async fn refresh_event(&self, home: &std::path::Path) -> Result> { match self { Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => identity::refresh_event(l, home).await, Writer::Remote { recorded, .. } => identity::rotation(recorded, home), } } /// 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, .. } | Writer::Remote { 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(()) } } /// The owner identity a server records, as a key pair from the owner's /// home: what requests to it are signed with. async fn remote_owner(ep: &spectra_mcp::client::Endpoint, home: &std::path::Path) -> Result { let r = spectra_mcp::client::Client::at(ep.clone(), None).send("GET", "/api/v1/instance", &[], vec![]).await?; let info = r.json()?; let key = info.get("owner").and_then(|o| o.as_str()).context("the server has no owner yet: spectra connect claims it")?; let keys = home.join("keys"); spectra_core::keys::KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity"))) .with_context(|| format!("the server's owner {} is not an identity in {}", &key[..12.min(key.len())], home.display())) } /// An API call; a refusal becomes an error that says why. pub async fn call(c: &spectra_mcp::client::Client, method: &str, path: &str, body: Option) -> Result { let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?; let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null); if r.status >= 400 { let why = v .get("detail") .or(v.get("title")) .and_then(serde_json::Value::as_str) .map(String::from) .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into()); bail!("{why}"); } Ok(v) } /// A query string value, percent-encoded. pub fn encode(s: &str) -> String { s.bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), _ => format!("%{b:02X}"), }) .collect() } 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::Connect { url, owner_home, expect, yes } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home, expect, yes)), Command::Init { owner_home, name, owner: Some(key) } => { let _ = owner_home; runtime()?.block_on(identity::init_for(&ctx, &key, name)) } Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)), Command::Identity { command } => runtime()?.block_on(async { match command { IdentityCmd::Create { name, contact, home } => identity::create(&name, &contact, home), IdentityCmd::Show { json } => identity::show(&ctx, json).await, IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await, } }), Command::Serve { bind, tls, insecure, open } => { init_logging(); runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open)) } Command::Open { owner_home, no_browser, port } => runtime()?.block_on(async { if ctx.remote()?.is_some() { open::open_remote(&ctx, owner_home, !no_browser, port).await } else { open::open(&ctx, owner_home, !no_browser).await } }), 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 { target, name, path, description, url, owner_home } => { project::add(&ctx, project::Add { target, name, path, description, url }, owner_home).await } ProjectCmd::Link { project, depends_on, remove, owner_home } => { project::link(&ctx, &project, &depends_on, remove, 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, expires, yes, owner_home } => { agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home }).await } AgentCmd::Renew { name, expires, owner_home } => agent::renew(&ctx, &name, &expires, 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) }