OXYGENHUB

The command line from another machine: every command through the API (#23, completes #21) 67bfa06a894f

With a server configured, the workstation needs no ledger file: reads
and the owner's events go through the JSON API, and admin verify runs on
the server. Locally, commands read through the daemon whenever it
serves, so one path is tested on one machine and on two. An agent's
shim talks to the server over TLS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 48580f1c0455
When
· 2026-11-03 13:36 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
67bfa06a894ff1f171c8f284d5295a676d8e46f29f8ceb212536b0a7b9b184d8
Depends on
09752034b5a8 258bb3824813 2fc88a7b3a5c 36537112fcac 571572f5fb2c 5d695d869fbf 77df591d5a2f d17cbc7069ce da84e98f104e e071e91a14ef eca5af92279b
Depended on by
1876f2ce71e4 862f0db224aa b044d90930d9 98a0fb9f0948 01320eefd505
In bookmarks
main
In tags
v0.1.0
Edits
85 atoms, 1 tree operation, 12 files

Browse the tree at this change

added tests/client.rs +106 −0
1 +
//! The whole command line from a workstation (spec §8.6, spectra#21 and
2 +
//! #23): the daemon on one machine, the owner, their agents and every
3 +
//! command on another, with no ledger file on the workstation at all.
4 +
5 +
mod common;
6 +
7 +
use common::{add_agent, delegate, hermetic, run, text, Serving};
8 +
use serde_json::{json, Value};
9 +
use spectra_core::keys::KeyPair;
10 +
use std::path::Path;
11 +
use std::process::Stdio;
12 +
use std::time::Duration;
13 +
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
14 +
15 +
fn json_of(d: &Path, args: &[&str]) -> Value {
16 +
    let o = run(d, args);
17 +
    assert!(o.status.success(), "{args:?}: {}", text(&o));
18 +
    serde_json::from_slice(&o.stdout)
19 +
        .unwrap_or_else(|e| panic!("{args:?}: one JSON document ({e}): {}", String::from_utf8_lossy(&o.stdout)))
20 +
}
21 +
22 +
/// A server in `s`, claimed by the owner whose home is `c/owner`.
23 +
fn connected(s: &Path, c: &Path) -> (Serving, u16) {
24 +
    KeyPair::create_home(&c.join("owner"), "Andy McCall", "").unwrap();
25 +
    let owner = KeyPair::load_home(&c.join("owner")).unwrap();
26 +
    assert!(run(s, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]).status.success());
27 +
    let (srv, port) = Serving::start_tls(s);
28 +
    let url = format!("https://127.0.0.1:{port}");
29 +
    assert_eq!(run(c, &["connect", &url]).status.code(), Some(1));
30 +
    delegate(&c.join("owner"), &KeyPair::instance(&s.join("data"), "earth").unwrap());
31 +
    let o = run(c, &["connect", &url]);
32 +
    assert!(o.status.success(), "{}", text(&o));
33 +
    (srv, port)
34 +
}
35 +
36 +
#[tokio::test]
37 +
async fn every_command_works_from_another_machine() {
38 +
    let (sd, cd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
39 +
    let (s, c) = (sd.path(), cd.path());
40 +
    let (srv, _port) = connected(s, c);
41 +
42 +
    std::fs::create_dir_all(c.join("oxygen")).unwrap();
43 +
    let o = run(c, &["project", "add", c.join("oxygen").to_str().unwrap()]);
44 +
    assert!(o.status.success(), "{}", text(&o));
45 +
    add_agent(c, "claude-oxygen", &[]);
46 +
    add_agent(c, "spare", &[]);
47 +
48 +
    let id = json_of(c, &["identity", "show", "--json"]);
49 +
    assert_eq!(id["instance"]["name"], "earth");
50 +
    assert_eq!(id["instance"]["delegated"], true, "{id}");
51 +
    let agents = json_of(c, &["agent", "list", "--json"]);
52 +
    let claude = agents.as_array().unwrap().iter().find(|a| a["name"] == "claude-oxygen").unwrap();
53 +
    assert_eq!(claude["state"], "ok", "{claude}");
54 +
    assert_eq!(claude["display"], "claude-oxygen · Andy McCall's agent");
55 +
    let projects = json_of(c, &["project", "list", "--json"]);
56 +
    assert_eq!(projects[0]["name"], "oxygen");
57 +
    assert!(projects[0]["agents"].as_array().unwrap().contains(&json!("claude-oxygen")));
58 +
    let o = run(c, &["agent", "disable", "spare"]);
59 +
    assert!(o.status.success(), "{}", text(&o));
60 +
61 +
    assert_eq!(json_of(c, &["say", "--json", "-c", "general", "the release is tomorrow"])["channel"], "#general");
62 +
    assert!(json_of(c, &["inbox", "--json"]).is_array());
63 +
    assert_eq!(json_of(c, &["search", "--json", "release"]).as_array().unwrap().len(), 1);
64 +
    let brief: Value = serde_json::from_slice(&run(c, &["api", "GET", "/api/v1/brief"]).stdout).unwrap();
65 +
    assert_eq!(brief["actor"], "human");
66 +
67 +
    // An agent's shim on the workstation talks to the server.
68 +
    let mut cmd = tokio::process::Command::from(hermetic(c));
69 +
    let home = c.join("agents/claude-oxygen");
70 +
    cmd.args(["mcp", "--agent", "claude-oxygen", "--home", home.to_str().unwrap()])
71 +
        .stdin(Stdio::piped())
72 +
        .stdout(Stdio::piped())
73 +
        .stderr(Stdio::piped())
74 +
        .kill_on_drop(true);
75 +
    let mut shim = cmd.spawn().unwrap();
76 +
    let mut stdin = shim.stdin.take().unwrap();
77 +
    let mut lines = BufReader::new(shim.stdout.take().unwrap()).lines();
78 +
    for v in [
79 +
        json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "t", "version": "1" } } }),
80 +
        json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "brief", "arguments": {} } }),
81 +
    ] {
82 +
        stdin.write_all(format!("{v}\n").as_bytes()).await.unwrap();
83 +
    }
84 +
    stdin.flush().await.unwrap();
85 +
    let mut brief = Value::Null;
86 +
    for _ in 0..2 {
87 +
        let line = tokio::time::timeout(Duration::from_secs(20), lines.next_line()).await.unwrap().unwrap().unwrap();
88 +
        let v: Value = serde_json::from_str(&line).unwrap();
89 +
        if v["id"] == 2 {
90 +
            brief = v;
91 +
        }
92 +
    }
93 +
    assert!(brief["result"]["content"][0]["text"].as_str().unwrap().contains("You are claude-oxygen · Andy McCall's agent"), "{brief}");
94 +
    drop(shim);
95 +
96 +
    // The ledger stays on the server: verify asks it; check and rebuild refuse here.
97 +
    let v = json_of(c, &["admin", "verify", "--json"]);
98 +
    assert!(v["findings"].as_array().unwrap().is_empty(), "{v}");
99 +
    let o = run(c, &["admin", "check"]);
100 +
    assert!(!o.status.success() && text(&o).contains("runs on the server"), "{}", text(&o));
101 +
    assert!(!c.join("data").exists(), "the workstation has no data directory");
102 +
    srv.stop();
103 +
104 +
    let o = run(s, &["admin", "verify"]);
105 +
    assert!(o.status.success(), "{}", text(&o));
106 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Changed: the command line works from another machine (#23).** With a server configured by `spectra connect`, every command goes through the JSON API and the workstation needs no ledger file: `identity show`, `agent add`, `list` and `disable`, `project add` and `list`, `say`, `inbox`, `search`, `api` and `admin verify`, which the server runs on its own ledger (`GET /api/v1/admin/verify`). `admin check` and `rebuild` say they run on the server. Locally, commands read through the daemon whenever it is serving, so the same path is exercised on one machine and on two. The API's agents now carry their delegation state and expiry, projects their agents, and inbox items and search hits their author's display name.
5 6
- **Added: `spectra connect https://HOST[:PORT]` (#21).** Uses a Spectra on another machine. It trusts the server only when your own identity delegates to its instance key and that key vouches for the certificate it presents; until you have delegated, it saves the instance's public identity and prints the `oxy identity delegate add` to run. Then it pins the certificate in `~/.config/spectra/client.toml` (or `SPECTRA_SERVER`), claims a server set up with `spectra init --owner`, and brings the server up to your current identity. A certificate that changes is refused until `connect` checks it again. The stdio shim uses the configured server (with `--home`).
6 7
- **Added: setting a server up without the owner's key (#22).** `spectra init --owner KEYID` makes the instance key and the ledger with only the owner's KeyId named. Until that owner claims it, the daemon answers who it is and refuses everything else, saying it has no owner yet; the claim is the owner's identity signed by itself, and is accepted from no other key. A signed request the API refuses is now told why.
7 8
- **Added: serving other machines over TLS (#20).** With `[server] bind` beyond loopback (or `spectra serve --bind ADDR`), the daemon serves HTTPS with a certificate of its own, made on first start and kept in `keys/tls.{crt,key}`, or with `[server] tls = { cert, key }`. No certificate authority is involved: the instance key vouches for the certificate with a signed `instance.tls` event, and `GET /api/v1/instance` tells a client, before it trusts anything, who the instance is, whose it is and which certificate it signed. Clients pin that one certificate and check the server's handshake signature. An agent bound to a Unix user is refused over TCP and recorded as `guard.impersonation`, since a network connection has no uid to check.
…
15 unchanged lines
modified crates/spectra-api/src/lib.rs +14 −0
…
38 unchanged lines
39 39
    pub presence: String,
40 40
    pub status: Option<String>,
41 41
    pub disabled: bool,
42 +
    /// `ok` while the owner's identity delegates to the key, else why not.
43 +
    #[serde(default)]
44 +
    pub state: String,
45 +
    /// When the owner's delegation to the key ends, in ms.
46 +
    #[serde(default)]
47 +
    pub delegation_expires: Option<u64>,
42 48
}
43 49
44 50
/// A project (spec §23.1).
…
4 unchanged lines
49 55
    pub remote: Option<String>,
50 56
    pub depends_on: Vec<String>,
51 57
    pub depended_on_by: Vec<String>,
58 +
    /// Its agents that are not disabled.
59 +
    #[serde(default)]
60 +
    pub agents: Vec<String>,
52 61
}
53 62
54 63
/// A channel (spec §10.1).
…
59 unchanged lines
114 123
    pub kind: String,
115 124
    pub at_ms: i64,
116 125
    pub from: String,
126 +
    /// `claude-oxygen · Andy McCall's agent`, or the human's name.
127 +
    #[serde(default)]
128 +
    pub from_display: String,
117 129
    pub channel: String,
118 130
    pub summary: String,
119 131
}
…
15 unchanged lines
135 147
    pub at_ms: i64,
136 148
    pub snippet: String,
137 149
    pub thread: Option<String>,
150 +
    #[serde(default)]
151 +
    pub author_display: String,
138 152
}
139 153
140 154
/// An event as the log and the stream show it: never its payload's data on
…
55 unchanged lines
modified crates/spectra-api/ts/index.d.ts +5 −0
…
14 unchanged lines
15 15
};
16 16
17 17
export type Agent = {
18 +
  delegation_expires?: number | null;
18 19
  disabled: boolean;
19 20
  display: string;
20 21
  key: string;
…
2 unchanged lines
23 24
  owner: string;
24 25
  presence: string;
25 26
  project: string;
27 +
  state?: string;
26 28
  status?: string | null;
27 29
};
28 30
29 31
export type Project = {
32 +
  agents?: string[];
30 33
  depended_on_by: string[];
31 34
  depends_on: string[];
32 35
  name: string;
…
47 unchanged lines
80 83
  at_ms: number;
81 84
  channel: string;
82 85
  from: string;
86 +
  from_display?: string;
83 87
  id: string;
84 88
  kind: string;
85 89
  summary: string;
…
7 unchanged lines
93 97
export type Hit = {
94 98
  at_ms: number;
95 99
  author: string;
100 +
  author_display?: string;
96 101
  channel: string;
97 102
  id: string;
98 103
  snippet: string;
…
31 unchanged lines
modified crates/spectra-mcp/src/api.rs +78 −21
…
35 unchanged lines
36 36
        .route("/api/v1/inbox/ack", post(ack))
37 37
        .route("/api/v1/search", get(search))
38 38
        .route("/api/v1/events", get(events))
39 +
        .route("/api/v1/admin/verify", get(admin_verify))
39 40
        .with_state(state)
40 41
}
41 42
…
84 unchanged lines
126 127
    };
127 128
}
128 129
130 +
/// `GET /api/v1/admin/verify`: the owner asks the daemon to verify its
131 +
/// ledger (spec §8.6): what `spectra admin verify` prints, from afar.
132 +
async fn admin_verify(
133 +
    State(st): State<AppState>,
134 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
135 +
    headers: HeaderMap,
136 +
    uri: axum::http::Uri,
137 +
) -> Response {
138 +
    let c = who!(st, peer, headers, uri, "GET", b"", false);
139 +
    if !matches!(c.principal, Principal::Owner { .. }) {
140 +
        return problem(StatusCode::FORBIDDEN, "only the owner verifies the ledger", None);
141 +
    }
142 +
    let ledger = match spectra_core::ledger::Ledger::open_read_only(&st.daemon.data.join("spectra.db")).await {
143 +
        Ok(l) => l,
144 +
        Err(e) => return bad(e),
145 +
    };
146 +
    match ledger.verify(&mut spectra_core::principals::Principals::default()).await {
147 +
        Ok(v) => axum::Json(v).into_response(),
148 +
        Err(e) => bad(e),
149 +
    }
150 +
}
151 +
129 152
async fn health() -> Response {
130 153
    axum::Json(serde_json::json!({ "ok": true })).into_response()
131 154
}
…
16 unchanged lines
148 171
    .await
149 172
    .unwrap_or_default();
150 173
    let presence = st.daemon.presence.lock().await;
174 +
    let principals = st.daemon.principals.read().await;
151 175
    let now = now_ms();
152 176
    rows.into_iter()
153 -
        .map(|(name, owner, key, kind, project, status, disabled)| api::Agent {
154 -
            display: format!("{name} · {owner}'s agent"),
155 -
            presence: if disabled.is_some() { "offline".into() } else { presence.state(&name, now).into() },
156 -
            disabled: disabled.is_some(),
157 -
            name,
158 -
            owner,
159 -
            key,
160 -
            kind,
161 -
            project,
162 -
            status,
177 +
        .map(|(name, owner, key, kind, project, status, disabled)| {
178 +
            let k = oxy_core::hash::ObjectRef::from_hex(&key);
179 +
            let state = match k.map(|k| principals.principal(&k, now)) {
180 +
                Some(Ok(_)) => "ok".to_string(),
181 +
                Some(Err(why)) => why,
182 +
                None => "a malformed key".into(),
183 +
            };
184 +
            let delegation_expires = k.and_then(|k| principals.owner().and_then(|o| o.delegate(&k)).and_then(|d| d.expires));
185 +
            api::Agent {
186 +
                display: format!("{name} · {owner}'s agent"),
187 +
                presence: if disabled.is_some() { "offline".into() } else { presence.state(&name, now).into() },
188 +
                disabled: disabled.is_some(),
189 +
                name,
190 +
                owner,
191 +
                key,
192 +
                kind,
193 +
                project,
194 +
                status,
195 +
                state,
196 +
                delegation_expires,
197 +
            }
163 198
        })
164 199
        .collect()
165 200
}
…
30 unchanged lines
196 231
            .fetch_all(pool)
197 232
            .await
198 233
            .unwrap_or_default();
199 -
        out.push(api::Project { name, path, remote, depends_on, depended_on_by });
234 +
        let agents = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name")
235 +
            .bind(&name)
236 +
            .fetch_all(pool)
237 +
            .await
238 +
            .unwrap_or_default();
239 +
        out.push(api::Project { name, path, remote, depends_on, depended_on_by, agents });
200 240
    }
201 241
    json_etag(&headers, &out)
202 242
}
…
121 unchanged lines
324 364
}
325 365
326 366
async fn inbox_items(st: &AppState, actor: &str) -> Vec<api::InboxItem> {
327 -
    conv::inbox(&st.daemon.ledger, actor, None)
328 -
        .await
329 -
        .unwrap_or_default()
330 -
        .into_iter()
331 -
        .map(|i| api::InboxItem { id: i.id, kind: i.kind, at_ms: i.at_ms, from: i.from, channel: i.channel, summary: i.summary })
332 -
        .collect()
367 +
    let mut out = Vec::new();
368 +
    for i in conv::inbox(&st.daemon.ledger, actor, None).await.unwrap_or_default() {
369 +
        let from_display = conv::display_name(&st.daemon.ledger, &i.from).await;
370 +
        out.push(api::InboxItem {
371 +
            id: i.id,
372 +
            kind: i.kind,
373 +
            at_ms: i.at_ms,
374 +
            from: i.from,
375 +
            from_display,
376 +
            channel: i.channel,
377 +
            summary: i.summary,
378 +
        });
379 +
    }
380 +
    out
333 381
}
334 382
335 383
async fn inbox(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response {
…
62 unchanged lines
398 446
    match conv::search(&st.daemon.ledger, &c.principal, &q.q, q.channel.as_deref(), q.author.as_deref(), since, q.limit.unwrap_or(20)).await
399 447
    {
400 448
        Ok(hits) => {
401 -
            let out: Vec<api::Hit> = hits
402 -
                .into_iter()
403 -
                .map(|h| api::Hit { id: h.id, channel: h.channel, author: h.author, at_ms: h.at_ms, snippet: h.snippet, thread: h.thread })
404 -
                .collect();
449 +
            let mut out: Vec<api::Hit> = Vec::new();
450 +
            for h in hits {
451 +
                let author_display = conv::display_name(&st.daemon.ledger, &h.author).await;
452 +
                out.push(api::Hit {
453 +
                    id: h.id,
454 +
                    channel: h.channel,
455 +
                    author: h.author,
456 +
                    at_ms: h.at_ms,
457 +
                    snippet: h.snippet,
458 +
                    thread: h.thread,
459 +
                    author_display,
460 +
                });
461 +
            }
405 462
            json_etag(&headers, &out)
406 463
        }
407 464
        Err(e) => bad(e),
…
74 unchanged lines
modified docs/plan.md +2 −2
…
31 unchanged lines
32 32
33 33
- [x] #20 Server over TLS — done 2026-11-03: own certificate in keys/tls.*, vouched for by a signed instance.tls event; GET /api/v1/instance; pinned client transport; a uid-bound agent refused over TCP; tests/tls.rs.
34 34
- [x] #22 Remote setup — done 2026-11-03: spectra init --owner KEYID; unowned daemon refuses all but /api/v1/instance and POST /api/v1/claim, which takes only the named key; tests/remote.rs. Found: a failing end-to-end test left its daemon running, which held the output open and looked like a hang; Serving now kills it on drop.
35 -
- [ ] #21 spectra connect and the TLS client: trust through the owner's delegation, pinned certificate, shims over the network.
36 -
- [ ] #23 The CLI without a ledger file: every command through the JSON API.
35 +
- [x] #21 spectra connect — done 2026-11-03: trust through the owner's delegation to the instance key, pin, claim with the rotation delegating causes; tests/connect.rs, and the shim over TLS in tests/client.rs.
36 +
- [x] #23 The CLI without a ledger file — done 2026-11-03: every command through the API when a daemon serves, local or remote; admin verify via the server; tests/client.rs runs them all from a workstation with no data directory.
37 37
- [ ] #24 Radar from a workstation: spectra open's loopback proxy to the pinned server.
38 38
- [ ] #25 Run the daemon on Earth: install over SSH, a unit that waits for a Spectra, README.
39 39
- [ ] #13 Acceptance (with Andy), across two machines. Ready when the rest is done:
…
17 unchanged lines
modified src/cli/admin.rs +44 −16
1 1
//! `spectra admin`: the ledger's own checks (spec §9.5, §19).
2 2
3 3
use super::{ui, Ctx};
4 -
use anyhow::Result;
4 +
use anyhow::{Context, Result};
5 5
use spectra_core::ledger::Ledger;
6 6
use spectra_core::principals::Principals;
7 7
8 8
/// `spectra admin verify`: recompute the chain, check every checkpoint and
9 9
/// proof; exit 1 when anything is wrong.
10 10
pub async fn verify(ctx: &Ctx, json: bool) -> Result<i32> {
11 -
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
12 11
    let spin = if json { None } else { Some(ui::spinner("verifying the ledger")) };
13 -
    let v = ledger.verify(&mut Principals::default()).await?;
12 +
    // On a client the server verifies its own ledger (spec §8.6).
13 +
    let v: serde_json::Value = match ctx.remote()? {
14 +
        Some(_) => {
15 +
            let home = super::identity::owner_home(ctx, None);
16 +
            let c = ctx.owner_client(&home).await?.context("no server")?;
17 +
            super::call(&c, "GET", "/api/v1/admin/verify", None).await?
18 +
        }
19 +
        None => serde_json::to_value(Ledger::open_read_only(&ctx.db()?).await?.verify(&mut Principals::default()).await?)?,
20 +
    };
14 21
    if let Some(s) = spin {
15 22
        s.clear();
16 23
    }
24 +
    let findings = v["findings"].as_array().cloned().unwrap_or_default();
25 +
    let clean = findings.is_empty();
17 26
    if json {
18 -
        println!("{}", serde_json::to_string(&v)?);
19 -
        return Ok(if v.clean() { 0 } else { 1 });
27 +
        println!("{v}");
28 +
        return Ok(if clean { 0 } else { 1 });
20 29
    }
21 -
    if v.clean() {
30 +
    if clean {
22 31
        println!("{} {}", ui::done("Verified"), ui::subject("the ledger"));
23 32
    } else {
24 33
        println!(
25 34
            "{} {} {}",
26 35
            ui::err(ui::glyph("✘", "x")),
27 36
            ui::err("The ledger does not verify:"),
28 -
            ui::err(format!("{} finding(s)", v.findings.len()))
37 +
            ui::err(format!("{} finding(s)", findings.len()))
29 38
        );
30 39
    }
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))));
40 +
    println!("{}", ui::kv("events", v["events"].as_u64().unwrap_or(0)));
41 +
    let last = v["last_checkpoint"].as_u64().map(|s| format!("last at #{s}")).unwrap_or_else(|| "none yet".into());
42 +
    println!("{}", ui::kv("signed", format!("{} checkpoint(s){}{last}", v["checkpoints"].as_u64().unwrap_or(0), ui::sep())));
43 +
    let uncovered = v["uncovered"].as_u64().unwrap_or(0);
44 +
    if uncovered > 0 {
45 +
        println!("{}", ui::kv("pending", ui::dim(format!("{uncovered} event(s) since, chained and awaiting the next checkpoint"))));
36 46
    }
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);
47 +
    println!("{}", ui::kv("head", ui::hash(v["head"].as_str().unwrap_or(""))));
48 +
    for f in &findings {
49 +
        // As the problem is named in code: hash_mismatch is HashMismatch.
50 +
        let problem = match &f["problem"] {
51 +
            serde_json::Value::String(p) => p
52 +
                .split('_')
53 +
                .map(|w| {
54 +
                    let mut c = w.chars();
55 +
                    c.next().map(|f| f.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
56 +
                })
57 +
                .collect(),
58 +
            other => other.to_string(),
59 +
        };
60 +
        println!(
61 +
            "  {} {} {}",
62 +
            ui::id(format!("#{}", f["seq"].as_u64().unwrap_or(0))),
63 +
            ui::err(problem),
64 +
            f["detail"].as_str().unwrap_or("")
65 +
        );
40 66
    }
41 -
    Ok(if v.clean() { 0 } else { 1 })
67 +
    Ok(if clean { 0 } else { 1 })
42 68
}
43 69
44 70
/// `spectra admin check`: do the projections equal a rebuild?
45 71
pub async fn check(ctx: &Ctx) -> Result<i32> {
72 +
    ctx.local_only("spectra admin check")?;
46 73
    let ledger = ctx.writer().await?;
47 74
    let differ = ledger.check().await?;
48 75
    if differ.is_empty() {
…
8 unchanged lines
57 84
58 85
/// `spectra admin rebuild`: replay the ledger into every projection.
59 86
pub async fn rebuild(ctx: &Ctx) -> Result<i32> {
87 +
    ctx.local_only("spectra admin rebuild")?;
60 88
    let ledger = ctx.writer().await?;
61 89
    let spin = ui::spinner("replaying the ledger");
62 90
    ledger.rebuild().await?;
…
3 unchanged lines
modified src/cli/agent.rs +43 −16
…
3 unchanged lines
4 4
//! command that makes it the owner's, and records the agent only once the
5 5
//! owner's identity delegates to it. There is no agent without an owner.
6 6
7 -
use super::identity::{owner_home, refresh_event};
8 -
use super::{marks, ui, Ctx};
7 +
use super::identity::owner_home;
8 +
use super::{call, 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};
…
22 unchanged lines
34 34
    if !KINDS.contains(&a.kind.as_str()) {
35 35
        bail!("--kind is one of {}", KINDS.join(", "));
36 36
    }
37 -
    {
38 -
        let ledger = Ledger::open_read_only(&ctx.db()?).await?;
39 -
        let p = Principals::load(&ledger).await?;
40 -
        if p.agent_key(&a.name).is_some() {
41 -
            bail!("an agent named {} already exists (spectra agent list)", a.name);
37 +
    let home = owner_home(ctx, a.owner_home.clone());
38 +
    let (exists, known) = match ctx.owner_client(&home).await? {
39 +
        Some(c) => {
40 +
            let agents = call(&c, "GET", "/api/v1/agents", None).await?;
41 +
            let projects = call(&c, "GET", "/api/v1/projects", None).await?;
42 +
            let has = |v: &serde_json::Value, n: &str| v.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == n));
43 +
            (has(&agents, &a.name), has(&projects, &a.project))
42 44
        }
43 -
        let known: Option<String> =
44 -
            sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(&a.project).fetch_optional(ledger.pool()).await?;
45 -
        if known.is_none() {
46 -
            bail!("no project named {} (spectra project add PATH --name {})", a.project, a.project);
45 +
        None => {
46 +
            let ledger = Ledger::open_read_only(&ctx.db()?).await?;
47 +
            let p = Principals::load(&ledger).await?;
48 +
            let known: Option<String> =
49 +
                sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(&a.project).fetch_optional(ledger.pool()).await?;
50 +
            (p.agent_key(&a.name).is_some(), known.is_some())
47 51
        }
52 +
    };
53 +
    if exists {
54 +
        bail!("an agent named {} already exists (spectra agent list)", a.name);
55 +
    }
56 +
    if !known {
57 +
        bail!("no project named {} (spectra project add PATH --name {})", a.project, a.project);
48 58
    }
49 -
    let home = owner_home(ctx, a.owner_home.clone());
50 59
    // The agent's key, made once in its own home and reused if `add` is run again.
51 60
    let agent = if a.home.join("keys").exists() {
52 61
        KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))?
…
30 unchanged lines
83 92
        }
84 93
    }
85 94
    let w = ctx.owner_writer(&home).await?;
86 -
    let mut events: Vec<_> = refresh_event(w.ledger(), &home).await?.into_iter().collect();
95 +
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
87 96
    let owner = KeyPair::load_home(&home)?;
88 97
    let mut p = identity_payload(&agent.identity);
89 98
    p["name"] = a.name.clone().into();
…
32 unchanged lines
122 131
}
123 132
124 133
pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> {
134 +
    let out = match ctx.owner_client(&owner_home(ctx, None)).await? {
135 +
        Some(c) => call(&c, "GET", "/api/v1/agents", None).await?.as_array().cloned().unwrap_or_default(),
136 +
        None => list_local(ctx).await?,
137 +
    };
138 +
    print_list(out, json)
139 +
}
140 +
141 +
async fn list_local(ctx: &Ctx) -> Result<Vec<serde_json::Value>> {
125 142
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
126 143
    let p = Principals::load(&ledger).await?;
127 144
    let rows: Vec<AgentRow> = sqlx::query_as(
…
18 unchanged lines
146 163
        let presence = if disabled.is_some() { "offline".to_string() } else { presence.unwrap_or_else(|| "offline".into()) };
147 164
        out.push(serde_json::json!({ "name": name, "display": who, "key": key, "kind": kind, "project": project, "state": state, "presence": presence, "status": status, "delegation_expires": expires, "disabled": disabled.is_some() }));
148 165
    }
166 +
    Ok(out)
167 +
}
168 +
169 +
fn print_list(out: Vec<serde_json::Value>, json: bool) -> Result<i32> {
149 170
    if json {
150 171
        println!("{}", serde_json::Value::Array(out));
151 172
        return Ok(0);
…
38 unchanged lines
190 211
pub async fn disable(ctx: &Ctx, name: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> {
191 212
    let home = owner_home(ctx, owner_home_flag);
192 213
    let w = ctx.owner_writer(&home).await?;
193 -
    let p = Principals::load(w.ledger()).await?;
194 -
    p.agent_key(name).with_context(|| format!("no agent named {name}"))?;
195 -
    let mut events: Vec<_> = refresh_event(w.ledger(), &home).await?.into_iter().collect();
214 +
    let known = match (w.ledger(), w.client()) {
215 +
        (_, Some(c)) => call(c, "GET", "/api/v1/agents", None).await?.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == name)),
216 +
        (Some(l), None) => Principals::load(l).await?.agent_key(name).is_some(),
217 +
        (None, None) => false,
218 +
    };
219 +
    if !known {
220 +
        bail!("no agent named {name}");
221 +
    }
222 +
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
196 223
    let owner = KeyPair::load_home(&home)?;
197 224
    events.push(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name })));
198 225
    w.append(events).await?;
…
11 unchanged lines
modified src/cli/human.rs +55 −50
…
2 unchanged lines
3 3
//! signs with the owner's own key: through the daemon when it is serving,
4 4
//! else straight into the ledger.
5 5
6 -
use super::identity::{owner_home, recorded_owner};
6 +
use super::identity::owner_home;
7 7
use super::{ui, Ctx};
8 8
use anyhow::{bail, Context, Result};
9 9
use serde_json::{json, Value};
…
4 unchanged lines
14 14
use spectra_mcp::client::Client;
15 15
use std::path::PathBuf;
16 16
17 -
/// A client signing as the owner, when the daemon is serving.
18 17
async fn owner_client(ctx: &Ctx, home: &std::path::Path) -> Result<Option<Client>> {
19 -
    if !Client::alive(&ctx.socket()).await {
20 -
        return Ok(None);
21 -
    }
22 -
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
23 -
    Ok(Some(Client::new(&ctx.socket(), Some(recorded_owner(&ledger, home).await?))))
18 +
    ctx.owner_client(home).await
24 19
}
25 20
26 21
async fn api(c: &Client, method: &str, path: &str, body: Option<Value>) -> Result<Value> {
27 -
    let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?;
28 -
    let v: Value = r.json().unwrap_or(Value::Null);
29 -
    if r.status >= 400 {
30 -
        let why = v
31 -
            .get("detail")
32 -
            .or(v.get("title"))
33 -
            .and_then(Value::as_str)
34 -
            .map(String::from)
35 -
            .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
36 -
        bail!("{why}");
37 -
    }
38 -
    Ok(v)
22 +
    super::call(c, method, path, body).await
39 23
}
40 24
41 -
fn encode(s: &str) -> String {
42 -
    s.bytes()
43 -
        .map(|b| match b {
44 -
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
45 -
            _ => format!("%{b:02X}"),
46 -
        })
47 -
        .collect()
48 -
}
25 +
use super::encode;
49 26
50 27
pub async fn say(
51 28
    ctx: &Ctx,
…
68 unchanged lines
120 97
    println!("  {}", ui::dim(format!("reply: spectra say --reply-to {} \"…\"", i["id"].as_str().unwrap_or(""))));
121 98
}
122 99
123 -
pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option<PathBuf>, as_json: bool) -> Result<i32> {
124 -
    let home = owner_home(ctx, owner_home_flag);
100 +
/// The owner's unread items: from the daemon when one serves, else the ledger.
101 +
async fn inbox_items(ctx: &Ctx, client: Option<&Client>) -> Result<Vec<Value>> {
102 +
    if let Some(c) = client {
103 +
        return Ok(api(c, "GET", "/api/v1/inbox", None).await?.as_array().cloned().unwrap_or_default());
104 +
    }
125 105
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
126 -
    let mut items: Vec<Value> = Vec::new();
106 +
    let mut items = Vec::new();
127 107
    for i in conv::inbox(&ledger, "human", None).await? {
128 108
        let from_display = conv::display_name(&ledger, &i.from).await;
129 109
        let mut v = serde_json::to_value(&i)?;
130 110
        v["from_display"] = from_display.into();
131 111
        items.push(v);
132 112
    }
113 +
    Ok(items)
114 +
}
115 +
116 +
pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option<PathBuf>, as_json: bool) -> Result<i32> {
117 +
    let home = owner_home(ctx, owner_home_flag);
118 +
    let client = owner_client(ctx, &home).await?;
119 +
    let items = inbox_items(ctx, client.as_ref()).await?;
133 120
    if as_json && !follow {
134 121
        println!("{}", Value::Array(items));
135 122
        return Ok(0);
…
13 unchanged lines
149 136
    if !follow {
150 137
        return Ok(0);
151 138
    }
152 -
    let client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?;
139 +
    let client = client.context("--follow needs `spectra serve` running")?;
153 140
    if !as_json {
154 141
        println!("{} {}", ui::note(), ui::dim("following; Ctrl-C stops"));
155 142
    }
156 143
    // Every new message is a cue to look at the inbox again.
157 144
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
145 +
    let stream_client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?;
158 146
    let watcher = tokio::spawn(async move {
159 -
        client
147 +
        stream_client
160 148
            .stream("/api/v1/events?follow=1&kind=message.posted", |l| {
161 149
                if l.starts_with("data:") {
162 150
                    let _ = tx.send(());
…
3 unchanged lines
166 154
            .await
167 155
    });
168 156
    while rx.recv().await.is_some() {
169 -
        for i in conv::inbox(&ledger, "human", None).await? {
170 -
            if seen.insert(i.id.clone()) {
171 -
                let mut v = serde_json::to_value(&i)?;
172 -
                v["from_display"] = conv::display_name(&ledger, &i.from).await.into();
157 +
        for v in inbox_items(ctx, Some(&client)).await? {
158 +
            if seen.insert(v["id"].as_str().unwrap_or("").to_string()) {
173 159
                if as_json {
174 160
                    println!("{v}");
175 161
                } else {
…
15 unchanged lines
191 177
    limit: u32,
192 178
    as_json: bool,
193 179
) -> Result<i32> {
194 -
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
195 -
    let owner = Principals::load(&ledger).await?;
196 -
    let me = owner.owner().context("no owner is recorded")?;
197 -
    let principal = spectra_core::principals::Principal::Owner { key: me.hash(), name: me.name.clone() };
198 -
    let since = since.as_deref().and_then(|s| spectra_mcp::tools::parse_since(s, spectra_core::ids::now_ms()));
199 -
    let hits = conv::search(&ledger, &principal, query, channel.as_deref(), author.as_deref(), since, limit).await?;
180 +
    let hits: Vec<Value> = match owner_client(ctx, &owner_home(ctx, None)).await? {
181 +
        Some(c) => {
182 +
            let mut q = format!("/api/v1/search?q={}&limit={limit}", encode(query));
183 +
            for (k, v) in [("channel", &channel), ("author", &author), ("since", &since)] {
184 +
                if let Some(v) = v {
185 +
                    q.push_str(&format!("&{k}={}", encode(v)));
186 +
                }
187 +
            }
188 +
            api(&c, "GET", &q, None).await?.as_array().cloned().unwrap_or_default()
189 +
        }
190 +
        None => {
191 +
            let ledger = Ledger::open_read_only(&ctx.db()?).await?;
192 +
            let owner = Principals::load(&ledger).await?;
193 +
            let me = owner.owner().context("no owner is recorded")?;
194 +
            let principal = spectra_core::principals::Principal::Owner { key: me.hash(), name: me.name.clone() };
195 +
            let since = since.as_deref().and_then(|s| spectra_mcp::tools::parse_since(s, spectra_core::ids::now_ms()));
196 +
            let mut out = Vec::new();
197 +
            for h in conv::search(&ledger, &principal, query, channel.as_deref(), author.as_deref(), since, limit).await? {
198 +
                let mut v = serde_json::to_value(&h)?;
199 +
                v["author_display"] = conv::display_name(&ledger, &h.author).await.into();
200 +
                out.push(v);
201 +
            }
202 +
            out
203 +
        }
204 +
    };
200 205
    if as_json {
201 -
        println!("{}", serde_json::to_string(&hits)?);
206 +
        println!("{}", Value::Array(hits));
202 207
        return Ok(0);
203 208
    }
204 209
    if hits.is_empty() {
205 210
        println!("{} {}", ui::note(), ui::dim("no matches"));
206 211
    }
207 212
    for h in hits {
208 -
        let who = conv::display_name(&ledger, &h.author).await;
213 +
        let id = h["id"].as_str().unwrap_or("");
209 214
        println!(
210 215
            "{} {}{}{}{}{}",
211 -
            ui::id(&h.id[..12.min(h.id.len())]),
212 -
            ui::author(who),
216 +
            ui::id(&id[..12.min(id.len())]),
217 +
            ui::author(h["author_display"].as_str().or(h["author"].as_str()).unwrap_or("")),
213 218
            ui::sep(),
214 -
            h.channel,
219 +
            h["channel"].as_str().unwrap_or(""),
215 220
            ui::sep(),
216 -
            ui::dim(ui::ago(h.at_ms as u64))
221 +
            ui::dim(ui::ago(h["at_ms"].as_u64().unwrap_or(0)))
217 222
        );
218 -
        println!("  {}", h.snippet);
223 +
        println!("  {}", h["snippet"].as_str().unwrap_or(""));
219 224
    }
220 225
    Ok(0)
221 226
}
…
2 unchanged lines
224 229
/// JSON out (the signing proxy for scripts and the editor extensions).
225 230
pub async fn raw(ctx: &Ctx, method: &str, path: &str, data: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> {
226 231
    let home = owner_home(ctx, owner_home_flag);
227 -
    let c = owner_client(ctx, &home).await?.context("`spectra api` needs `spectra serve` running")?;
232 +
    let c = owner_client(ctx, &home).await?.context("`spectra api` needs `spectra serve` running, or `spectra connect`")?;
228 233
    let r = c.send(&method.to_uppercase(), path, &[], data.map(String::into_bytes).unwrap_or_default()).await?;
229 234
    println!("{}", String::from_utf8_lossy(&r.body));
230 235
    Ok(if r.status < 400 { 0 } else { 2 })
…
1 unchanged lines
modified src/cli/identity.rs +44 −1
…
66 unchanged lines
67 67
}
68 68
69 69
pub async fn show(ctx: &Ctx, json: bool) -> Result<i32> {
70 +
    if let Some(cfg) = ctx.remote()? {
71 +
        return show_remote(&cfg, json).await;
72 +
    }
70 73
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
71 74
    let p = Principals::load(&ledger).await?;
72 75
    let owner = p.owner().context("no owner is recorded")?;
…
37 unchanged lines
110 113
pub async fn refresh(ctx: &Ctx, owner_home_flag: Option<PathBuf>) -> Result<i32> {
111 114
    let home = owner_home(ctx, owner_home_flag);
112 115
    let w = ctx.owner_writer(&home).await?;
113 -
    let rotated = refresh_event(w.ledger(), &home).await?;
116 +
    let rotated = w.refresh_event(&home).await?;
114 117
    let outcome = rotated.as_ref().map(|_| KeyPair::load_home(&home).map(|p| p.identity)).transpose()?;
115 118
    if let Some(e) = rotated {
116 119
        w.append(vec![e]).await?;
…
61 unchanged lines
178 181
    let keys = home.join("keys");
179 182
    KeyPair::load(&keys.join(format!("{}.secret", recorded.to_hex())), &keys.join(format!("{}.identity", recorded.to_hex())))
180 183
        .with_context(|| format!("the recorded owner's key is not in {}", home.display()))
184 +
}
185 +
186 +
/// `identity show` for a Spectra on another machine: what it says of itself.
187 +
async fn show_remote(cfg: &super::connect::ClientCfg, json: bool) -> Result<i32> {
188 +
    let info = super::call(&spectra_mcp::client::Client::at(cfg.endpoint()?, None), "GET", "/api/v1/instance", None).await?;
189 +
    let owner_key = info["owner"].as_str().unwrap_or("").to_string();
190 +
    let instance_key = info["instance"]["key"].as_str().unwrap_or("").to_string();
191 +
    let home = oxy_core::repo::default_home();
192 +
    let me = KeyPair::load_home(&home).ok();
193 +
    let delegated = me.as_ref().is_some_and(|m| {
194 +
        oxy_core::hash::ObjectRef::from_hex(&instance_key)
195 +
            .is_some_and(|k| m.identity.delegates_to(&k, spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms()))
196 +
    });
197 +
    if json {
198 +
        println!(
199 +
            "{}",
200 +
            serde_json::json!({
201 +
                "server": cfg.server,
202 +
                "owner": { "name": me.as_ref().map(|m| m.identity.name.clone()), "key": owner_key },
203 +
                "instance": { "name": info["name"], "key": instance_key, "delegated": delegated },
204 +
            })
205 +
        );
206 +
        return Ok(0);
207 +
    }
208 +
    println!("{}", ui::section("Server"));
209 +
    println!("{}", ui::kv("url", ui::path(&cfg.server)));
210 +
    println!("{}", ui::section("Owner"));
211 +
    if let Some(m) = &me {
212 +
        println!("{}", ui::kv("name", ui::author(format!("{} <{}>", m.identity.name, m.identity.email))));
213 +
    }
214 +
    println!("{}", ui::kv("key", ui::hash(&owner_key)));
215 +
    println!("{}", ui::section("Instance"));
216 +
    println!("{}", ui::kv("name", info["name"].as_str().unwrap_or("")));
217 +
    println!("{}", ui::kv("key", ui::hash(&instance_key)));
218 +
    if delegated {
219 +
        println!("{}", ui::kv("status", ui::ok("delegated by you: its checkpoints and certificate are verifiably yours")));
220 +
    } else {
221 +
        println!("{}", ui::kv("status", ui::warn("not delegated by your current identity")));
222 +
    }
223 +
    Ok(0)
181 224
}
182 225
183 226
/// `spectra init --owner KEYID` (spec §8.6): a server's instance key and
…
52 unchanged lines
modified src/cli/mod.rs +104 −8
…
15 unchanged lines
16 16
pub mod serve;
17 17
pub mod ui;
18 18
19 -
use anyhow::{bail, Result};
19 +
use anyhow::{bail, Context, Result};
20 20
use clap::{Parser, Subcommand};
21 21
use std::path::PathBuf;
22 22
…
279 unchanged lines
302 302
        })
303 303
    }
304 304
305 -
    /// Somewhere to append the owner's signed events: straight to the
306 -
    /// ledger, or through the daemon when it is serving (spec §8.5).
305 +
    /// A client signing as the owner: to the configured server (spec §8.6),
306 +
    /// or to the local daemon when it is serving; none when neither. It
307 +
    /// signs with the owner identity the ledger records, since the daemon
308 +
    /// may not know a newer one yet.
309 +
    pub async fn owner_client(&self, owner_home: &std::path::Path) -> Result<Option<spectra_mcp::client::Client>> {
310 +
        use spectra_mcp::client::Client;
311 +
        if let Some(cfg) = self.remote()? {
312 +
            let ep = cfg.endpoint()?;
313 +
            let pair = remote_owner(&ep, owner_home).await?;
314 +
            return Ok(Some(Client::at(ep, Some(pair))));
315 +
        }
316 +
        if !self.data.join("spectra.db").exists() || !Client::alive(&self.socket()).await {
317 +
            return Ok(None);
318 +
        }
319 +
        let ledger = spectra_core::ledger::Ledger::open_read_only(&self.db()?).await?;
320 +
        Ok(Some(Client::new(&self.socket(), Some(identity::recorded_owner(&ledger, owner_home).await?))))
321 +
    }
322 +
323 +
    /// Somewhere to append the owner's signed events: the configured server,
324 +
    /// the local daemon when it is serving, else straight to the ledger
325 +
    /// (spec §8.5, §8.6).
307 326
    pub async fn owner_writer(&self, owner_home: &std::path::Path) -> Result<Writer> {
327 +
        if let Some(cfg) = self.remote()? {
328 +
            let ep = cfg.endpoint()?;
329 +
            let pair = remote_owner(&ep, owner_home).await?;
330 +
            let recorded = pair.identity.clone();
331 +
            return Ok(Writer::Remote { client: Box::new(spectra_mcp::client::Client::at(ep, Some(pair))), recorded });
332 +
        }
308 333
        let db = self.db()?;
309 334
        if spectra_mcp::client::Client::alive(&self.socket()).await {
310 335
            let ledger = spectra_core::ledger::Ledger::open_read_only(&db).await?;
…
1 unchanged lines
312 337
            return Ok(Writer::Daemon { ledger, client: Box::new(spectra_mcp::client::Client::new(&self.socket(), Some(transport))) });
313 338
        }
314 339
        Ok(Writer::Direct(self.writer().await?))
340 +
    }
341 +
342 +
    /// Refuse a command that needs the ledger file on this machine.
343 +
    pub fn local_only(&self, what: &str) -> Result<()> {
344 +
        if self.remote()?.is_some() {
345 +
            bail!("{what} runs on the server, which holds the ledger (this machine uses {})", connect::path().display());
346 +
        }
347 +
        Ok(())
315 348
    }
316 349
317 350
    /// `[owner] home` from spectra.toml.
…
7 unchanged lines
325 358
/// Where the owner's events go.
326 359
pub enum Writer {
327 360
    Direct(spectra_core::ledger::Ledger),
328 -
    Daemon { ledger: spectra_core::ledger::Ledger, client: Box<spectra_mcp::client::Client> },
361 +
    Daemon {
362 +
        ledger: spectra_core::ledger::Ledger,
363 +
        client: Box<spectra_mcp::client::Client>,
364 +
    },
365 +
    /// A Spectra on another machine; `recorded` is the owner identity it knows.
366 +
    Remote {
367 +
        client: Box<spectra_mcp::client::Client>,
368 +
        recorded: oxy_core::identity::Identity,
369 +
    },
329 370
}
330 371
331 372
impl Writer {
332 -
    /// The ledger, for reading.
333 -
    pub fn ledger(&self) -> &spectra_core::ledger::Ledger {
373 +
    /// The ledger on this machine, for reading, if there is one.
374 +
    pub fn ledger(&self) -> Option<&spectra_core::ledger::Ledger> {
334 375
        match self {
335 -
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => l,
376 +
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => Some(l),
377 +
            Writer::Remote { .. } => None,
336 378
        }
337 379
    }
338 380
381 +
    /// A client of the daemon, when one is serving.
382 +
    pub fn client(&self) -> Option<&spectra_mcp::client::Client> {
383 +
        match self {
384 +
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => Some(client),
385 +
            Writer::Direct(_) => None,
386 +
        }
387 +
    }
388 +
389 +
    /// The rotation that brings the recorded owner to the current identity
390 +
    /// in `home`, if it has moved.
391 +
    pub async fn refresh_event(&self, home: &std::path::Path) -> Result<Option<spectra_core::event::NewEvent>> {
392 +
        match self {
393 +
            Writer::Direct(l) | Writer::Daemon { ledger: l, .. } => identity::refresh_event(l, home).await,
394 +
            Writer::Remote { recorded, .. } => identity::rotation(recorded, home),
395 +
        }
396 +
    }
397 +
339 398
    /// Append events signed over their payload, then checkpoint.
340 399
    pub async fn append(&self, events: Vec<spectra_core::event::NewEvent>) -> Result<()> {
341 400
        match self {
…
1 unchanged lines
343 402
                l.append(events).await?;
344 403
                l.checkpoint().await?;
345 404
            }
346 -
            Writer::Daemon { client, .. } => {
405 +
            Writer::Daemon { client, .. } | Writer::Remote { client, .. } => {
347 406
                let wire: Vec<_> = events.iter().map(spectra_core::daemon::WireEvent::from_new).collect();
348 407
                let r = client.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&wire)?).await?;
349 408
                if r.status != 200 {
…
8 unchanged lines
358 417
        }
359 418
        Ok(())
360 419
    }
420 +
}
421 +
422 +
/// The owner identity a server records, as a key pair from the owner's
423 +
/// home: what requests to it are signed with.
424 +
async fn remote_owner(ep: &spectra_mcp::client::Endpoint, home: &std::path::Path) -> Result<spectra_core::keys::KeyPair> {
425 +
    let r = spectra_mcp::client::Client::at(ep.clone(), None).send("GET", "/api/v1/instance", &[], vec![]).await?;
426 +
    let info = r.json()?;
427 +
    let key = info.get("owner").and_then(|o| o.as_str()).context("the server has no owner yet: spectra connect claims it")?;
428 +
    let keys = home.join("keys");
429 +
    spectra_core::keys::KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity")))
430 +
        .with_context(|| format!("the server's owner {} is not an identity in {}", &key[..12.min(key.len())], home.display()))
431 +
}
432 +
433 +
/// An API call; a refusal becomes an error that says why.
434 +
pub async fn call(c: &spectra_mcp::client::Client, method: &str, path: &str, body: Option<serde_json::Value>) -> Result<serde_json::Value> {
435 +
    let r = c.send(method, path, &[], body.map(|b| serde_json::to_vec(&b)).transpose()?.unwrap_or_default()).await?;
436 +
    let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null);
437 +
    if r.status >= 400 {
438 +
        let why = v
439 +
            .get("detail")
440 +
            .or(v.get("title"))
441 +
            .and_then(serde_json::Value::as_str)
442 +
            .map(String::from)
443 +
            .unwrap_or_else(|| String::from_utf8_lossy(&r.body).into());
444 +
        bail!("{why}");
445 +
    }
446 +
    Ok(v)
447 +
}
448 +
449 +
/// A query string value, percent-encoded.
450 +
pub fn encode(s: &str) -> String {
451 +
    s.bytes()
452 +
        .map(|b| match b {
453 +
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
454 +
            _ => format!("%{b:02X}"),
455 +
        })
456 +
        .collect()
361 457
}
362 458
363 459
fn init_logging() {
…
89 unchanged lines
modified src/cli/project.rs +32 −15
1 1
//! `spectra project`: the checkouts agents work in (spec §23.1).
2 2
3 -
use super::identity::{owner_home, refresh_event};
4 -
use super::{ui, Ctx};
3 +
use super::identity::owner_home;
4 +
use super::{call, ui, Ctx};
5 5
use anyhow::{bail, Context, Result};
6 6
use spectra_core::keys::KeyPair;
7 7
use spectra_core::ledger::Ledger;
…
13 unchanged lines
21 21
    let checkout = projects::inspect(&path)?;
22 22
    let home = owner_home(ctx, owner_home_flag);
23 23
    let w = ctx.owner_writer(&home).await?;
24 -
    let ledger = w.ledger();
25 -
    let exists: Option<String> = sqlx::query_scalar("SELECT name FROM projects WHERE name = ? OR path = ?")
26 -
        .bind(&name)
27 -
        .bind(path.display().to_string())
28 -
        .fetch_optional(ledger.pool())
29 -
        .await?;
30 -
    if let Some(e) = exists {
31 -
        bail!("{} is already a project ({e})", path.display());
24 +
    let path_s = path.display().to_string();
25 +
    let existing = all(&w).await?;
26 +
    if let Some(e) = existing.iter().find(|p| p["name"] == name.as_str() || p["path"] == path_s.as_str()) {
27 +
        bail!("{} is already a project ({})", path.display(), e["name"].as_str().unwrap_or(""));
32 28
    }
33 -
    let mut events: Vec<_> = refresh_event(ledger, &home).await?.into_iter().collect();
29 +
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
34 30
    let owner = KeyPair::load_home(&home)?;
35 31
    let remote = remote.or(checkout.remote.clone());
36 32
    let payload = serde_json::json!({
…
9 unchanged lines
46 42
    if let Some(id) = &checkout.repo_id {
47 43
        println!("{}", ui::kv("repo", ui::hash(id)));
48 44
    }
49 -
    let (deps, users) = relations(ledger, &name).await?;
45 +
    let now = all(&w).await?;
46 +
    let me = now.iter().find(|p| p["name"] == name.as_str()).cloned().unwrap_or_default();
47 +
    let names =
48 +
        |k: &str| me[k].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>()).unwrap_or_default();
49 +
    let (deps, users) = (names("depends_on"), names("depended_on_by"));
50 50
    if !deps.is_empty() {
51 51
        println!("{}", ui::kv("pins", deps.join(", ")));
52 52
    }
…
18 unchanged lines
71 71
    Ok((deps, users))
72 72
}
73 73
74 -
pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> {
75 -
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
74 +
/// Every project with its relations and agents: from the daemon when one
75 +
/// serves, else from the ledger here.
76 +
async fn all(w: &super::Writer) -> Result<Vec<serde_json::Value>> {
77 +
    match (w.client(), w.ledger()) {
78 +
        (Some(c), _) => Ok(call(c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default()),
79 +
        (None, Some(l)) => local(l).await,
80 +
        (None, None) => Ok(vec![]),
81 +
    }
82 +
}
83 +
84 +
async fn local(ledger: &Ledger) -> Result<Vec<serde_json::Value>> {
76 85
    let rows: Vec<(String, String, Option<String>)> =
77 86
        sqlx::query_as("SELECT name, path, remote FROM projects ORDER BY name").fetch_all(ledger.pool()).await?;
78 87
    let mut out = Vec::new();
79 88
    for (name, path, remote) in rows {
80 -
        let (deps, users) = relations(&ledger, &name).await?;
89 +
        let (deps, users) = relations(ledger, &name).await?;
81 90
        let agents: Vec<String> = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name")
82 91
            .bind(&name)
83 92
            .fetch_all(ledger.pool())
84 93
            .await?;
85 94
        out.push(serde_json::json!({ "name": name, "path": path, "remote": remote, "depends_on": deps, "depended_on_by": users, "agents": agents }));
86 95
    }
96 +
    Ok(out)
97 +
}
98 +
99 +
pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> {
100 +
    let out = match ctx.owner_client(&owner_home(ctx, None)).await? {
101 +
        Some(c) => call(&c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default(),
102 +
        None => local(&Ledger::open_read_only(&ctx.db()?).await?).await?,
103 +
    };
87 104
    if json {
88 105
        println!("{}", serde_json::Value::Array(out));
89 106
        return Ok(0);
…
22 unchanged lines