OXYGENHUB

Coverage / src/cli/mod.rs 68.8%

88 of 128 lines run, 17 of 48 functions · run #11 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 mcp;
12
pub mod project;
13
pub mod serve;
14
pub mod ui;
15
16
use anyhow::{bail, Result};
17
use clap::{Parser, Subcommand};
18
use std::path::PathBuf;
19
20
#[derive(Parser)]
21
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
22
pub struct Cli {
23
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
24
    #[arg(long, global = true)]
25
    pub plain: bool,
26
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
27
    #[arg(long, global = true, env = "SPECTRA_DATA")]
28
    pub data: Option<PathBuf>,
29
    #[command(subcommand)]
30
    pub command: Command,
31
}
32
33
#[derive(Subcommand)]
34
pub enum Command {
35
    /// The version and build of this binary
36
    Version {
37
        /// Machine-readable output
38
        #[arg(long)]
39
        json: bool,
40
    },
41
    /// Make this machine's Spectra: record the owner's identity (read from
42
    /// their OXY_HOME and proven by signing) and make the instance key
43
    Init {
44
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
45
        #[arg(long)]
46
        owner_home: Option<PathBuf>,
47
        /// This instance's name (default: the host name)
48
        #[arg(long)]
49
        name: Option<String>,
50
    },
51
    /// The owner's and the instance's identities: show, refresh
52
    Identity {
53
        #[command(subcommand)]
54
        command: IdentityCmd,
55
    },
56
    /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port
57
    Serve {
58
        /// The loopback address and port for browsers and HTTP clients
59
        #[arg(long, default_value = "127.0.0.1:7350")]
60
        bind: String,
61
        /// Allow a non-loopback address in plain HTTP
62
        #[arg(long)]
63
        insecure: bool,
64
    },
65
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
66
    Mcp {
67
        /// The agent it speaks for
68
        #[arg(long)]
69
        agent: String,
70
        /// The agent's home (default: the one recorded when it was added)
71
        #[arg(long)]
72
        home: Option<PathBuf>,
73
    },
74
    /// The checkouts agents work in: add, list
75
    Project {
76
        #[command(subcommand)]
77
        command: ProjectCmd,
78
    },
79
    /// Agents, each its owner's delegate: add, list, disable
80
    Agent {
81
        #[command(subcommand)]
82
        command: AgentCmd,
83
    },
84
    /// The ledger's own checks: verify, check, rebuild
85
    Admin {
86
        #[command(subcommand)]
87
        command: Admin,
88
    },
89
}
90
91
#[derive(Subcommand)]
92
pub enum IdentityCmd {
93
    /// The owner and the instance as the ledger knows them
94
    Show {
95
        #[arg(long)]
96
        json: bool,
97
    },
98
    /// Record the owner's current identity after `oxy identity delegate add|remove`
99
    Refresh {
100
        #[arg(long)]
101
        owner_home: Option<PathBuf>,
102
    },
103
}
104
105
#[derive(Subcommand)]
106
pub enum ProjectCmd {
107
    /// Register a checkout: its repository id, remote and pinned subrepos
108
    Add {
109
        path: PathBuf,
110
        /// Its name (default: the directory's)
111
        #[arg(long)]
112
        name: Option<String>,
113
        /// Its remote URL (default: the checkout's origin)
114
        #[arg(long)]
115
        remote: Option<String>,
116
        #[arg(long)]
117
        owner_home: Option<PathBuf>,
118
    },
119
    /// Every project with what it pins, what pins it, and its agents
120
    List {
121
        #[arg(long)]
122
        json: bool,
123
    },
124
}
125
126
#[derive(Subcommand)]
127
pub enum AgentCmd {
128
    /// Make an agent's key in its own home and record it once you delegate to it
129
    Add {
130
        /// Lower-case letters, digits and dashes
131
        name: String,
132
        /// claude-code, antigravity, kimi-code, codex or other
133
        #[arg(long)]
134
        kind: String,
135
        /// The project it works in
136
        #[arg(long)]
137
        project: String,
138
        /// The agent's own OXY_HOME, where its key is made and kept
139
        #[arg(long)]
140
        home: PathBuf,
141
        /// Bind the key to this Unix user: a request from another is refused
142
        #[arg(long)]
143
        uid: Option<u32>,
144
        /// Bind the key to the Unix user of its first signed connection
145
        #[arg(long, conflicts_with = "uid")]
146
        learn_uid: bool,
147
        /// How long to wait for your delegation, in seconds
148
        #[arg(long, default_value_t = 600)]
149
        wait: u64,
150
        /// The owner's OXY_HOME
151
        #[arg(long)]
152
        owner_home: Option<PathBuf>,
153
    },
154
    /// Every agent with its owner, key, delegation and state
155
    List {
156
        #[arg(long)]
157
        json: bool,
158
    },
159
    /// Refuse an agent's key from now on
160
    Disable {
161
        name: String,
162
        #[arg(long)]
163
        owner_home: Option<PathBuf>,
164
    },
165
}
166
167
#[derive(Subcommand)]
168
pub enum Admin {
169
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
170
    Verify {
171
        /// Machine-readable output
172
        #[arg(long)]
173
        json: bool,
174
    },
175
    /// Whether the projections equal a rebuild from the ledger
176
    Check,
177
    /// Replay the ledger into every projection
178
    Rebuild,
179
}
180
181
/// What every command needs: where the data lives.
182
pub struct Ctx {
183
    pub data: PathBuf,
184
}
185
186
impl Ctx {
187
    /// The ledger's database, which must exist.
18899×
    pub fn db(&self) -> Result<PathBuf> {
18999×
        let db = self.data.join("spectra.db");
19099×
        if !db.exists() {
1911×
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
19298×
        }
19398×
        Ok(db)
19499×
    }
195
196
    /// The ledger, for writing: the instance key signs its checkpoints, and
197
    /// the lock keeps any other writer out.
19834×
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
19934×
        let db = self.db()?;
20034×
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
20134×
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
20234×
    }
203
204
    /// The daemon's Unix socket.
20540×
    pub fn socket(&self) -> PathBuf {
20640×
        self.data.join("spectra.sock")
20740×
    }
208
209
    /// Somewhere to append the owner's signed events: straight to the
210
    /// ledger, or through the daemon when it is serving (spec §8.5).
21131×
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
21231×
        let db = self.db()?;
21331×
        if spectra_mcp::client::Client::alive(&self.socket()).await {
2142×
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
2152×
            let transport = identity::recorded_owner(&ledger, owner_home).await?;
2162×
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
21729×
        }
21829×
        Ok(Writer::Direct(self.writer().await?))
21931×
    }
220
221
    /// `[owner] home` from spectra.toml.
22246×
    pub fn config_owner_home(&self) -> Option<PathBuf> {
22346×
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
22432×
        let t: toml::Table = text.parse().ok()?;
22532×
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
22646×
    }
227
}
228
229
/// Where the owner's events go.
230
pub enum Writer {
231
    Direct(spectra_core::ledger::Ledger),
232
    Daemon { ledger: spectra_core::ledger::Ledger, client: Box<spectra_mcp::client::Client> },
233
}
234
235
impl Writer {
236
    /// The ledger, for reading.
23731×
    pub fn ledger(&self) -> &spectra_core::ledger::Ledger {
23831×
        match self {
23931×
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => l,
240
        }
24131×
    }
242
243
    /// Append events signed over their payload, then checkpoint.
24429×
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
24529×
        match self {
24627×
            Writer::Direct(l) => {
24727×
                l.append(events).await?;
24827×
                l.checkpoint().await?;
249
            }
2502×
            Writer::Daemon { client, .. } => {
2512×
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
2522×
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
2532×
                if r.status != 200 {
2540×
                    let why = r
2550×
                        .json()
2560×
                        .ok()
2570×
                        .and_then(|v| v.get("title").and_then(|t| t.as_str()).map(String::from))
2580×
                        .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
2590×
                    bail!("the daemon refused: {why}");
2602×
                }
261
            }
262
        }
26329×
        Ok(())
26429×
    }
265
}
266
2677×
fn init_logging() {
2687×
    let filter = tracing_subscriber::EnvFilter::try_from_env("SPECTRA_LOG").unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
2697×
    tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
2707×
}
271
2720×
fn default_data() -> PathBuf {
2730×
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
2740×
        return PathBuf::from(x).join("spectra");
2750×
    }
2760×
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
2770×
    PathBuf::from(home).join(".local").join("share").join("spectra")
2780×
}
279
28080×
fn runtime() -> Result<tokio::runtime::Runtime> {
28180×
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
28280×
}
283
28480×
pub fn run() -> Result<i32> {
28580×
    let cli = Cli::parse();
28680×
    ui::init(cli.plain);
28780×
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
28880×
    match cli.command {
2890×
        Command::Version { json } => version(json),
29015×
        Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
2910×
        Command::Identity { command } => runtime()?.block_on(async {
2920×
            match command {
2930×
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
2940×
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
295
            }
2960×
        }),
2977×
        Command::Serve { bind, insecure } => {
2987×
            init_logging();
2997×
            runtime()?.block_on(serve::serve(&ctx, &bind, insecure))
300
        }
3012×
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
30218×
        Command::Project { command } => runtime()?.block_on(async {
30318×
            match command {
30416×
                ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await,
3052×
                ProjectCmd::List { json } => project::list(&ctx, json).await,
306
            }
30718×
        }),
30821×
        Command::Agent { command } => runtime()?.block_on(async {
30921×
            match command {
31015×
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
31115×
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
312
                }
3134×
                AgentCmd::List { json } => agent::list(&ctx, json).await,
3142×
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
315
            }
31621×
        }),
31717×
        Command::Admin { command } => runtime()?.block_on(async {
31817×
            match command {
31912×
                Admin::Verify { json } => admin::verify(&ctx, json).await,
3204×
                Admin::Check => admin::check(&ctx).await,
3211×
                Admin::Rebuild => admin::rebuild(&ctx).await,
322
            }
32317×
        }),
324
    }
32580×
}
326
3270×
fn version(as_json: bool) -> Result<i32> {
3280×
    if as_json {
3290×
        println!(
3300×
            "{}",
3310×
            serde_json::json!({
3320×
                "version": env!("CARGO_PKG_VERSION"),
3330×
                "build": env!("SPECTRA_BUILD_ID"),
3340×
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
3350×
                "ledger_format": spectra_core::LEDGER_FORMAT,
3360×
            })
3370×
        );
3380×
    } else {
3390×
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
3400×
    }
3410×
    Ok(0)
3420×
}