The human at a terminal: spectra say, inbox --follow, search and api, signed with the owner's key (#12) da84e98f104e
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 55a19075f13a
- When
- · 2026-11-02 23:23 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
da84e98f104e97c82761a4ce03e84d1cc19003aa557b813f7c7f0d3f1054f3aa- Depends on
- 09752034b5a8 258bb3824813 2fc88a7b3a5c 36537112fcac 571572f5fb2c e071e91a14ef eb675dfa5e18
- Depended on by
- 8d803d858c8c 98a0fb9f0948 01320eefd505 67bfa06a894f b576d05cf862 d17cbc7069ce 98a16830a3d3
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 9 atoms, 2 tree operations, 6 files
Browse the tree at this change
added src/cli/human.rs +231 −0
| 1 | + | //! The human at a terminal (spec §19): `spectra say`, `spectra inbox |
|
| 2 | + | //! [--follow]`, `spectra search`, and `spectra api` for anything else. Each |
|
| 3 | + | //! signs with the owner's own key: through the daemon when it is serving, |
|
| 4 | + | //! else straight into the ledger. |
|
| 5 | + | ||
| 6 | + | use super::identity::{owner_home, recorded_owner}; |
|
| 7 | + | use super::{ui, Ctx}; |
|
| 8 | + | use anyhow::{bail, Context, Result}; |
|
| 9 | + | use serde_json::{json, Value}; |
|
| 10 | + | use spectra_core::conversation::{self as conv, Author, Post, Posted, Signer}; |
|
| 11 | + | use spectra_core::keys::KeyPair; |
|
| 12 | + | use spectra_core::ledger::Ledger; |
|
| 13 | + | use spectra_core::principals::Principals; |
|
| 14 | + | use spectra_mcp::client::Client; |
|
| 15 | + | use std::path::PathBuf; |
|
| 16 | + | ||
| 17 | + | /// A client signing as the owner, when the daemon is serving. |
|
| 18 | + | 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?)))) |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | 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) |
|
| 39 | + | } |
|
| 40 | + | ||
| 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 | + | } |
|
| 49 | + | ||
| 50 | + | pub async fn say( |
|
| 51 | + | ctx: &Ctx, |
|
| 52 | + | channel: Option<String>, |
|
| 53 | + | reply_to: Option<String>, |
|
| 54 | + | body: String, |
|
| 55 | + | owner_home_flag: Option<PathBuf>, |
|
| 56 | + | as_json: bool, |
|
| 57 | + | ) -> Result<i32> { |
|
| 58 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 59 | + | let (id, where_) = match owner_client(ctx, &home).await? { |
|
| 60 | + | Some(c) => { |
|
| 61 | + | let ch = channel.clone().unwrap_or_else(|| "general".into()); |
|
| 62 | + | let v = api( |
|
| 63 | + | &c, |
|
| 64 | + | "POST", |
|
| 65 | + | &format!("/api/v1/channels/{}/messages", encode(ch.trim_start_matches('#'))), |
|
| 66 | + | Some(json!({ "body": body, "reply_to": reply_to })), |
|
| 67 | + | ) |
|
| 68 | + | .await?; |
|
| 69 | + | if v["duplicate"] == true { |
|
| 70 | + | bail!("you said exactly this in the last minute ({})", v["id"].as_str().unwrap_or("")); |
|
| 71 | + | } |
|
| 72 | + | (v["id"].as_str().unwrap_or("").to_string(), v["channel"].as_str().unwrap_or("").to_string()) |
|
| 73 | + | } |
|
| 74 | + | None => { |
|
| 75 | + | let ledger = ctx.writer().await?; |
|
| 76 | + | let owner = KeyPair::load_home(&home)?; |
|
| 77 | + | let principal = Principals::load(&ledger) |
|
| 78 | + | .await? |
|
| 79 | + | .principal(&owner.key, spectra_core::ids::now_ms()) |
|
| 80 | + | .map_err(anyhow::Error::msg) |
|
| 81 | + | .context("run `spectra identity refresh` first")?; |
|
| 82 | + | let author = Author { principal, signer: Signer::Cli(&owner) }; |
|
| 83 | + | let post = Post { |
|
| 84 | + | channel: if reply_to.is_some() { None } else { Some(channel.unwrap_or_else(|| "general".into())) }, |
|
| 85 | + | body, |
|
| 86 | + | reply_to, |
|
| 87 | + | ..Default::default() |
|
| 88 | + | }; |
|
| 89 | + | let posted = conv::post(&ledger, &author, post, spectra_core::ids::now_ms()).await?; |
|
| 90 | + | ledger.checkpoint().await?; |
|
| 91 | + | match posted { |
|
| 92 | + | Posted::Message { id, channel } => (id, channel), |
|
| 93 | + | Posted::Duplicate { id } => bail!("you said exactly this in the last minute ({id})"), |
|
| 94 | + | } |
|
| 95 | + | } |
|
| 96 | + | }; |
|
| 97 | + | if as_json { |
|
| 98 | + | println!("{}", json!({ "id": id, "channel": where_ })); |
|
| 99 | + | } else { |
|
| 100 | + | println!("{} {}{}{}", ui::done("Posted"), ui::subject(&where_), ui::sep(), ui::id(&id)); |
|
| 101 | + | } |
|
| 102 | + | Ok(0) |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | fn print_item(i: &Value) { |
|
| 106 | + | let kind = i["kind"].as_str().unwrap_or(""); |
|
| 107 | + | let mark = if kind == "dm" { ui::warn(ui::glyph("✉", "@")) } else { ui::ok(ui::glyph("●", "*")) }; |
|
| 108 | + | let from = i["from_display"].as_str().or(i["from"].as_str()).unwrap_or(""); |
|
| 109 | + | let at = i["at_ms"].as_u64().map(ui::ago).unwrap_or_default(); |
|
| 110 | + | println!( |
|
| 111 | + | "{mark} {} {}{}{}{}{}", |
|
| 112 | + | ui::dim(kind), |
|
| 113 | + | ui::author(from), |
|
| 114 | + | ui::sep(), |
|
| 115 | + | i["channel"].as_str().unwrap_or(""), |
|
| 116 | + | ui::sep(), |
|
| 117 | + | ui::dim(at) |
|
| 118 | + | ); |
|
| 119 | + | println!(" {}", i["summary"].as_str().unwrap_or("")); |
|
| 120 | + | println!(" {}", ui::dim(format!("reply: spectra say --reply-to {} \"…\"", i["id"].as_str().unwrap_or("")))); |
|
| 121 | + | } |
|
| 122 | + | ||
| 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); |
|
| 125 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 126 | + | let mut items: Vec<Value> = Vec::new(); |
|
| 127 | + | for i in conv::inbox(&ledger, "human", None).await? { |
|
| 128 | + | let from_display = conv::display_name(&ledger, &i.from).await; |
|
| 129 | + | let mut v = serde_json::to_value(&i)?; |
|
| 130 | + | v["from_display"] = from_display.into(); |
|
| 131 | + | items.push(v); |
|
| 132 | + | } |
|
| 133 | + | if as_json && !follow { |
|
| 134 | + | println!("{}", Value::Array(items)); |
|
| 135 | + | return Ok(0); |
|
| 136 | + | } |
|
| 137 | + | if items.is_empty() && !follow { |
|
| 138 | + | println!("{} {}", ui::note(), ui::dim("nothing unread")); |
|
| 139 | + | } |
|
| 140 | + | let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); |
|
| 141 | + | for i in &items { |
|
| 142 | + | seen.insert(i["id"].as_str().unwrap_or("").to_string()); |
|
| 143 | + | if as_json { |
|
| 144 | + | println!("{i}"); |
|
| 145 | + | } else { |
|
| 146 | + | print_item(i); |
|
| 147 | + | } |
|
| 148 | + | } |
|
| 149 | + | if !follow { |
|
| 150 | + | return Ok(0); |
|
| 151 | + | } |
|
| 152 | + | let client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?; |
|
| 153 | + | if !as_json { |
|
| 154 | + | println!("{} {}", ui::note(), ui::dim("following; Ctrl-C stops")); |
|
| 155 | + | } |
|
| 156 | + | // Every new message is a cue to look at the inbox again. |
|
| 157 | + | let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); |
|
| 158 | + | let watcher = tokio::spawn(async move { |
|
| 159 | + | client |
|
| 160 | + | .stream("/api/v1/events?follow=1&kind=message.posted", |l| { |
|
| 161 | + | if l.starts_with("data:") { |
|
| 162 | + | let _ = tx.send(()); |
|
| 163 | + | } |
|
| 164 | + | true |
|
| 165 | + | }) |
|
| 166 | + | .await |
|
| 167 | + | }); |
|
| 168 | + | 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(); |
|
| 173 | + | if as_json { |
|
| 174 | + | println!("{v}"); |
|
| 175 | + | } else { |
|
| 176 | + | print_item(&v); |
|
| 177 | + | } |
|
| 178 | + | } |
|
| 179 | + | } |
|
| 180 | + | } |
|
| 181 | + | watcher.await??; |
|
| 182 | + | Ok(0) |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | pub async fn search( |
|
| 186 | + | ctx: &Ctx, |
|
| 187 | + | query: &str, |
|
| 188 | + | channel: Option<String>, |
|
| 189 | + | author: Option<String>, |
|
| 190 | + | since: Option<String>, |
|
| 191 | + | limit: u32, |
|
| 192 | + | as_json: bool, |
|
| 193 | + | ) -> 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?; |
|
| 200 | + | if as_json { |
|
| 201 | + | println!("{}", serde_json::to_string(&hits)?); |
|
| 202 | + | return Ok(0); |
|
| 203 | + | } |
|
| 204 | + | if hits.is_empty() { |
|
| 205 | + | println!("{} {}", ui::note(), ui::dim("no matches")); |
|
| 206 | + | } |
|
| 207 | + | for h in hits { |
|
| 208 | + | let who = conv::display_name(&ledger, &h.author).await; |
|
| 209 | + | println!( |
|
| 210 | + | "{} {}{}{}{}{}", |
|
| 211 | + | ui::id(&h.id[..12.min(h.id.len())]), |
|
| 212 | + | ui::author(who), |
|
| 213 | + | ui::sep(), |
|
| 214 | + | h.channel, |
|
| 215 | + | ui::sep(), |
|
| 216 | + | ui::dim(ui::ago(h.at_ms as u64)) |
|
| 217 | + | ); |
|
| 218 | + | println!(" {}", h.snippet); |
|
| 219 | + | } |
|
| 220 | + | Ok(0) |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | /// `spectra api METHOD PATH [-d JSON]`: any API call, signed as the owner, |
|
| 224 | + | /// JSON out (the signing proxy for scripts and the editor extensions). |
|
| 225 | + | pub async fn raw(ctx: &Ctx, method: &str, path: &str, data: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 226 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 227 | + | let c = owner_client(ctx, &home).await?.context("`spectra api` needs `spectra serve` running")?; |
|
| 228 | + | let r = c.send(&method.to_uppercase(), path, &[], data.map(String::into_bytes).unwrap_or_default()).await?; |
|
| 229 | + | println!("{}", String::from_utf8_lossy(&r.body)); |
|
| 230 | + | Ok(if r.status < 400 { 0 } else { 2 }) |
|
| 231 | + | } |
added tests/cli_json.rs +63 −0
| 1 | + | //! Every `--json` output is one JSON document on stdout and nothing else |
|
| 2 | + | //! (as `oxy`'s are), with and without the daemon; and `spectra say` with |
|
| 3 | + | //! `spectra inbox --follow` work as a terminal chat with the agents. |
|
| 4 | + | ||
| 5 | + | mod common; |
|
| 6 | + | ||
| 7 | + | use common::{add_agent, hermetic, run, setup, text, Serving}; |
|
| 8 | + | use serde_json::{json, Value}; |
|
| 9 | + | use spectra_mcp::client::Client; |
|
| 10 | + | use std::io::{BufRead, BufReader}; |
|
| 11 | + | use std::process::Stdio; |
|
| 12 | + | use std::time::Duration; |
|
| 13 | + | ||
| 14 | + | fn json_of(d: &std::path::Path, args: &[&str]) -> Value { |
|
| 15 | + | let o = run(d, args); |
|
| 16 | + | assert!(o.status.success(), "{args:?}: {}", text(&o)); |
|
| 17 | + | serde_json::from_slice(&o.stdout) |
|
| 18 | + | .unwrap_or_else(|e| panic!("{args:?} printed more than one JSON document ({e}): {}", String::from_utf8_lossy(&o.stdout))) |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | #[test] |
|
| 22 | + | fn every_json_output_parses_without_the_daemon() { |
|
| 23 | + | let dir = setup(); |
|
| 24 | + | let d = dir.path(); |
|
| 25 | + | add_agent(d, "claude-oxygen", &[]); |
|
| 26 | + | assert_eq!(json_of(d, &["version", "--json"])["ledger_format"], 1); |
|
| 27 | + | assert_eq!(json_of(d, &["identity", "show", "--json"])["owner"]["name"], "Andy McCall"); |
|
| 28 | + | assert_eq!(json_of(d, &["agent", "list", "--json"])[0]["name"], "claude-oxygen"); |
|
| 29 | + | assert_eq!(json_of(d, &["project", "list", "--json"])[0]["name"], "oxygen"); |
|
| 30 | + | let posted = json_of(d, &["say", "--json", "-c", "general", "the release is tomorrow @claude-oxygen"]); |
|
| 31 | + | assert_eq!(posted["channel"], "#general"); |
|
| 32 | + | assert!(json_of(d, &["inbox", "--json"]).as_array().unwrap().is_empty()); |
|
| 33 | + | assert_eq!(json_of(d, &["search", "--json", "release"]).as_array().unwrap().len(), 1); |
|
| 34 | + | let v = json_of(d, &["admin", "verify", "--json"]); |
|
| 35 | + | assert!(v["findings"].as_array().unwrap().is_empty(), "a CLI post verifies: {v}"); |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | #[tokio::test] |
|
| 39 | + | async fn a_terminal_chat_with_the_agents() { |
|
| 40 | + | let dir = setup(); |
|
| 41 | + | let d = dir.path(); |
|
| 42 | + | let claude = add_agent(d, "claude-oxygen", &[]); |
|
| 43 | + | let srv = Serving::start(d); |
|
| 44 | + | let a = Client::new(&srv.socket, Some(claude)); |
|
| 45 | + | ||
| 46 | + | // Through the running daemon: say, and the api proxy. |
|
| 47 | + | assert_eq!(json_of(d, &["say", "--json", "hello from the terminal"])["channel"], "#general"); |
|
| 48 | + | let api: Value = serde_json::from_slice(&run(d, &["api", "GET", "/api/v1/brief"]).stdout).unwrap(); |
|
| 49 | + | assert_eq!(api["actor"], "human"); |
|
| 50 | + | ||
| 51 | + | let mut follow = hermetic(d).args(["inbox", "--follow", "--json"]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); |
|
| 52 | + | let mut lines = BufReader::new(follow.stdout.take().unwrap()).lines(); |
|
| 53 | + | tokio::time::sleep(Duration::from_millis(500)).await; |
|
| 54 | + | let say = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "say", "arguments": { "body": "@andy the build is green", "channel": "general" } } }); |
|
| 55 | + | assert_eq!(a.mcp("2025-11-25", &say).await.unwrap().status, 200); |
|
| 56 | + | let got = tokio::task::spawn_blocking(move || lines.next()).await.unwrap().expect("a line").unwrap(); |
|
| 57 | + | let item: Value = serde_json::from_str(&got).unwrap(); |
|
| 58 | + | assert_eq!(item["from"], "agent:claude-oxygen"); |
|
| 59 | + | assert!(item["summary"].as_str().unwrap().contains("the build is green")); |
|
| 60 | + | follow.kill().ok(); |
|
| 61 | + | follow.wait().ok(); |
|
| 62 | + | srv.stop(); |
|
| 63 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: the human at a terminal (#12).** `spectra say [-c CHANNEL] [--reply-to ID] BODY`, `spectra inbox [--follow]`, `spectra search QUERY [--channel] [--author] [--since 7d]` and `spectra api METHOD PATH [-d JSON]`, each signing with the owner's own key: through the daemon when it is serving, else straight into the ledger. Every listing takes `--json` (one document); `say` and `inbox --follow` make a terminal chat with the agents. |
|
| 5 | 6 | - **Added: the JSON API and its TypeScript types (#11).** `/api/v1` has health, version, brief, agents, projects, channels, a channel's messages (read and post), threads, the inbox and ack, search, and the event log, which follows live as SSE (`?follow=1`). Requests are signed, or a read carries the owner's Radar session; refusals are RFC 7807 problems; lists page with `?after=`; reads carry an ETag. `crates/spectra-api` holds the types, and `make ts` writes `ts/index.d.ts` for the editor extensions. |
|
| 6 | 7 | - **Fixed: a client hanging up mid-request could stop the ledger saving anything.** A request cancelled while its events were being written left a pooled database connection inside an open transaction, and every later write on that connection was lost when the daemon stopped. Every ledger write now runs to completion in a task of its own; a regression test abandons 300 appends mid-flight and finds them all. |
|
| 7 | 8 | - **Added: Radar, the owner's window (#10).** `spectra open` (or `spectra serve --open`) signs a one-time link with the owner's key; the browser session it opens shows Live (each agent with its owner, presence, status and last call; the inbox; recent events), Channels, a channel or thread with a compose box, and the Log, with each event's hash, proof and level and a Verify now button. Pages refresh from an event stream that carries ids, never data, and work without JavaScript; posts need the session's CSRF token and Spectra's own origin and are recorded as the owner's, with the signed link as proof. |
|
| … | 10 unchanged lines |
modified crates/spectra-mcp/src/client.rs +36 −0
| … | 86 unchanged lines |
||
| 87 | 87 | } |
|
| 88 | 88 | } |
|
| 89 | 89 | ||
| 90 | + | impl Client { |
|
| 91 | + | /// Send a signed GET and hand each line of the streamed body to `line` |
|
| 92 | + | /// as it arrives (an SSE stream, say); stop when it returns false. |
|
| 93 | + | pub async fn stream(&self, path: &str, mut line: impl FnMut(&str) -> bool) -> Result<u16> { |
|
| 94 | + | let stream = tokio::net::UnixStream::connect(&self.socket) |
|
| 95 | + | .await |
|
| 96 | + | .with_context(|| format!("connecting to {} (is `spectra serve` running?)", self.socket.display()))?; |
|
| 97 | + | let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?; |
|
| 98 | + | tokio::spawn(async move { |
|
| 99 | + | let _ = conn.await; |
|
| 100 | + | }); |
|
| 101 | + | let mut req = hyper::Request::builder().method("GET").uri(path).header("host", "spectra").header("connection", "close"); |
|
| 102 | + | if let Some(pair) = &self.signer { |
|
| 103 | + | for (k, v) in sign_headers(pair, &format!("GET {path}"), b"") { |
|
| 104 | + | req = req.header(k, v); |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | let resp = sender.send_request(req.body(Full::new(Bytes::new()))?).await?; |
|
| 108 | + | let status = resp.status().as_u16(); |
|
| 109 | + | let mut body = resp.into_body(); |
|
| 110 | + | let mut buf = String::new(); |
|
| 111 | + | while let Some(frame) = body.frame().await { |
|
| 112 | + | if let Ok(data) = frame?.into_data() { |
|
| 113 | + | buf.push_str(&String::from_utf8_lossy(&data)); |
|
| 114 | + | while let Some(i) = buf.find('\n') { |
|
| 115 | + | let l: String = buf.drain(..=i).collect(); |
|
| 116 | + | if !line(l.trim_end()) { |
|
| 117 | + | return Ok(status); |
|
| 118 | + | } |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | } |
|
| 122 | + | Ok(status) |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | ||
| 90 | 126 | /// The signature headers of spec §8.5 for `method_path` and `body`. |
|
| 91 | 127 | pub fn sign_headers(pair: &KeyPair, method_path: &str, body: &[u8]) -> Vec<(&'static str, String)> { |
|
| 92 | 128 | let p = Proof::sign(pair, method_path, body); |
|
| … | 25 unchanged lines |
modified docs/plan.md +1 −1
| … | 24 unchanged lines |
||
| 25 | 25 | - [x] #9 The stdio shim — done 2026-11-02: spectra mcp --agent NAME, driven over pipes in tests/cli_mcp.rs. Real Claude Code, Antigravity and Kimi connections are checked in #13 with Andy, since they need his MCP configuration. |
|
| 26 | 26 | - [x] #10 Radar — done 2026-11-02: Live, Channels, channel/thread with compose, Log with Verify now; spectra open; checked by hand in Chrome on a demo Spectra. That demo found the ledger cancellation bug (fixed, regression test fails on the old code) and a referrer policy that blanked the Origin of form posts. |
|
| 27 | 27 | - [x] #11 JSON API and spectra-api types — done 2026-11-02: every 0.1 endpoint, problems, paging, ETags, the live log; types and ts/index.d.ts. How spectra-vscode will sign is spec open question 11. |
|
| 28 | - | - [ ] #12 Human CLI |
|
| 28 | + | - [x] #12 Human CLI — done 2026-11-02: say, inbox --follow, search, api (the signing proxy for spectra-vscode); tests/cli_json.rs parses every --json output with and without the daemon. |
|
| 29 | 29 | - [ ] #13 Acceptance (with Andy) |
|
| 30 | 30 | ||
| 31 | 31 | ## Log |
|
| … | 5 unchanged lines |
modified src/cli/mod.rs +58 −0
| … | 4 unchanged lines |
||
| 5 | 5 | pub mod admin; |
|
| 6 | 6 | pub mod agent; |
|
| 7 | 7 | pub mod format; |
|
| 8 | + | pub mod human; |
|
| 8 | 9 | pub mod identity; |
|
| 9 | 10 | pub mod json; |
|
| 10 | 11 | pub mod marks; |
|
| … | 72 unchanged lines |
||
| 83 | 84 | #[arg(long)] |
|
| 84 | 85 | home: Option<PathBuf>, |
|
| 85 | 86 | }, |
|
| 87 | + | /// Post a message as yourself |
|
| 88 | + | Say { |
|
| 89 | + | /// What to say (Markdown; @name mentions) |
|
| 90 | + | body: String, |
|
| 91 | + | /// The channel (default: #general) |
|
| 92 | + | #[arg(short, long)] |
|
| 93 | + | channel: Option<String>, |
|
| 94 | + | /// Reply in the thread of this message |
|
| 95 | + | #[arg(long)] |
|
| 96 | + | reply_to: Option<String>, |
|
| 97 | + | #[arg(long)] |
|
| 98 | + | json: bool, |
|
| 99 | + | #[arg(long)] |
|
| 100 | + | owner_home: Option<PathBuf>, |
|
| 101 | + | }, |
|
| 102 | + | /// Your unread mentions and direct messages; --follow waits for more |
|
| 103 | + | Inbox { |
|
| 104 | + | #[arg(long)] |
|
| 105 | + | follow: bool, |
|
| 106 | + | #[arg(long)] |
|
| 107 | + | json: bool, |
|
| 108 | + | #[arg(long)] |
|
| 109 | + | owner_home: Option<PathBuf>, |
|
| 110 | + | }, |
|
| 111 | + | /// Search what was said: words, "phrases", prefix*, AND/OR/NOT |
|
| 112 | + | Search { |
|
| 113 | + | query: String, |
|
| 114 | + | #[arg(long)] |
|
| 115 | + | channel: Option<String>, |
|
| 116 | + | #[arg(long)] |
|
| 117 | + | author: Option<String>, |
|
| 118 | + | /// 7d, 3h, 30m |
|
| 119 | + | #[arg(long)] |
|
| 120 | + | since: Option<String>, |
|
| 121 | + | #[arg(long, default_value_t = 20)] |
|
| 122 | + | limit: u32, |
|
| 123 | + | #[arg(long)] |
|
| 124 | + | json: bool, |
|
| 125 | + | }, |
|
| 126 | + | /// Any API call, signed as the owner: JSON out |
|
| 127 | + | Api { |
|
| 128 | + | method: String, |
|
| 129 | + | path: String, |
|
| 130 | + | /// The request body (JSON) |
|
| 131 | + | #[arg(short = 'd', long = "body")] |
|
| 132 | + | body: Option<String>, |
|
| 133 | + | #[arg(long)] |
|
| 134 | + | owner_home: Option<PathBuf>, |
|
| 135 | + | }, |
|
| 86 | 136 | /// The checkouts agents work in: add, list |
|
| 87 | 137 | Project { |
|
| 88 | 138 | #[command(subcommand)] |
|
| … | 223 unchanged lines |
||
| 312 | 362 | } |
|
| 313 | 363 | Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)), |
|
| 314 | 364 | Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)), |
|
| 365 | + | Command::Say { body, channel, reply_to, json, owner_home } => { |
|
| 366 | + | runtime()?.block_on(human::say(&ctx, channel, reply_to, body, owner_home, json)) |
|
| 367 | + | } |
|
| 368 | + | Command::Inbox { follow, json, owner_home } => runtime()?.block_on(human::inbox(&ctx, follow, owner_home, json)), |
|
| 369 | + | Command::Search { query, channel, author, since, limit, json } => { |
|
| 370 | + | runtime()?.block_on(human::search(&ctx, &query, channel, author, since, limit, json)) |
|
| 371 | + | } |
|
| 372 | + | Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)), |
|
| 315 | 373 | Command::Project { command } => runtime()?.block_on(async { |
|
| 316 | 374 | match command { |
|
| 317 | 375 | ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await, |
|
| … | 38 unchanged lines |