OXYGENHUB

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