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