OXYGENHUB

Coverage / src/cli/mod.rs 68.5%

87 of 127 lines run, 17 of 48 functions · run #9 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 identity;
9
pub mod json;
10
pub mod marks;
11
pub mod project;
12
pub mod serve;
13
pub mod ui;
14
15
use anyhow::{bail, Result};
16
use clap::{Parser, Subcommand};
17
use std::path::PathBuf;
18
19
#[derive(Parser)]
20
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
21
pub struct Cli {
22
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
23
    #[arg(long, global = true)]
24
    pub plain: bool,
25
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
26
    #[arg(long, global = true, env = "SPECTRA_DATA")]
27
    pub data: Option<PathBuf>,
28
    #[command(subcommand)]
29
    pub command: Command,
30
}
31
32
#[derive(Subcommand)]
33
pub enum Command {
34
    /// The version and build of this binary
35
    Version {
36
        /// Machine-readable output
37
        #[arg(long)]
38
        json: bool,
39
    },
40
    /// Make this machine's Spectra: record the owner's identity (read from
41
    /// their OXY_HOME and proven by signing) and make the instance key
42
    Init {
43
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
44
        #[arg(long)]
45
        owner_home: Option<PathBuf>,
46
        /// This instance's name (default: the host name)
47
        #[arg(long)]
48
        name: Option<String>,
49
    },
50
    /// The owner's and the instance's identities: show, refresh
51
    Identity {
52
        #[command(subcommand)]
53
        command: IdentityCmd,
54
    },
55
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
56
    Serve {
57
        /// The loopback address and port for browsers and HTTP clients
58
        #[arg(long, default_value = "127.0.0.1:7350")]
59
        bind: String,
60
        /// Allow a non-loopback address in plain HTTP
61
        #[arg(long)]
62
        insecure: bool,
63
    },
64
    /// The checkouts agents work in: add, list
65
    Project {
66
        #[command(subcommand)]
67
        command: ProjectCmd,
68
    },
69
    /// Agents, each its owner's delegate: add, list, disable
70
    Agent {
71
        #[command(subcommand)]
72
        command: AgentCmd,
73
    },
74
    /// The ledger's own checks: verify, check, rebuild
75
    Admin {
76
        #[command(subcommand)]
77
        command: Admin,
78
    },
79
}
80
81
#[derive(Subcommand)]
82
pub enum IdentityCmd {
83
    /// The owner and the instance as the ledger knows them
84
    Show {
85
        #[arg(long)]
86
        json: bool,
87
    },
88
    /// Record the owner's current identity after `oxy identity delegate add|remove`
89
    Refresh {
90
        #[arg(long)]
91
        owner_home: Option<PathBuf>,
92
    },
93
}
94
95
#[derive(Subcommand)]
96
pub enum ProjectCmd {
97
    /// Register a checkout: its repository id, remote and pinned subrepos
98
    Add {
99
        path: PathBuf,
100
        /// Its name (default: the directory's)
101
        #[arg(long)]
102
        name: Option<String>,
103
        /// Its remote URL (default: the checkout's origin)
104
        #[arg(long)]
105
        remote: Option<String>,
106
        #[arg(long)]
107
        owner_home: Option<PathBuf>,
108
    },
109
    /// Every project with what it pins, what pins it, and its agents
110
    List {
111
        #[arg(long)]
112
        json: bool,
113
    },
114
}
115
116
#[derive(Subcommand)]
117
pub enum AgentCmd {
118
    /// Make an agent's key in its own home and record it once you delegate to it
119
    Add {
120
        /// Lower-case letters, digits and dashes
121
        name: String,
122
        /// claude-code, antigravity, kimi-code, codex or other
123
        #[arg(long)]
124
        kind: String,
125
        /// The project it works in
126
        #[arg(long)]
127
        project: String,
128
        /// The agent's own OXY_HOME, where its key is made and kept
129
        #[arg(long)]
130
        home: PathBuf,
131
        /// Bind the key to this Unix user: a request from another is refused
132
        #[arg(long)]
133
        uid: Option<u32>,
134
        /// Bind the key to the Unix user of its first signed connection
135
        #[arg(long, conflicts_with = "uid")]
136
        learn_uid: bool,
137
        /// How long to wait for your delegation, in seconds
138
        #[arg(long, default_value_t = 600)]
139
        wait: u64,
140
        /// The owner's OXY_HOME
141
        #[arg(long)]
142
        owner_home: Option<PathBuf>,
143
    },
144
    /// Every agent with its owner, key, delegation and state
145
    List {
146
        #[arg(long)]
147
        json: bool,
148
    },
149
    /// Refuse an agent's key from now on
150
    Disable {
151
        name: String,
152
        #[arg(long)]
153
        owner_home: Option<PathBuf>,
154
    },
155
}
156
157
#[derive(Subcommand)]
158
pub enum Admin {
159
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
160
    Verify {
161
        /// Machine-readable output
162
        #[arg(long)]
163
        json: bool,
164
    },
165
    /// Whether the projections equal a rebuild from the ledger
166
    Check,
167
    /// Replay the ledger into every projection
168
    Rebuild,
169
}
170
171
/// What every command needs: where the data lives.
172
pub struct Ctx {
173
    pub data: PathBuf,
174
}
175
176
impl Ctx {
177
    /// The ledger's database, which must exist.
17882×
    pub fn db(&self) -> Result<PathBuf> {
17982×
        let db = self.data.join("spectra.db");
18082×
        if !db.exists() {
1811×
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
18281×
        }
18381×
        Ok(db)
18482×
    }
185
186
    /// The ledger, for writing: the instance key signs its checkpoints, and
187
    /// the lock keeps any other writer out.
18828×
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
18928×
        let db = self.db()?;
19028×
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
19128×
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
19228×
    }
193
194
    /// The daemon's Unix socket.
19533×
    pub fn socket(&self) -> PathBuf {
19633×
        self.data.join("spectra.sock")
19733×
    }
198
199
    /// Somewhere to append the owner's signed events: straight to the
200
    /// ledger, or through the daemon when it is serving (spec §8.5).
20125×
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
20225×
        let db = self.db()?;
20325×
        if spectra_mcp::client::Client::alive(&self.socket()).await {
2042×
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
2052×
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
2062×
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
20723×
        }
20823×
        Ok(Writer::Direct(self.writer().await?))
20925×
    }
210
211
    /// `[owner] home` from spectra.toml.
21238×
    pub fn config_owner_home(&self) -> Option<PathBuf> {
21338×
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
21426×
        let t: toml::Table = text.parse().ok()?;
21526×
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
21638×
    }
217
}
218
219
/// Where the owner's events go.
220
pub enum Writer {
221
    Direct(spectra_core::ledger::Ledger),
222
    Daemon { ledger: spectra_core::ledger::Ledger, client: Box<spectra_mcp::client::Client> },
223
}
224
225
impl Writer {
226
    /// The ledger, for reading.
22725×
    pub fn ledger(&self) -> &spectra_core::ledger::Ledger {
22825×
        match self {
22925×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => l,
230
        }
23125×
    }
232
233
    /// Append events signed over their payload, then checkpoint.
23423×
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
23523×
        match self {
23621×
            Writer::Direct(l) => {
23721×
                l.append(events).await?;
23821×
                l.checkpoint().await?;
239
            }
2402×
            Writer::Daemon { client, .. } => {
2412×
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
2422×
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
2432×
                if r.status != 200 {
2440×
                    let why = r
2450×
                        .json()
2460×
                        .ok()
2470×
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
2480×
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
2490×
                    bail!("the daemon refused: {why}");
2502×
                }
251
            }
252
        }
25323×
        Ok(())
25423×
    }
255
}
256
2576×
fn init_logging() {
2586×
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
2596×
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
2606×
}
261
2620×
fn default_data() -> PathBuf {
2630×
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
2640×
        return PathBuf::from(x).join("spectra");
2650×
    }
2660×
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
2670×
    PathBuf::from(home).join(".local").join("share").join("spectra")
2680×
}
269
27069×
fn runtime() -> Result<tokio::runtime::Runtime> {
27169×
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
27269×
}
273
27469×
pub fn run() -> Result<i32> {
27569×
    let cli = Cli::parse();
27669×
    ui::init(cli.plain);
27769×
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
27869×
    match cli.command {
2790×
        Command::Version { json } => version(json),
28013×
        Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
2810×
        Command::Identity { command } => runtime()?.block_on(async {
2820×
            match command {
2830×
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
2840×
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
285
            }
2860×
        }),
2876×
        Command::Serve { bind, insecure } => {
2886×
            init_logging();
2896×
            runtime()?.block_on(serve::serve(&ctx, &bind, insecure))
290
        }
29116×
        Command::Project { command } => runtime()?.block_on(async {
29216×
            match command {
29314×
                ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await,
2942×
                ProjectCmd::List { json } => project::list(&ctx, json).await,
295
            }
29616×
        }),
29717×
        Command::Agent { command } => runtime()?.block_on(async {
29817×
            match command {
29911×
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
30011×
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
301
                }
3024×
                AgentCmd::List { json } => agent::list(&ctx, json).await,
3032×
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
304
            }
30517×
        }),
30617×
        Command::Admin { command } => runtime()?.block_on(async {
30717×
            match command {
30812×
                Admin::Verify { json } => admin::verify(&ctx, json).await,
3094×
                Admin::Check => admin::check(&ctx).await,
3101×
                Admin::Rebuild => admin::rebuild(&ctx).await,
311
            }
31217×
        }),
313
    }
31469×
}
315
3160×
fn version(as_json: bool) -> Result<i32> {
3170×
    if as_json {
3180×
        println!(
3190×
            "{}",
3200×
            serde_json::json!({
3210×
                "version": env!("CARGO_PKG_VERSION"),
3220×
                "build": env!("SPECTRA_BUILD_ID"),
3230×
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
3240×
                "ledger_format": spectra_core::LEDGER_FORMAT,
3250×
            })
3260×
        );
3270×
    } else {
3280×
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
3290×
    }
3300×
    Ok(0)
3310×
}