OXYGENHUB

The command line in oxy's style: ticks and verbs, key-value rows, sections, hints, spinners; plain when piped 5d695d869fbf

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 708a464d29bd
When
· 2026-11-02 22:03 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
5d695d869fbf6968e55f7fa2784a417f5ecf8758dcdfd845a4a2ed378d81e2b3
Depends on
09752034b5a8 258bb3824813 36537112fcac
Depended on by
8d803d858c8c 98a0fb9f0948 813b6330f3ad 67bfa06a894f eb675dfa5e18 571572f5fb2c 2fc88a7b3a5c
In bookmarks
main
In tags
v0.1.0
Edits
39 atoms, 1 tree operation, 7 files

Browse the tree at this change

added src/cli/marks.rs +34 −0
1 +
//! Spectra's own marks, in the style of `oxy`'s (`ui.rs` stays exactly
2 +
//! `oxy`'s, so the two tools look the same): presence and agent health.
3 +
4 +
use super::ui::{dim, glyph, ok, warn};
5 +
use console::style;
6 +
7 +
/// An agent's presence (spec §8.1) as a coloured glyph and word.
8 +
#[allow(dead_code)] // Used by agent list once presence lands (#5).
9 +
pub fn presence(state: &str) -> String {
10 +
    let (g, a) = match state {
11 +
        "online" => ("●", "*"),
12 +
        "busy" => ("◆", "+"),
13 +
        "idle" => ("◌", "o"),
14 +
        "blocked" => ("‼", "!"),
15 +
        "paused" => ("⏸", "="),
16 +
        _ => ("○", "-"),
17 +
    };
18 +
    let text = format!("{} {state:<7}", glyph(g, a));
19 +
    match state {
20 +
        "online" => ok(text),
21 +
        "busy" => style(text).cyan().to_string(),
22 +
        "blocked" | "paused" => warn(text),
23 +
        _ => dim(text),
24 +
    }
25 +
}
26 +
27 +
/// Whether an agent may act: a tick, or a warning with the reason.
28 +
pub fn health(state: &str) -> String {
29 +
    if state == "ok" {
30 +
        ok(glyph("✔", "ok"))
31 +
    } else {
32 +
        warn(glyph("⚠", "!"))
33 +
    }
34 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Changed: the command line looks like oxy's.** Every command reports in `oxy`'s style, through `oxy`'s own `ui` module: a green tick and verb, dimmed key-value rows, section bars, hints and warnings, author names and separators, and a spinner while it works; plain ASCII when piped, with `--plain` or with `NO_COLOR`.
5 6
- **Added: identities instead of tokens (#4).** `spectra init` records the owner's Oxygen identity, proven by signing with it, and makes the instance key. `spectra agent add` makes an agent's key pair in its own home and records the agent only once the owner's identity delegates to it (`oxy identity delegate add`), so every agent is somebody's: `claude-oxygen · Andy McCall's agent`. Expiry and `spectra agent disable` stop an agent's key. `spectra admin verify` replays identities and delegations and checks every signed event, refusing an event whose actor is not its signer. Requests are authenticated by signature, a five-minute window, single-use nonces and an optional Unix-user binding. One process writes a ledger at a time.
6 7
- **Added: the ledger (#3).** Every event is deterministic CBOR (through `oxy-core`) hashed with a domain-separated BLAKE3 and chained to the one before; the envelope, with its hybrid logical clock, is frozen. The instance key signs a checkpoint every 256 events, every ten minutes while writing, and on demand. `spectra admin verify [--json]` recomputes the chain and checks every checkpoint, catching an edited, removed or re-signed event; `spectra admin check` and `rebuild` keep projections equal to the ledger. A single append takes 0.15 ms.
7 8
- **Added: CI on OxygenFlow (#2).** `.flow/pipeline.kdl` in the shape of Oxygen's: fmt, clippy, test (nextest under llvm-cov, with JUnit and coverage for the hub) and build, in the vendored image with no network.
…
1 unchanged lines
modified src/cli/admin.rs +30 −21
…
8 unchanged lines
9 9
/// proof; exit 1 when anything is wrong.
10 10
pub async fn verify(ctx: &Ctx, json: bool) -> Result<i32> {
11 11
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
12 +
    let spin = if json { None } else { Some(ui::spinner("verifying the ledger")) };
12 13
    let v = ledger.verify(&mut Principals::default()).await?;
14 +
    if let Some(s) = spin {
15 +
        s.clear();
16 +
    }
13 17
    if json {
14 18
        println!("{}", serde_json::to_string(&v)?);
15 -
    } else if v.clean() {
16 -
        println!("{} ledger verified", ui::done(ui::glyph("✔", "ok")));
17 -
        println!("{}", ui::kv("events", v.events));
19 +
        return Ok(if v.clean() { 0 } else { 1 });
20 +
    }
21 +
    if v.clean() {
22 +
        println!("{} {}", ui::done("Verified"), ui::subject("the ledger"));
23 +
    } else {
18 24
        println!(
19 -
            "{}",
20 -
            ui::kv(
21 -
                "checkpoints",
22 -
                format!("{} (last at {})", v.checkpoints, v.last_checkpoint.map(|s| s.to_string()).unwrap_or("none".into()))
23 -
            )
25 +
            "{} {} {}",
26 +
            ui::err(ui::glyph("✘", "x")),
27 +
            ui::err("The ledger does not verify:"),
28 +
            ui::err(format!("{} finding(s)", v.findings.len()))
24 29
        );
25 -
        println!("{}", ui::kv("uncovered", format!("{} event(s) after the last checkpoint", v.uncovered)));
26 -
        println!("{}", ui::kv("head", ui::hash(hex::encode(v.head))));
27 -
    } else {
28 -
        println!("{} ledger does not verify: {} finding(s)", ui::err(ui::glyph("✘", "x")), v.findings.len());
29 -
        for f in &v.findings {
30 -
            println!("  {} {:?}: {}", ui::id(format!("#{}", f.seq)), f.problem, f.detail);
31 -
        }
30 +
    }
31 +
    println!("{}", ui::kv("events", v.events));
32 +
    let last = v.last_checkpoint.map(|s| format!("last at #{s}")).unwrap_or_else(|| "none yet".into());
33 +
    println!("{}", ui::kv("signed", format!("{} checkpoint(s){}{last}", v.checkpoints, ui::sep())));
34 +
    if v.uncovered > 0 {
35 +
        println!("{}", ui::kv("pending", ui::dim(format!("{} event(s) since, chained and awaiting the next checkpoint", v.uncovered))));
36 +
    }
37 +
    println!("{}", ui::kv("head", ui::hash(hex::encode(v.head))));
38 +
    for f in &v.findings {
39 +
        println!("  {} {} {}", ui::id(format!("#{}", f.seq)), ui::err(format!("{:?}", f.problem)), f.detail);
32 40
    }
33 41
    Ok(if v.clean() { 0 } else { 1 })
34 42
}
35 43
36 44
/// `spectra admin check`: do the projections equal a rebuild?
37 45
pub async fn check(ctx: &Ctx) -> Result<i32> {
38 -
    let ledger = Ledger::open(&ctx.db()?, None).await?;
46 +
    let ledger = ctx.writer().await?;
39 47
    let differ = ledger.check().await?;
40 48
    if differ.is_empty() {
41 -
        println!("{} projections match the ledger", ui::done(ui::glyph("✔", "ok")));
49 +
        println!("{} {}", ui::done("Checked"), ui::subject("every projection matches the ledger"));
42 50
        Ok(0)
43 51
    } else {
44 -
        println!("{} projections differ from the ledger: {}", ui::err(ui::glyph("✘", "x")), differ.join(", "));
45 -
        println!("  run {} to rebuild them", ui::id("spectra admin rebuild"));
52 +
        println!("{} projections differ from the ledger: {}", ui::caution(), ui::err(differ.join(", ")));
53 +
        println!("  {} {}", ui::note(), ui::dim("rebuild them with spectra admin rebuild"));
46 54
        Ok(1)
47 55
    }
48 56
}
49 57
50 58
/// `spectra admin rebuild`: replay the ledger into every projection.
51 59
pub async fn rebuild(ctx: &Ctx) -> Result<i32> {
52 -
    let ledger = Ledger::open(&ctx.db()?, None).await?;
60 +
    let ledger = ctx.writer().await?;
61 +
    let spin = ui::spinner("replaying the ledger");
53 62
    ledger.rebuild().await?;
54 -
    println!("{} projections rebuilt from {} event(s)", ui::done(ui::glyph("✔", "ok")), ledger.head().await.0);
63 +
    spin.done(&format!("{} {}", ui::done("Rebuilt"), ui::subject(format!("projections from {} events", ledger.head().await.0))));
55 64
    Ok(0)
56 65
}
modified src/cli/agent.rs +32 −20
…
4 unchanged lines
5 5
//! owner's identity delegates to it. There is no agent without an owner.
6 6
7 7
use super::identity::{owner_home, refresh_owner};
8 -
use super::{ui, Ctx};
8 +
use super::{marks, ui, Ctx};
9 9
use anyhow::{bail, Context, Result};
10 10
use spectra_core::ids::now_ms;
11 11
use spectra_core::keys::{home_identity, KeyPair};
…
35 unchanged lines
47 47
        KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))?
48 48
    } else {
49 49
        let pair = KeyPair::create_home(&a.home, &a.name, "")?;
50 -
        println!("{} made {}'s key in {}", ui::done(ui::glyph("✔", "ok")), a.name, ui::path(a.home.display()));
50 +
        println!("{} {}  {}", ui::done("Created"), ui::subject(format!("{}'s key", a.name)), ui::path(a.home.display()));
51 51
        pair
52 52
    };
53 53
    let delegated = |id: &oxy_core::identity::Identity| id.delegates_to(&agent.key, DELEGATION_SCOPE, now_ms());
54 54
    if !delegated(&home_identity(&home)?) {
55 -
        println!("To make {} yours, delegate to its key (as yourself):", a.name);
55 +
        println!("{} {}", ui::caution(), ui::warn(format!("{} is not yours yet: delegate to its key, as yourself:", a.name)));
56 56
        println!(
57 -
            "  {}",
57 +
            "    {}",
58 58
            ui::id(format!(
59 59
                "oxy identity delegate add {} --scope changes --expires 8h",
60 60
                a.home.join("keys").join(format!("{}.identity", agent.key.to_hex())).display()
61 61
            ))
62 -
        );
63 -
        println!(
64 -
            "{}",
65 -
            ui::dim(format!("waiting up to {} s for the delegation; Ctrl-C stops, and running this again resumes", a.wait_secs))
66 62
        );
63 +
        let spin = ui::spinner(&format!(
64 +
            "waiting for your delegation to {} (up to {} s; Ctrl-C stops, and running this again resumes)",
65 +
            a.name, a.wait_secs
66 +
        ));
67 67
        let deadline = std::time::Instant::now() + Duration::from_secs(a.wait_secs);
68 68
        loop {
69 69
            if home_identity(&home).is_ok_and(|id| delegated(&id)) {
70 +
                spin.clear();
70 71
                break;
71 72
            }
72 73
            if std::time::Instant::now() >= deadline {
74 +
                spin.clear();
73 75
                bail!("{}'s key is not delegated yet: run the command above, then this again", a.name);
74 76
            }
75 77
            tokio::time::sleep(Duration::from_secs(2)).await;
…
18 unchanged lines
94 96
    p["delegation"] = serde_json::json!({ "scope": d.scope.names(), "expires": d.expires });
95 97
    ledger.append_one(signed_event(&owner, "human", "agent.added", p)).await?;
96 98
    ledger.checkpoint().await?;
97 -
    println!("{} {} · {}'s agent", ui::done(ui::glyph("✔", "ok")), a.name, owner.identity.name);
98 -
    println!("Connect its client with {}.", ui::id(format!("spectra mcp --agent {}", a.name)));
99 +
    println!("{} {}{}{}", ui::done("Added"), ui::subject(&a.name), ui::sep(), ui::author(format!("{}'s agent", owner.identity.name)));
100 +
    println!("{}", ui::kv("key", ui::hash(agent.key.to_hex())));
101 +
    let until = d.expires.map(|e| format!(" until {}", super::format::when(e))).unwrap_or_default();
102 +
    println!("{}", ui::kv("delegated", format!("{}{until}", d.scope.names().join(","))));
103 +
    println!("  {} {} {}", ui::note(), ui::dim("connect its client with"), ui::id(format!("spectra mcp --agent {}", a.name)));
99 104
    Ok(0)
100 105
}
101 106
…
23 unchanged lines
125 130
        return Ok(0);
126 131
    }
127 132
    if out.is_empty() {
128 -
        println!("No agents yet: {}", ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR"));
133 +
        println!("{} {}", ui::note(), ui::dim("no agents yet: spectra agent add NAME --kind KIND --project PROJECT --home DIR"));
129 134
    }
130 135
    for a in out {
131 136
        let state = a["state"].as_str().unwrap_or("");
132 -
        let mark = if state == "ok" { ui::ok(ui::glyph("●", "*")) } else { ui::warn(ui::glyph("○", "o")) };
133 -
        let until = a["delegation_expires"].as_u64().map(|e| format!(" until {}", super::format::when(e))).unwrap_or_default();
137 +
        let until = a["delegation_expires"].as_u64().map(|e| format!("{}until {}", ui::sep(), super::format::when(e))).unwrap_or_default();
138 +
        let display = a["display"].as_str().unwrap_or("");
139 +
        let (name, owner) = display.split_once(" · ").unwrap_or((display, ""));
140 +
        println!("{} {}{}{}", marks::health(state), ui::subject(name), ui::sep(), ui::author(owner));
134 141
        println!(
135 -
            "{mark} {}  {}  {}  {}{until}",
136 -
            a["display"].as_str().unwrap_or(""),
137 -
            a["kind"].as_str().unwrap_or(""),
138 -
            a["project"].as_str().unwrap_or(""),
139 -
            ui::hash(&a["key"].as_str().unwrap_or("")[..12])
142 +
            "  {}",
143 +
            ui::dim(format!(
144 +
                "{}{}{}{}{}{until}",
145 +
                a["kind"].as_str().unwrap_or(""),
146 +
                ui::sep(),
147 +
                a["project"].as_str().unwrap_or(""),
148 +
                ui::sep(),
149 +
                &a["key"].as_str().unwrap_or("")[..12]
150 +
            ))
140 151
        );
141 152
        if state != "ok" {
142 -
            println!("    {}", ui::warn(state));
153 +
            println!("  {} {}", ui::caution(), ui::warn(state));
143 154
        }
144 155
    }
145 156
    Ok(0)
…
8 unchanged lines
154 165
    let owner = KeyPair::load_home(&home)?;
155 166
    ledger.append_one(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name }))).await?;
156 167
    ledger.checkpoint().await?;
157 -
    println!("{} {name} is disabled; its key is refused from now on", ui::done(ui::glyph("✔", "ok")));
168 +
    println!("{} {}", ui::done("Disabled"), ui::subject(name));
169 +
    println!("  {} {}", ui::note(), ui::dim("its key is refused from now on; earlier events keep their signatures"));
158 170
    Ok(0)
159 171
}
160 172
…
6 unchanged lines
modified src/cli/identity.rs +37 −11
…
46 unchanged lines
47 47
        data.join("spectra.toml"),
48 48
        format!("# Spectra's configuration (spec §22.1).\n\n[owner]\nkey = \"{}\"\nhome = {}\n", owner.key.to_hex(), toml_str(&home_s)),
49 49
    )?;
50 -
    println!("{} Spectra initialised in {}", ui::done(ui::glyph("✔", "ok")), ui::path(data.display()));
51 -
    println!("{}", ui::kv("owner", format!("{} {}", owner.identity.name, ui::hash(owner.key.short(12)))));
52 -
    println!("{}", ui::kv("instance", format!("{} {}", name, ui::hash(instance.key.short(12)))));
50 +
    println!("{} {}  {}", ui::done("Initialised"), ui::subject("Spectra"), ui::path(data.display()));
51 +
    println!("{}", ui::kv("owner", format!("{}{}{}", ui::author(&owner.identity.name), ui::sep(), ui::hash(owner.key.short(12)))));
52 +
    println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12)))));
53 +
    println!("{}", ui::kv("ledger", format!("{}{}", ui::path(db.display()), ui::dim(" (verified with spectra admin verify)"))));
53 54
    println!();
54 -
    println!("So that checkpoints are verifiably from your machine, delegate to the instance key:");
55 +
    println!("  {} {}", ui::note(), ui::dim("so that checkpoints are verifiably from your machine, delegate to the instance key:"));
55 56
    println!(
56 -
        "  {}",
57 +
        "    {}",
57 58
        ui::id(format!("oxy identity delegate add {} --scope changes", data.join("keys").join("instance.identity").display()))
58 59
    );
59 -
    println!("Then add agents with {}.", ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR"));
60 +
    println!(
61 +
        "  {} {} {}",
62 +
        ui::note(),
63 +
        ui::dim("then add agents with"),
64 +
        ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR")
65 +
    );
60 66
    Ok(0)
61 67
}
62 68
…
14 unchanged lines
77 83
        );
78 84
        return Ok(0);
79 85
    }
80 -
    println!("{}", ui::kv("owner", format!("{} <{}> {}", owner.name, owner.email, ui::hash(owner.hash().to_hex()))));
86 +
    println!("{}", ui::section("Owner"));
87 +
    println!("{}", ui::kv("name", ui::author(format!("{} <{}>", owner.name, owner.email))));
88 +
    println!("{}", ui::kv("key", ui::hash(owner.hash().to_hex())));
89 +
    println!("{}", ui::kv("delegates", owner.delegates.len()));
81 90
    if let Some(i) = instance {
82 -
        let note = if delegated { ui::ok("delegated by the owner") } else { ui::warn("not delegated by the owner yet") };
83 -
        println!("{}", ui::kv("instance", format!("{} {} {}", i.name, ui::hash(i.hash().short(12)), note)));
91 +
        println!("{}", ui::section("Instance"));
92 +
        println!("{}", ui::kv("name", &i.name));
93 +
        println!("{}", ui::kv("key", ui::hash(i.hash().to_hex())));
94 +
        if delegated {
95 +
            println!("{}", ui::kv("status", ui::ok("delegated by the owner: checkpoints are verifiably yours")));
96 +
        } else {
97 +
            println!("{}", ui::kv("status", ui::warn("not delegated by the owner yet")));
98 +
            println!(
99 +
                "  {} {}",
100 +
                ui::note(),
101 +
                ui::id(format!("oxy identity delegate add {} --scope changes", ctx.data.join("keys").join("instance.identity").display()))
102 +
            );
103 +
        }
84 104
    }
85 105
    Ok(0)
86 106
}
…
5 unchanged lines
92 112
    let outcome = refresh_owner(&ledger, &owner_home(ctx, owner_home_flag)).await?;
93 113
    ledger.checkpoint().await?;
94 114
    match outcome {
95 -
        Some(id) => println!("{} recorded the owner's current identity {}", ui::done(ui::glyph("✔", "ok")), ui::hash(id.hash().short(12))),
96 -
        None => println!("{} the owner's identity is current", ui::done(ui::glyph("✔", "ok"))),
115 +
        Some(id) => println!(
116 +
            "{} {}{}{}",
117 +
            ui::done("Recorded"),
118 +
            ui::subject("the owner's current identity"),
119 +
            ui::sep(),
120 +
            ui::hash(id.hash().short(12))
121 +
        ),
122 +
        None => println!("{} {}", ui::done("Current"), ui::subject("the owner's identity has not moved")),
97 123
    }
98 124
    Ok(0)
99 125
}
…
46 unchanged lines
modified src/cli/mod.rs +1 −0
…
6 unchanged lines
7 7
pub mod format;
8 8
pub mod identity;
9 9
pub mod json;
10 +
pub mod marks;
10 11
pub mod ui;
11 12
12 13
use anyhow::{bail, Result};
…
211 unchanged lines
modified tests/cli_identity.rs +6 −1
…
70 unchanged lines
71 71
    delegate(&d.join("owner"), &agent.identity);
72 72
    let o = run(d, &args);
73 73
    assert!(o.status.success(), "{}", text(&o));
74 -
    assert!(text(&o).contains("claude-oxygen · Andy McCall's agent"), "{}", text(&o));
74 +
    assert!(text(&o).contains("Added claude-oxygen") && text(&o).contains("Andy McCall's agent"), "{}", text(&o));
75 75
    assert_eq!(KeyPair::load_home(&home).unwrap().key, agent.key, "the key made first is the key recorded");
76 76
77 77
    let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap();
…
6 unchanged lines
84 84
    assert!(list[0]["state"].as_str().unwrap().contains("disabled"), "{list}");
85 85
    verify_clean(d);
86 86
    assert!(run(d, &["admin", "check"]).status.success());
87 +
88 +
    // Piped, as here, output is plain like oxy's: no colour codes, no glyphs.
89 +
    let o = run(d, &["agent", "list"]);
90 +
    let t = text(&o);
91 +
    assert!(!t.contains('\x1b') && !t.contains('✔'), "{t}");
87 92
}
88 93
89 94
#[test]
…
20 unchanged lines