OXYGENHUB
# 5eb57578f4ef spectra / src / cli / mod.rs
8.2 KiB · Rust · last changed Projects with relations from .oxymodules, presence and sessions; projections skip malformed payloads (#5) 2 days ago source blame history raw
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 ui;
13
14
use anyhow::{bail, Result};
15
use clap::{Parser, Subcommand};
16
use std::path::PathBuf;
17
18
#[derive(Parser)]
19
#[command(name = "spectra", version = env!("SPECTRA_VERSION_LONG"), about = "A control plane for AI coding agents")]
20
pub struct Cli {
21
    /// Plain output: no colour or glyphs (also NO_COLOR, or when not a terminal)
22
    #[arg(long, global = true)]
23
    pub plain: bool,
24
    /// The data directory (default: $XDG_DATA_HOME/spectra, else ~/.local/share/spectra)
25
    #[arg(long, global = true, env = "SPECTRA_DATA")]
26
    pub data: Option<PathBuf>,
27
    #[command(subcommand)]
28
    pub command: Command,
29
}
30
31
#[derive(Subcommand)]
32
pub enum Command {
33
    /// The version and build of this binary
34
    Version {
35
        /// Machine-readable output
36
        #[arg(long)]
37
        json: bool,
38
    },
39
    /// Make this machine's Spectra: record the owner's identity (read from
40
    /// their OXY_HOME and proven by signing) and make the instance key
41
    Init {
42
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
43
        #[arg(long)]
44
        owner_home: Option<PathBuf>,
45
        /// This instance's name (default: the host name)
46
        #[arg(long)]
47
        name: Option<String>,
48
    },
49
    /// The owner's and the instance's identities: show, refresh
50
    Identity {
51
        #[command(subcommand)]
52
        command: IdentityCmd,
53
    },
54
    /// The checkouts agents work in: add, list
55
    Project {
56
        #[command(subcommand)]
57
        command: ProjectCmd,
58
    },
59
    /// Agents, each its owner's delegate: add, list, disable
60
    Agent {
61
        #[command(subcommand)]
62
        command: AgentCmd,
63
    },
64
    /// The ledger's own checks: verify, check, rebuild
65
    Admin {
66
        #[command(subcommand)]
67
        command: Admin,
68
    },
69
}
70
71
#[derive(Subcommand)]
72
pub enum IdentityCmd {
73
    /// The owner and the instance as the ledger knows them
74
    Show {
75
        #[arg(long)]
76
        json: bool,
77
    },
78
    /// Record the owner's current identity after `oxy identity delegate add|remove`
79
    Refresh {
80
        #[arg(long)]
81
        owner_home: Option<PathBuf>,
82
    },
83
}
84
85
#[derive(Subcommand)]
86
pub enum ProjectCmd {
87
    /// Register a checkout: its repository id, remote and pinned subrepos
88
    Add {
89
        path: PathBuf,
90
        /// Its name (default: the directory's)
91
        #[arg(long)]
92
        name: Option<String>,
93
        /// Its remote URL (default: the checkout's origin)
94
        #[arg(long)]
95
        remote: Option<String>,
96
        #[arg(long)]
97
        owner_home: Option<PathBuf>,
98
    },
99
    /// Every project with what it pins, what pins it, and its agents
100
    List {
101
        #[arg(long)]
102
        json: bool,
103
    },
104
}
105
106
#[derive(Subcommand)]
107
pub enum AgentCmd {
108
    /// Make an agent's key in its own home and record it once you delegate to it
109
    Add {
110
        /// Lower-case letters, digits and dashes
111
        name: String,
112
        /// claude-code, antigravity, kimi-code, codex or other
113
        #[arg(long)]
114
        kind: String,
115
        /// The project it works in
116
        #[arg(long)]
117
        project: String,
118
        /// The agent's own OXY_HOME, where its key is made and kept
119
        #[arg(long)]
120
        home: PathBuf,
121
        /// Bind the key to this Unix user: a request from another is refused
122
        #[arg(long)]
123
        uid: Option<u32>,
124
        /// Bind the key to the Unix user of its first signed connection
125
        #[arg(long, conflicts_with = "uid")]
126
        learn_uid: bool,
127
        /// How long to wait for your delegation, in seconds
128
        #[arg(long, default_value_t = 600)]
129
        wait: u64,
130
        /// The owner's OXY_HOME
131
        #[arg(long)]
132
        owner_home: Option<PathBuf>,
133
    },
134
    /// Every agent with its owner, key, delegation and state
135
    List {
136
        #[arg(long)]
137
        json: bool,
138
    },
139
    /// Refuse an agent's key from now on
140
    Disable {
141
        name: String,
142
        #[arg(long)]
143
        owner_home: Option<PathBuf>,
144
    },
145
}
146
147
#[derive(Subcommand)]
148
pub enum Admin {
149
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
150
    Verify {
151
        /// Machine-readable output
152
        #[arg(long)]
153
        json: bool,
154
    },
155
    /// Whether the projections equal a rebuild from the ledger
156
    Check,
157
    /// Replay the ledger into every projection
158
    Rebuild,
159
}
160
161
/// What every command needs: where the data lives.
162
pub struct Ctx {
163
    pub data: PathBuf,
164
}
165
166
impl Ctx {
167
    /// The ledger's database, which must exist.
168
    pub fn db(&self) -> Result<PathBuf> {
169
        let db = self.data.join("spectra.db");
170
        if !db.exists() {
171
            bail!("no Spectra in {} (run `spectra init`, or pass --data)", self.data.display());
172
        }
173
        Ok(db)
174
    }
175
176
    /// The ledger, for writing: the instance key signs its checkpoints, and
177
    /// the lock keeps any other writer out.
178
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
179
        let db = self.db()?;
180
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
181
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
182
    }
183
184
    /// `[owner] home` from spectra.toml.
185
    pub fn config_owner_home(&self) -> Option<PathBuf> {
186
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
187
        let t: toml::Table = text.parse().ok()?;
188
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
189
    }
190
}
191
192
fn default_data() -> PathBuf {
193
    if let Ok(x) = std::env::var("XDG_DATA_HOME") {
194
        return PathBuf::from(x).join("spectra");
195
    }
196
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
197
    PathBuf::from(home).join(".local").join("share").join("spectra")
198
}
199
200
fn runtime() -> Result<tokio::runtime::Runtime> {
201
    Ok(tokio::runtime::Builder::new_multi_thread().enable_all().build()?)
202
}
203
204
pub fn run() -> Result<i32> {
205
    let cli = Cli::parse();
206
    ui::init(cli.plain);
207
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
208
    match cli.command {
209
        Command::Version { json } => version(json),
210
        Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
211
        Command::Identity { command } => runtime()?.block_on(async {
212
            match command {
213
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
214
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
215
            }
216
        }),
217
        Command::Project { command } => runtime()?.block_on(async {
218
            match command {
219
                ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await,
220
                ProjectCmd::List { json } => project::list(&ctx, json).await,
221
            }
222
        }),
223
        Command::Agent { command } => runtime()?.block_on(async {
224
            match command {
225
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
226
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
227
                }
228
                AgentCmd::List { json } => agent::list(&ctx, json).await,
229
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
230
            }
231
        }),
232
        Command::Admin { command } => runtime()?.block_on(async {
233
            match command {
234
                Admin::Verify { json } => admin::verify(&ctx, json).await,
235
                Admin::Check => admin::check(&ctx).await,
236
                Admin::Rebuild => admin::rebuild(&ctx).await,
237
            }
238
        }),
239
    }
240
}
241
242
fn version(as_json: bool) -> Result<i32> {
243
    if as_json {
244
        println!(
245
            "{}",
246
            serde_json::json!({
247
                "version": env!("CARGO_PKG_VERSION"),
248
                "build": env!("SPECTRA_BUILD_ID"),
249
                "mcp_protocol_versions": spectra_mcp::PROTOCOL_VERSIONS,
250
                "ledger_format": spectra_core::LEDGER_FORMAT,
251
            })
252
        );
253
    } else {
254
        println!("spectra {}", env!("SPECTRA_VERSION_LONG"));
255
    }
256
    Ok(0)
257
}