Skip to main content

choir/
main.rs

1//! `choir` — the agent-facing command line for a choir node.
2//!
3//! Everything the templates teach an agent to do by hand (mint a key,
4//! provision a workspace, sign and submit ops, run a review round) as
5//! one binary. All HTTP shells out to `curl`; the daemon base URL is a
6//! positional argument (no crate reads environment variables).
7//!
8//! ```text
9//! choir [--auth-file <path>] [--auth-user <name>] <command> ...
10//! choir key <key-file> [name]
11//! choir git-credential <auth-file> [--auth-user <name>] get|store|erase
12//! choir invite <api> <name> <owner/repo> [read|write]
13//! choir asks <api>
14//! choir grant <api> <request-id> <owner/repo> [read|write]
15//! choir decline <api> <request-id>
16//! choir join <link> | <api> <invite-file> <key-file> [--user <name>] [--channel <name>] [--key-file <path>] [--ssh-key <path>] [--token-file <path>]
17//! choir workspace <api> <owner/repo> <name> [--base <git-oid> --owner <channel> --key-file <path> --change <id> --idempotency-key <key>]
18//! choir checkpoint <api> <key-file> <channel> <change-id> <workspace-id> <git-oid>
19//! choir propose [reviewer]... [--key-file <path>] [--channel <name>] [--api <url>] [--repo <owner/repo>] [--onto <branch>]
20//! choir workspace-archive <api> <key-file> <channel> <owner/repo> <name> <change-id> <idempotency-key>
21//! choir submit <api> <key-file> <channel> '<op-json>'
22//! choir review <api> <key-file> <channel> <id> <git-oid> [--ref <repo:ref>] [reviewer]...
23//! choir verdict <api> <key-file> <reviewer> <id> approve|request-changes [note]
24//! choir slash <api> <node-key-file> <id> <reviewer> '<reason>'
25//! choir bind <api> <node-key-file> <operator> <key-hex> [channel]
26//! choir revoke <api> <node-key-file> <key-hex> '<reason>'
27//! choir appeal <api> <attempt-id>
28//! choir intent <api> <key-file> <channel> <subject> <kind> '<body>'
29//! choir check <api> <key-file> <channel> <git-oid> <name> passed|failed|running|errored [evidence] [--ref <repo:ref>]
30//! choir checks <api> <git-oid>
31//! choir reviews <api> <reviewer>
32//! choir acl render <api> <acl-file>
33//! choir view <api>
34//! choir triage <api>
35//! choir funnel <api>
36//! choir state <api> <channel>
37//! choir skill install [--into <dir>]
38//! choir repair <log-file> --verify | --truncate-tail
39//! ```
40//!
41//! Exit codes: 0 = the node accepted, 1 = the node rejected (the JSON
42//! error body is printed), 2 = usage error. `choir checks` adds 3 = the
43//! answer is not decided yet and 4 = a check could not be run at all,
44//! which is our fault rather than the commit's and wants a re-run
45//! rather than a rewrite; see [`check_exit`].
46//!
47//! `choir doctor` reads the same two codes as everything else — 0
48//! nothing required is missing, 1 something is — so that `choir doctor
49//! && choir propose …` means what it looks like it means. A degraded
50//! but working machine exits 0; see [`choir_cli::doctor`].
51
52use choir_hash::ContentHash;
53use choir_identity::{ActorKey, Registry};
54use choir_node::platform::hex_decode;
55use choir_view::{
56    reviewer_operator, ArchiveAuthorization, CheckStatus, CreateAuthorization, OpKind, Verdict,
57    ViewOp,
58};
59
60#[derive(Clone, Copy)]
61struct AuthOptions<'a> {
62    file: Option<&'a str>,
63    user: Option<&'a str>,
64    /// Whether either flag was actually given.
65    ///
66    /// Separate from the two options because [`AuthOptions::file_for`]
67    /// fills `file` in from the layout `choir init` wrote, and the
68    /// commands that refuse to take a credential at all — `init`,
69    /// `join`, `key`, `git-credential` — must keep asking "did the
70    /// reader pass one", not "is there one".
71    explicit: bool,
72}
73
74/// The credential `choir init` wrote, found once.
75///
76/// A `OnceLock` rather than a lookup per call: every command that
77/// reaches the node asks, and the answer is a `stat` on a path that
78/// cannot change while the process runs.
79static DEFAULT_AUTH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
80
81/// `~/.choir/auth` if it exists, or whatever `.choir/config` names.
82///
83/// `HOME` describes the machine rather than carrying a setting of ours,
84/// the same reason `choir init` may read it.
85fn discovered_auth_file() -> Option<String> {
86    if let Some(named) = configured("auth") {
87        return Some(named);
88    }
89    let path = std::path::PathBuf::from(std::env::var_os("HOME")?)
90        .join(".choir")
91        .join("auth");
92    path.exists().then(|| path.display().to_string())
93}
94
95/// Whether a bearer token may be sent to this node without being asked
96/// for by name.
97///
98/// Loopback, or the node `.choir/config` points at. An explicit
99/// `--auth-file` is the reader saying which credential goes where and
100/// is never second-guessed; this is only about the *implicit* one, and
101/// an implicit credential must not follow a URL that merely happened to
102/// be typed after a command that takes one.
103fn may_hold_credential(api: &str) -> bool {
104    let rest = api.split_once("://").map_or(api, |(_, rest)| rest);
105    let host = rest.split('/').next().unwrap_or("");
106    let name = host.rsplit_once(':').map_or(host, |(name, _)| name);
107    if matches!(name, "127.0.0.1" | "localhost" | "::1" | "[::1]") {
108        return true;
109    }
110    configured("node").is_some_and(|node| node.trim_end_matches('/') == api.trim_end_matches('/'))
111}
112
113impl<'a> AuthOptions<'a> {
114    fn is_empty(self) -> bool {
115        !self.explicit
116    }
117
118    /// The credential to use when talking to `api`.
119    ///
120    /// What was given, else the one `choir init` wrote — so the command
121    /// that creates a node and the commands that read it agree without
122    /// a path being typed in between. Before this, every one of them
123    /// answered 401 on a node the same tool had just set up.
124    fn file_for(self, api: &str) -> Option<&'a str> {
125        if self.file.is_some() {
126            return self.file;
127        }
128        if !may_hold_credential(api) {
129            return None;
130        }
131        DEFAULT_AUTH.get_or_init(discovered_auth_file).as_deref()
132    }
133}
134
135/// A short human summary on stderr, when a person is looking.
136///
137/// Never on stdout. The commands that call this answer with JSON, and
138/// that JSON is read by agents, `jq`, and the tests -- so it stays byte
139/// for byte what it was, whatever is attached. This is the second
140/// audience: someone at a prompt who has just run their first choir
141/// command and would otherwise be reading a brace.
142///
143/// Nothing here is load-bearing. A reader who redirects stderr loses
144/// decoration and no information: every field printed is already in the
145/// document on stdout.
146fn note(heading: &str, rows: &[(&str, String)]) {
147    let style = choir_cli::style::Style::for_stderr();
148    if !style.is_painted() {
149        return;
150    }
151    let width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
152    eprintln!("\n  {}", style.green(heading));
153    for (key, value) in rows {
154        let key = format!("{key:width$}");
155        eprintln!("  {}  {value}", style.dim(&key));
156    }
157    eprintln!();
158}
159
160/// Both spellings of the request for help.
161///
162/// `-h` is what a reader tries when `--help` has not occurred to them
163/// yet, and answering it with "not a choir command" refuses the one
164/// question every command line has to answer.
165fn is_help(argument: &str) -> bool {
166    argument == "--help" || argument == "-h"
167}
168
169/// The command word this invocation was reaching for, if any.
170///
171/// Reads the process arguments again rather than being handed them:
172/// [`usage`] is called from a dozen places, most of them deep inside a
173/// command's own flag parsing where the name has long since been
174/// destructured away, and threading it through all of them would be a
175/// dozen chances to pass the wrong one.
176fn invoked_command() -> Option<String> {
177    let args: Vec<String> = std::env::args().skip(1).collect();
178    // The same two-argument skip [`parse_auth`] performs, so that
179    // `choir --auth-file f frobnicate` names `frobnicate` and not `f`.
180    let mut index = 0;
181    while matches!(
182        args.get(index).map(String::as_str),
183        Some("--auth-file" | "--auth-user")
184    ) {
185        index += 2;
186    }
187    let first = args.get(index)?.clone();
188    // A reader who typed a two-word command should be told about the
189    // command they typed, not about its first word. Read from the
190    // surface table rather than listed here: this was written when `acl
191    // render` was the only two-word name and hardcoded it, so by the
192    // time there were eight, `choir repo list` with no node configured
193    // answered "`repo` is not a choir command" — naming a word the
194    // reader had not got wrong, and suggesting `repo url`.
195    if let Some(second) = args.get(index + 1) {
196        let two = format!("{first} {second}");
197        if choir_cli::surface::COMMANDS.iter().any(|c| c.name == two) {
198            return Some(two);
199        }
200    }
201    // One word that is only ever the first half of a two-word name is
202    // still worth naming as such: `choir repo` is not a mistyped
203    // command, it is an unfinished one.
204    if choir_cli::surface::COMMANDS
205        .iter()
206        .any(|c| c.name.starts_with(&format!("{first} ")))
207    {
208        return Some(first);
209    }
210    Some(first)
211}
212
213/// Refuses an invocation, saying the smallest true thing about it.
214///
215/// The wall of thirty-two commands is the right answer to "what can this
216/// do" and the wrong answer to every other question. A reader who
217/// mistyped a name needs that name and a candidate; a reader who got a
218/// known command's arguments wrong needs *that command's* spec, which
219/// the index does not carry. Printing the index at all three was the
220/// same as printing nothing: the one line that mattered was buried
221/// forty lines from the top, above the prompt, off the screen.
222fn usage() -> ! {
223    let style = choir_cli::style::Style::for_stderr();
224    match invoked_command() {
225        // No command at all. On a machine that has been set up, the
226        // index is the question this answers. On one that has not, it is
227        // the wrong first screen: forty-eight commands, of which exactly
228        // one is any use to somebody holding an invite link.
229        None if choir_cli::join::Role::of(&state_dir(), discovered_auth_file().as_deref())
230            == choir_cli::join::Role::Nothing =>
231        {
232            eprint!("{}", choir_cli::join::orientation(style));
233        }
234        None => eprint!("{}", choir_cli::surface::usage_in(style)),
235        Some(name) => match choir_cli::surface::command_help_in(&name, style) {
236            Some(help) => {
237                eprintln!(
238                    "{} those arguments do not match `{}`. It takes:\n",
239                    style.red("choir:"),
240                    name
241                );
242                eprint!("{help}");
243            }
244            None => {
245                // A word that only ever begins a two-word name is an
246                // unfinished command, not a wrong one, and the useful
247                // answer is the list of its halves rather than the
248                // nearest string to it. `choir repo` used to suggest
249                // `repo url`, which is one of the three things it could
250                // have meant and no more likely than the others.
251                let under: Vec<&str> = choir_cli::surface::COMMANDS
252                    .iter()
253                    .map(|c| c.name)
254                    .filter(|n| n.starts_with(&format!("{name} ")))
255                    .collect();
256                if under.is_empty() {
257                    eprintln!("{} `{}` is not a choir command.", style.red("choir:"), name);
258                    let names = choir_cli::surface::COMMANDS.iter().map(|c| c.name);
259                    if let Some(near) = choir_cli::style::nearest(&name, names) {
260                        eprintln!("       did you mean {}?", style.cyan(near));
261                    }
262                } else {
263                    eprintln!(
264                        "{} `{}` is not a command on its own. It has:",
265                        style.red("choir:"),
266                        name
267                    );
268                    for one in under {
269                        eprintln!("         {}", style.cyan(one));
270                    }
271                }
272                eprintln!("       {} lists every command.", style.cyan("choir --help"));
273            }
274        },
275    }
276    std::process::exit(2);
277}
278
279fn hex_encode(bytes: &[u8]) -> String {
280    bytes.iter().map(|b| format!("{b:02x}")).collect()
281}
282
283/// Refuses to sign an operator-only op with a key file that does not
284/// already exist.
285///
286/// [`load_key`] *creates* a key file when absent, which is right for an
287/// agent minting its own identity and wrong here: a typo'd path would
288/// silently mint a fresh key, and the node would reject the submission as
289/// an unknown signer rather than as the mistake it is.
290fn require_node_key_file(path: &str) {
291    if !std::path::Path::new(path).is_file() {
292        eprintln!("choir: <node-key-file> must name the node's existing key file");
293        std::process::exit(2);
294    }
295}
296
297/// Derives the actor id from a 64-character ed25519 public key hex.
298///
299/// This is the one derivation the node also performs, so binding a key
300/// never asks an operator to hand-compute a hash — they paste the same
301/// hex `choir key` printed and the trusted-keys file carries.
302fn actor_id_from_hex(key_hex: &str) -> choir_hash::ContentHash {
303    let bytes: Option<Vec<u8>> = (key_hex.len() == 64)
304        .then(|| {
305            (0..64)
306                .step_by(2)
307                .map(|i| u8::from_str_radix(&key_hex[i..i + 2], 16).ok())
308                .collect()
309        })
310        .flatten();
311    let Some(bytes) = bytes else {
312        eprintln!("choir: <key-hex> must be a 64-character ed25519 public key hex");
313        std::process::exit(2);
314    };
315    choir_hash::ContentHash::blake3(&bytes)
316}
317
318/// `choir repair <log-file> --verify | --truncate-tail`.
319///
320/// Run against a log **no daemon is holding**. Nothing here takes a lock,
321/// because the only safe way to repair a log is for nothing to be
322/// appending to it, and a lock would suggest otherwise.
323///
324/// Exit codes follow the binary's convention: 0 the log is usable (or was
325/// made usable), 1 it is damaged and this cannot fix it, 2 usage.
326fn repair(log_file: &str, flags: &[&str]) {
327    let path = std::path::Path::new(log_file);
328    let verify = flags.contains(&"--verify");
329    let truncate = flags.contains(&"--truncate-tail");
330    if verify == truncate {
331        // Neither, or both. Both is the interesting one: it reads like
332        // "check and then fix", which is exactly the compound action the
333        // operator is supposed to be choosing between.
334        eprintln!(
335            "choir repair <log-file> --verify | --truncate-tail\n\
336             \n\
337               --verify         walk the chain and report; changes nothing\n\
338               --truncate-tail  quarantine a partly written final record and\n\
339                               cut the log back to the last complete one\n\
340             \n\
341             Exactly one mode, and no default: which of these happens to your\n\
342             log is not a decision this tool should make for you."
343        );
344        std::process::exit(2);
345    }
346
347    let report = match choir_oplog::repair::verify(path) {
348        Ok(report) => report,
349        Err(error) => {
350            eprintln!("choir: cannot read {log_file}: {error:?}");
351            std::process::exit(1);
352        }
353    };
354
355    println!("{log_file}");
356    println!("  intact records: {}", report.intact_records);
357    match &report.head {
358        Some(head) => println!("  head:           {}", head.to_hex()),
359        None => println!("  head:           (empty log)"),
360    }
361    if report.torn_tail_bytes > 0 {
362        println!(
363            "  torn tail:      {} bytes, never acknowledged to any client",
364            report.torn_tail_bytes
365        );
366    }
367
368    if let Some(fault) = &report.fault {
369        // Mid-log damage. Say what and where, then say the only thing
370        // that actually helps -- and do not offer to truncate, because
371        // truncating past this point would drop ops that were
372        // acknowledged to somebody.
373        println!("  FAULT:          {fault}");
374        eprintln!(
375            "\nThis is damage to a record that was written whole, not an interrupted\n\
376             write, so cutting the end of the file cannot repair it: every record\n\
377             after position {} was acknowledged to a client.\n\
378             \n\
379             Restore from backup:\n\
380               1. stop the node (it will refuse to start on this log anyway)\n\
381               2. keep this file -- do not delete it; it is the only copy of\n\
382                  whatever is still readable\n\
383               3. restore the log from the most recent backup\n\
384               4. verify the restored copy with `choir repair <log> --verify`\n\
385                  before starting the node on it",
386            fault.position()
387        );
388        std::process::exit(1);
389    }
390
391    if verify {
392        if report.torn_tail_bytes > 0 {
393            println!(
394                "\nUsable. The torn tail is repairable: re-run with --truncate-tail,\n\
395                 or simply start the node, which repairs it on open."
396            );
397        } else {
398            println!("\nIntact.");
399        }
400        return;
401    }
402
403    match choir_oplog::repair::truncate_tail(path) {
404        Ok(None) => println!("\nNothing to repair; the log already ends on a record boundary."),
405        Ok(Some(repaired)) => println!(
406            "\nRepaired.\n  quarantined:    {} ({} bytes)\n  log length:     {}\n\n\
407             The removed bytes are in that file, not deleted. Verify before\n\
408             starting the node: choir repair {log_file} --verify",
409            repaired.quarantine.display(),
410            repaired.bytes,
411            repaired.length
412        ),
413        Err(error) => {
414            eprintln!("choir: repair refused: {error:?}");
415            std::process::exit(1);
416        }
417    }
418}
419
420/// Loads the 32-byte secret key file, creating it (0600) if absent.
421fn load_key(path: &str) -> ActorKey {
422    if std::path::Path::new(path).exists() {
423        let bytes = std::fs::read(path).expect("read key file");
424        ActorKey::from_secret_bytes(&bytes.as_slice().try_into().expect("32-byte key file"))
425    } else {
426        let key = ActorKey::generate();
427        // Atomic and 0600 from creation: no window where the secret is
428        // world-readable or half-written.
429        choir_fs::write_atomic_private(std::path::Path::new(path), key.secret_bytes())
430            .expect("write key file");
431        key
432    }
433}
434
435/// One HTTP round trip through the shared endpoint and auth adapter.
436fn http(
437    api: &str,
438    auth: AuthOptions<'_>,
439    tool: &str,
440    arguments: serde_json::Value,
441) -> (u16, String) {
442    let client = match choir_cli::mcp::HttpClient::new(
443        api,
444        auth.file_for(api).map(std::path::Path::new),
445        auth.user,
446    ) {
447        Ok(client) => client,
448        Err(error) => {
449            eprintln!("choir: {error}");
450            std::process::exit(2);
451        }
452    };
453    let endpoint = choir_cli::surface::mcp_endpoint(tool).expect("CLI endpoint is in the table");
454    match client.request(endpoint, &arguments) {
455        Ok(response) => response,
456        Err(error) => {
457            eprintln!("choir: {error}");
458            std::process::exit(1);
459        }
460    }
461}
462
463/// Fetches `/api/view` and applies a pure derivation to it, pretty-printed.
464///
465/// A non-2xx or non-JSON response is fatal before the derivation runs:
466/// classifying an error body would produce a confidently empty document,
467/// which reads as "nothing to do" — the worst possible failure mode for
468/// a next-actions surface.
469fn derived_view(
470    api: &str,
471    auth: AuthOptions<'_>,
472    derive: impl Fn(&serde_json::Value) -> serde_json::Value,
473) -> String {
474    let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
475    if !(200..300).contains(&status) {
476        eprintln!("choir: GET /api/view returned {status}: {body}");
477        std::process::exit(1);
478    }
479    let view: serde_json::Value = match serde_json::from_str(&body) {
480        Ok(view) => view,
481        Err(error) => {
482            eprintln!("choir: /api/view response is not JSON: {error}");
483            std::process::exit(1);
484        }
485    };
486    serde_json::to_string_pretty(&derive(&view)).expect("derived documents are serializable")
487}
488
489/// One authenticated call to the node, for the operator-side commands
490/// that are a request and a printed answer and nothing else.
491///
492/// Exists so the four D72 commands below do not each carry the same
493/// twenty lines of client construction and status handling, and so the
494/// exit codes they return cannot drift apart: 1 when the node refused,
495/// 2 when this machine could not ask.
496fn operator_call(
497    api: &str,
498    auth: AuthOptions<'_>,
499    method: &str,
500    path: &'static str,
501    body: &serde_json::Value,
502) -> serde_json::Value {
503    let endpoint = choir_cli::surface::endpoint(method, path)
504        .unwrap_or_else(|| panic!("{path} is in the endpoint table"));
505    let client = match choir_cli::mcp::HttpClient::new(
506        api,
507        auth.file_for(api).map(std::path::Path::new),
508        auth.user,
509    ) {
510        Ok(client) => client,
511        Err(error) => {
512            eprintln!("choir: {error}");
513            std::process::exit(2);
514        }
515    };
516    let (status, text) = match client.request(endpoint, body) {
517        Ok(response) => response,
518        Err(error) => {
519            eprintln!("choir: {error}");
520            std::process::exit(1);
521        }
522    };
523    let parsed = serde_json::from_str::<serde_json::Value>(&text).unwrap_or_default();
524    if !(200..300).contains(&status) {
525        eprintln!(
526            "choir: {method} {path} returned {status}: {}",
527            parsed["error"].as_str().unwrap_or(text.trim())
528        );
529        if status == 403 || status == 401 {
530            eprintln!("this needs a credential holding `@node write`.");
531        }
532        std::process::exit(1);
533    }
534    parsed
535}
536
537/// A repository argument as the ACL spells one.
538///
539/// `.git` is how a grant names a repository, and leaving it off is the
540/// mistake that mints an invite granting nothing. Added rather than
541/// refused, since there is exactly one right answer.
542fn grant_line(repo: &str, level: &str) -> String {
543    let repo = repo.strip_suffix(".git").unwrap_or(repo);
544    format!("{repo}.git {level}")
545}
546
547/// Refuses a level that is not one, before the node has to.
548fn checked_level(level: &str) -> &str {
549    match level {
550        "read" | "propose" | "write" => level,
551        other => {
552            eprintln!("choir: `{other}` is not a level; use read, propose or write");
553            std::process::exit(2);
554        }
555    }
556}
557
558/// `choir invite <api> <name> <owner/repo> [read|write]`
559///
560/// Prints the join link and nothing else, because the link is the whole
561/// artefact: it gets pasted into a chat window, and anything printed
562/// beside it invites pasting that too. The `id:secret` pair the API also
563/// returns is for `curl -u` and is not what a person receives.
564///
565/// `name` is a *display* name (D46). The account handle is minted by the
566/// node, so the string the op log carries forever is never one an
567/// operator typed at half past midnight.
568/// `choir host` — a fresh machine to a running node.
569///
570/// The composition, not a reimplementation: every step here is a call
571/// into the thing that already did it, and the value this adds is the
572/// order, the refusals between the steps, and the fact that a reader
573/// does not have to know which of three orders applies to their machine
574/// before they have run anything.
575///
576/// Two lines go to stdout at the end — the URL and, when one was asked
577/// for, the invite link — because those are the two things a person
578/// copies out of this. Everything else is stderr, so `choir host` inside
579/// a pipeline yields the addresses and nothing else.
580fn host(rest: &[&str]) -> ! {
581    let style = choir_cli::style::Style::for_stderr();
582    let options = match choir_cli::host::parse(rest, state_dir()) {
583        Ok(options) => options,
584        Err(error) => {
585            eprintln!("{} {error}", style.red("choir host:"));
586            std::process::exit(2);
587        }
588    };
589    let layout = choir_cli::serve::Layout::new(&options.state, options.port);
590    let user = choir_cli::host::username();
591    let mut done: Vec<String> = Vec::new();
592    let retry = rerun_line(rest);
593    eprintln!();
594
595    // 1. The state directory. Skipped rather than refused when it is
596    //    already there: `choir host` is the command people re-run after
597    //    pasting a sudo line, and a setup command that cannot be run
598    //    twice is one that strands them at step two.
599    if layout.missing().is_empty() {
600        step(
601            "state",
602            &format!("{} (already here, kept)", options.state.display()),
603        );
604    } else {
605        let plan = choir_cli::init::Plan::new(&options.state, options.port);
606        if let Err(error) = choir_cli::init::run(&plan, false) {
607            host_failed(&done, "state", &error, &retry);
608        }
609        step(
610            "state",
611            &format!(
612                "{} — credential, key, trusted keys",
613                options.state.display()
614            ),
615        );
616    }
617    done.push("state".to_string());
618
619    // The two files that make this a node other people can join, both
620    // created here rather than by `init`, because a node somebody is
621    // *hosting* is by definition one others are meant to reach, and a
622    // `--invite` that answers 503 is the whole command failing at its
623    // last line.
624    //
625    // They come as a pair and the daemon insists on it: an issued grant
626    // with no table to grade it against is a grant to every repository.
627    // The table this writes is the smallest one that is not a lie — the
628    // operator's own credential keeps everything, and every account
629    // issued afterwards holds exactly what its invite carried.
630    if !layout.acl.exists() {
631        let acl = "# Who may reach what (D29). `<user> <repo|*|@node> <level>`,\n\
632                   # levels read < propose < write < own. Issued grants (D36) are\n\
633                   # merged with this file; `own` and `@node` are granted here only.\n\
634                   choir * own\n\
635                   choir @node write\n";
636        if let Err(error) = choir_fs::write_atomic_private(&layout.acl, acl) {
637            host_failed(
638                &done,
639                "acl",
640                &format!("{}: {error}", layout.acl.display()),
641                &retry,
642            );
643        }
644    }
645    if !layout.accounts.exists() {
646        if let Err(error) = choir_fs::write_atomic_private(&layout.accounts, "") {
647            host_failed(
648                &done,
649                "accounts",
650                &format!("{}: {error}", layout.accounts.display()),
651                &retry,
652            );
653        }
654    }
655
656    // 2. The certificate, or the reason there is none.
657    let name = options.exposure.name();
658    match &name {
659        None => step("certificate", "not needed — this node binds loopback only"),
660        Some(name) => {
661            let sudo = tls_line(name, &user, options.port, options.dry_run);
662            match layout.tls() {
663                Some((cert, _)) if !options.dry_run && std::fs::File::open(&cert).is_ok() => {
664                    match choir_cli::tls::expiry(&cert) {
665                        Ok(when) => step(
666                            "certificate",
667                            &format!("already issued, valid until {when}"),
668                        ),
669                        Err(_) => step(
670                            "certificate",
671                            &format!("already issued — {}", cert.display()),
672                        ),
673                    }
674                }
675                _ => {
676                    let firewall = choir_cli::host::firewall_hint(options.port, true);
677                    let mut paste = vec![sudo];
678                    if let Some(line) = firewall {
679                        paste.push(line);
680                    }
681                    handover(
682                        &done,
683                        &format!(
684                            "a certificate for {name} has to be issued as root.\n  \
685                             certbot writes /etc/letsencrypt, and the renewal hook that keeps\n  \
686                             this working for the next two years lives there too. Paste this:"
687                        ),
688                        &paste,
689                        &retry,
690                    );
691                }
692            }
693            done.push("certificate".to_string());
694        }
695    }
696
697    // 3. Linger. A `systemd --user` unit without it is stopped when the
698    //    user logs out, which on a VPS is roughly one minute after the
699    //    node was installed.
700    match choir_cli::host::linger(&user) {
701        None | Some(true) => {}
702        Some(false) if options.yes => {
703            eprintln!(
704                "  {}  {:14}  off — this node will stop when {user} logs out",
705                style.cyan("!!"),
706                style.dim("linger")
707            );
708        }
709        Some(false) => handover(
710            &done,
711            &format!(
712                "linger is off for {user}, so systemd stops this node at logout.\n  \
713                 One line fixes it for the life of the machine:"
714            ),
715            &[format!("sudo loginctl enable-linger {user}")],
716            &format!("{retry}          (or add --yes to accept a node that dies at logout)"),
717        ),
718    }
719    if choir_cli::host::linger(&user) == Some(true) {
720        step("linger", &format!("on for {user}"));
721    }
722
723    // 4. The address, written down before the node starts, because it is
724    //    what every client command will read back — including the one
725    //    that mints the invite, whose link is built from the URL it was
726    //    reached at.
727    let url = options.exposure.url(options.port);
728    if let Err(error) = choir_fs::write_atomic(&layout.public_url, format!("{url}\n")) {
729        host_failed(
730            &done,
731            "address",
732            &format!("{}: {error}", layout.public_url.display()),
733            &retry,
734        );
735    }
736    if let Err(error) = choir_fs::write_atomic(
737        std::path::Path::new(".choir/config"),
738        format!(
739            "# Which node the `choir` commands talk to when they are not\n\
740             # given one. Written by `choir host`: this machine is the node.\n\
741             node = {url}\n"
742        ),
743    ) {
744        eprintln!("  {} .choir/config: {error}", style.cyan("!!"));
745    }
746    step("address", &url);
747
748    // 5. Supervision — or becoming the thing that would have been
749    //    supervised. In a container the runtime is the supervisor and
750    //    PID 1 should be the daemon, so this execs rather than installs.
751    //    Same `exec` as `choir node serve`: signals and the exit code
752    //    the daemon uses to ask for supervision (75) reach the real
753    //    process rather than a wrapper.
754    if options.foreground {
755        step(
756            "foreground",
757            "becoming the daemon; the runtime supervises it",
758        );
759        eprintln!();
760        let program = match choir_cli::serve::find_daemon() {
761            Ok(program) => program,
762            Err(error) => host_failed(&done, "foreground", &error, &retry),
763        };
764        match choir_cli::serve::plan(program, &layout, &[], &options.extra) {
765            Ok(invocation) => {
766                let error = choir_cli::serve::exec(&invocation);
767                host_failed(&done, "foreground", &error, &retry);
768            }
769            Err(error) => host_failed(&done, "foreground", &error, &retry),
770        }
771    }
772    match install_unit(&options.state, options.port, &options.extra) {
773        Ok((unit, _)) => step("supervised", &unit.display().to_string()),
774        Err(error) => host_failed(&done, "supervised", &error, &retry),
775    }
776    done.push("supervised".to_string());
777
778    // 6. Readiness. Everything after this is a request to a daemon the
779    //    service manager was asked to start a moment ago.
780    let client = match choir_cli::mcp::HttpClient::new(&url, Some(&layout.auth), None) {
781        Ok(client) => client,
782        Err(error) => host_failed(&done, "healthy", &error, &retry),
783    };
784    match choir_cli::host::wait_healthy(&client, 30) {
785        Ok(()) => step("healthy", &format!("{url}/healthz")),
786        // The log, not the retry line, is what answers this one: the
787        // unit is installed and the service manager is restarting it
788        // every two seconds into whatever it is failing on, and running
789        // `choir host` again would install the same unit again.
790        Err(error) => host_failed(
791            &done,
792            "healthy",
793            &format!(
794                "{error}\n           it says why in {}",
795                layout.log.display()
796            ),
797            &format!("choir node logs --state {}", options.state.display()),
798        ),
799    }
800    done.push("healthy".to_string());
801
802    // 7. A first repository, and a first person.
803    if let Some(repo) = &options.repo {
804        let endpoint = choir_cli::surface::endpoint("POST", "/api/repo")
805            .expect("the repo endpoint is in the table");
806        match client.request(endpoint, &serde_json::json!({ "name": repo })) {
807            Ok((status, _)) if (200..300).contains(&status) => {
808                step("repository", &format!("{url}/{repo}"));
809            }
810            Ok((409, _)) => step("repository", &format!("{repo} was already there")),
811            Ok((status, body)) => {
812                host_failed(&done, "repository", &format!("{status}: {body}"), &retry)
813            }
814            Err(error) => host_failed(&done, "repository", &error, &retry),
815        }
816        done.push("repository".to_string());
817    }
818    let mut link: Option<String> = None;
819    if let Some(who) = &options.invite {
820        let repo = options.repo.clone().unwrap_or_default();
821        let endpoint = choir_cli::surface::endpoint("POST", "/api/accounts/invite")
822            .expect("the invite endpoint is in the table");
823        let body = serde_json::json!({
824            "display_name": who,
825            "grants": [grant_line(&repo, "write")],
826        });
827        match client.request(endpoint, &body) {
828            Ok((status, text)) if (200..300).contains(&status) => {
829                let answer: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
830                let issued = answer["join_url"]
831                    .as_str()
832                    .or_else(|| answer["invite"].as_str())
833                    .unwrap_or_default()
834                    .to_string();
835                step("invited", who);
836                link = Some(issued);
837            }
838            Ok((status, body)) => {
839                host_failed(&done, "invited", &format!("{status}: {body}"), &retry)
840            }
841            Err(error) => host_failed(&done, "invited", &error, &retry),
842        }
843    }
844
845    // The advisories that are not steps: things this deliberately did
846    // not do, each with the one line that does it.
847    eprintln!();
848    if name.is_some() {
849        if let Some(line) = choir_cli::host::firewall_hint(options.port, true) {
850            eprintln!(
851                "  {} a firewall is running here and this command did not touch it.\n    {line}\n",
852                style.cyan("note:")
853            );
854        }
855    } else {
856        eprintln!(
857            "  {} nobody outside this machine can reach a loopback node. To share it:\n    {}\n",
858            style.dim("share:"),
859            choir_cli::host::share_hint(options.port)
860        );
861    }
862    eprintln!("  {} choir doctor\n", style.dim("check it:"));
863
864    // The two things a person copies out of this.
865    println!("{url}");
866    if let Some(link) = link {
867        println!("{link}");
868    }
869    std::process::exit(0)
870}
871
872/// The `sudo` line `choir host` asks for, spelled out.
873fn tls_line(domain: &str, user: &str, port: u16, dry_run: bool) -> String {
874    let mut line = format!("sudo choir node tls {domain} --user {user} --port {port}");
875    if dry_run {
876        line.push_str(" --dry-run");
877    }
878    line
879}
880
881/// The same `choir host` invocation, to print as the thing to run next.
882fn rerun_line(rest: &[&str]) -> String {
883    let mut line = "choir host".to_string();
884    for argument in rest {
885        line.push(' ');
886        line.push_str(argument);
887    }
888    line
889}
890
891/// `choir node tls` — obtain the certificate and wire up its renewal.
892///
893/// The only command in this binary that expects to be run as root, and
894/// the only one that writes outside the state directory. Both facts are
895/// checked before anything happens rather than discovered halfway
896/// through, because a half-done certificate step leaves a marker naming
897/// files that are not there — and the node reads that marker at every
898/// start.
899fn node_tls(rest: &[&str]) -> ! {
900    let style = choir_cli::style::Style::for_stderr();
901    let command = "choir node tls";
902    let mut domain: Option<&str> = None;
903    let mut user: Option<String> = None;
904    let mut port = 8417u16;
905    let mut issuance = choir_cli::tls::Issuance::Live;
906    let mut i = 0;
907    while i < rest.len() {
908        match rest[i] {
909            "--dry-run" => {
910                issuance = choir_cli::tls::Issuance::DryRun;
911                i += 1;
912            }
913            "--staging" => {
914                issuance = choir_cli::tls::Issuance::Staging;
915                i += 1;
916            }
917            flag @ ("--user" | "--port") => {
918                let Some(value) = rest.get(i + 1) else {
919                    eprintln!("{command}: {flag} needs a value");
920                    std::process::exit(2);
921                };
922                match flag {
923                    "--user" => user = Some((*value).to_string()),
924                    _ => match value.parse() {
925                        Ok(n) => port = n,
926                        Err(_) => {
927                            eprintln!("{command}: --port needs a port number, not {value:?}");
928                            std::process::exit(2);
929                        }
930                    },
931                }
932                i += 2;
933            }
934            other if !other.starts_with('-') && domain.is_none() => {
935                domain = Some(other);
936                i += 1;
937            }
938            other => {
939                eprintln!("{command}: unknown option {other:?}");
940                std::process::exit(2);
941            }
942        }
943    }
944    let Some(domain) = domain else {
945        eprintln!(
946            "{command}: which name is the certificate for?\n\n  \
947             sudo choir node tls <domain> --user <the account the node runs as>"
948        );
949        std::process::exit(2);
950    };
951    // Required, never inferred. Under `sudo` the running account is
952    // root, and falling back to `SUDO_USER` would name the operator's
953    // own account — which is exactly the account the node does not run
954    // as. The failure would be silent: a marker and a cert pair landing
955    // in the wrong home while the node keeps serving plaintext.
956    let Some(user) = user else {
957        eprintln!(
958            "{command}: --user is required — which unprivileged account does the node\n  \
959             run as? Under sudo this process is root, and guessing would put the\n  \
960             certificate in the wrong home while the node kept serving plaintext.\n\n  \
961             sudo choir node tls {domain} --user $(id -un)"
962        );
963        std::process::exit(2);
964    };
965    let home = home_of(&user).unwrap_or_else(|| {
966        eprintln!("{command}: no such user: {user}");
967        std::process::exit(1);
968    });
969    let plan = choir_cli::tls::Plan::new(domain, port, &user, &home);
970    let challenge = choir_cli::tls::Challenge::for_state(&plan.state);
971    let uid = choir_cli::tls::uid();
972    if let Err(problems) = choir_cli::tls::preflight(&plan, challenge, &uid) {
973        eprintln!("\n  {} {problems}\n", style.red(&format!("{command}:")));
974        std::process::exit(1);
975    }
976    eprintln!();
977    if challenge == choir_cli::tls::Challenge::Http01 {
978        eprintln!(
979            "  {} HTTP-01: port 80 must be reachable from the internet now and at\n         \
980             every renewal. There is no $HOME/.choir/cloudflare.ini here, which is\n         \
981             what selects DNS-01 instead.\n",
982            style.dim("method:")
983        );
984    }
985    let (steps, failure) = match choir_cli::tls::apply(&plan, challenge, issuance, &uid) {
986        Ok(steps) => (steps, None),
987        Err((steps, error)) => (steps, Some(error)),
988    };
989    for one in &steps {
990        match &one.outcome {
991            Ok(detail) => step(&one.what, detail),
992            Err(error) => eprintln!(
993                "  {}  {:14}  {error}",
994                style.red("--"),
995                style.dim(&one.what)
996            ),
997        }
998    }
999    if let Some(error) = failure {
1000        eprintln!(
1001            "\n  {} {error}\n\n  \
1002             nothing the node reads was changed, so it is still serving whatever it\n  \
1003             was serving before.\n",
1004            style.red("stopped:")
1005        );
1006        std::process::exit(1);
1007    }
1008    if issuance == choir_cli::tls::Issuance::DryRun {
1009        eprintln!(
1010            "\n  {} the path works and no certificate was issued. Run it again\n  \
1011             without --dry-run.\n",
1012            style.green("dry run:")
1013        );
1014        std::process::exit(0);
1015    }
1016    eprintln!(
1017        "\n  {} renewal is certbot's own timer; the hook above re-projects the pair\n  \
1018         and restarts the node, because the daemon reads its certificate once at\n  \
1019         bind and has no reload.\n\n  \
1020         {} back as {user}: choir host --domain {domain} --port {port}\n",
1021        style.dim("renewal:"),
1022        style.dim("then:")
1023    );
1024    println!("{}", plan.url());
1025    std::process::exit(0)
1026}
1027
1028/// One account's home directory, out of the password database.
1029///
1030/// `getent passwd` where it exists, falling back to `dscl` on macOS,
1031/// because `choir node tls` names an account other than the one running
1032/// it and `HOME` describes the wrong one.
1033fn home_of(user: &str) -> Option<std::path::PathBuf> {
1034    if let Some(out) = std::process::Command::new("getent")
1035        .args(["passwd", user])
1036        .output()
1037        .ok()
1038        .filter(|out| out.status.success())
1039    {
1040        let text = String::from_utf8_lossy(&out.stdout);
1041        let field = text.trim().split(':').nth(5)?;
1042        if !field.is_empty() {
1043            return Some(std::path::PathBuf::from(field));
1044        }
1045    }
1046    let out = std::process::Command::new("dscl")
1047        .args([".", "-read", &format!("/Users/{user}"), "NFSHomeDirectory"])
1048        .output()
1049        .ok()
1050        .filter(|out| out.status.success())?;
1051    let text = String::from_utf8_lossy(&out.stdout);
1052    let field = text.trim().strip_prefix("NFSHomeDirectory:")?.trim();
1053    match field.is_empty() {
1054        true => None,
1055        false => Some(std::path::PathBuf::from(field)),
1056    }
1057}
1058
1059fn invite(api: &str, auth: AuthOptions<'_>, name: &str, repo: &str, level: &str) -> ! {
1060    let answer = operator_call(
1061        api,
1062        auth,
1063        "POST",
1064        "/api/accounts/invite",
1065        &serde_json::json!({
1066            "display_name": name,
1067            "grants": [grant_line(repo, checked_level(level))],
1068        }),
1069    );
1070    match answer["join_url"].as_str() {
1071        Some(url) => println!("{url}"),
1072        // A node reached without a `Host` header gets no link rather
1073        // than a guessed one, so say what there is: the pair, which is
1074        // what `curl -u` wants.
1075        None => println!("{}", answer["invite"].as_str().unwrap_or_default()),
1076    }
1077    std::process::exit(0)
1078}
1079
1080/// `choir asks <api>` — the queue, oldest first.
1081///
1082/// One line each: the id to answer, how long they have been waiting, and
1083/// what they wrote. No address, because none was collected (D72).
1084fn asks(api: &str, auth: AuthOptions<'_>) -> ! {
1085    let answer = operator_call(api, auth, "GET", "/api/accounts", &serde_json::json!({}));
1086    let rows = answer["requests"]
1087        .as_array()
1088        .map(Vec::as_slice)
1089        .unwrap_or_default();
1090    if rows.is_empty() {
1091        println!("Nobody is waiting.");
1092        std::process::exit(0);
1093    }
1094    let now = std::time::SystemTime::now()
1095        .duration_since(std::time::UNIX_EPOCH)
1096        .map_or(0, |d| d.as_secs());
1097    for row in rows {
1098        let asked_at = row["asked_at"].as_u64().unwrap_or(now);
1099        let waited = now.saturating_sub(asked_at) / 3600;
1100        println!(
1101            "{}  {}  ({waited}h)  {}",
1102            row["request_id"].as_str().unwrap_or("?"),
1103            row["display_name"].as_str().unwrap_or("?"),
1104            row["about"].as_str().unwrap_or("")
1105        );
1106    }
1107    std::process::exit(0)
1108}
1109
1110/// `choir grant <api> <request-id> <owner/repo> [read|write]`
1111///
1112/// Nothing to send afterwards, and that is the point: the request
1113/// becomes an invite under the id and secret the asker already holds, so
1114/// the link they were given is the link that starts working.
1115fn grant(api: &str, auth: AuthOptions<'_>, id: &str, repo: &str, level: &str) -> ! {
1116    let answer = operator_call(
1117        api,
1118        auth,
1119        "POST",
1120        "/api/accounts/request/grant",
1121        &serde_json::json!({
1122            "request_id": id,
1123            "grants": [grant_line(repo, checked_level(level))],
1124        }),
1125    );
1126    println!(
1127        "{} is in as {}. The link they already hold now works; send nothing.",
1128        answer["display_name"].as_str().unwrap_or("they"),
1129        answer["user"].as_str().unwrap_or("?")
1130    );
1131    std::process::exit(0)
1132}
1133
1134/// `choir decline <api> <request-id>`
1135fn decline(api: &str, auth: AuthOptions<'_>, id: &str) -> ! {
1136    operator_call(
1137        api,
1138        auth,
1139        "POST",
1140        "/api/accounts/request/decline",
1141        &serde_json::json!({ "request_id": id }),
1142    );
1143    println!("Declined. Their link now reads as one that was never valid.");
1144    std::process::exit(0)
1145}
1146
1147/// Rewrites an ACL file's trailing comments from the node's roster (D46).
1148///
1149/// The decisions all live in [`choir_cli::acl::render`]; this is the IO
1150/// around it. Three things it deliberately does:
1151///
1152/// - **Reads the roster before touching the file.** A failed fetch must
1153///   leave the ACL exactly as it was, because the failure mode of the
1154///   alternative is an authorization file emptied of its names by a
1155///   network error.
1156/// - **Writes only on a change**, so a re-run on a current file produces
1157///   no churn and no new mtime for the node's hot-reload to notice.
1158/// - **Prints the counts.** A file that renders no comments at all looks
1159///   exactly like a file whose handles are all unnamed, and the second
1160///   is the one worth knowing about.
1161fn acl_render(api: &str, auth: AuthOptions<'_>, acl_file: &str) -> ! {
1162    let endpoint = choir_cli::surface::endpoint("GET", "/api/accounts")
1163        .expect("the accounts roster is in the endpoint table");
1164    let client = match choir_cli::mcp::HttpClient::new(
1165        api,
1166        auth.file_for(api).map(std::path::Path::new),
1167        auth.user,
1168    ) {
1169        Ok(client) => client,
1170        Err(error) => {
1171            eprintln!("choir: {error}");
1172            std::process::exit(2);
1173        }
1174    };
1175    let (status, body) = match client.request(endpoint, &serde_json::json!({})) {
1176        Ok(response) => response,
1177        Err(error) => {
1178            eprintln!("choir: {error}");
1179            std::process::exit(1);
1180        }
1181    };
1182    if !(200..300).contains(&status) {
1183        eprintln!(
1184            "choir: GET /api/accounts returned {status}: {body}\n\
1185             reading the roster needs a credential with `@node auditor`."
1186        );
1187        std::process::exit(1);
1188    }
1189    let roster = match serde_json::from_str::<serde_json::Value>(&body) {
1190        Ok(doc) => doc["accounts"]
1191            .as_array()
1192            .map(Vec::as_slice)
1193            .unwrap_or_default()
1194            .iter()
1195            .filter_map(|account| {
1196                Some((
1197                    account["user"].as_str()?.to_string(),
1198                    account["display_name"].as_str()?.to_string(),
1199                ))
1200            })
1201            .collect::<choir_cli::acl::Roster>(),
1202        Err(error) => {
1203            eprintln!("choir: /api/accounts response is not JSON: {error}");
1204            std::process::exit(1);
1205        }
1206    };
1207
1208    let path = std::path::Path::new(acl_file);
1209    let before = match std::fs::read_to_string(path) {
1210        Ok(text) => text,
1211        Err(error) => {
1212            eprintln!("choir: cannot read {acl_file}: {error}");
1213            std::process::exit(1);
1214        }
1215    };
1216    let after = choir_cli::acl::render(&before, &roster);
1217    let (grants, named) = choir_cli::acl::counts(&before, &roster);
1218    let wrote = after != before;
1219    if wrote {
1220        // Private, not the plain atomic write: the replacement file is
1221        // created 0600 before any bytes reach it. An ACL file is 0600
1222        // by operator convention, and a rewrite that restored it at the
1223        // umask's mercy would widen an authorization file as a side
1224        // effect of making it readable.
1225        if let Err(error) = choir_fs::write_atomic_private(path, &after) {
1226            eprintln!("choir: cannot write {acl_file}: {error}");
1227            std::process::exit(1);
1228        }
1229    }
1230    let doc = serde_json::json!({
1231        "path": path.display().to_string(),
1232        "wrote": wrote,
1233        "grants": grants,
1234        "named": named,
1235        "unresolved": grants - named,
1236    });
1237    finish(200, &doc.to_string());
1238}
1239
1240/// Where the credential that redeems an invite came from.
1241///
1242/// Two shapes because there are two readers. An agent is handed a file
1243/// by whatever provisioned it; a person is handed a link in a chat
1244/// window, and the link *is* the credential — writing it to a file first
1245/// so this could read it back would be asking them to do by hand the one
1246/// step this command exists to remove.
1247enum Invite<'a> {
1248    /// A file holding one `<id>:<secret>` line.
1249    File(&'a str),
1250    /// The two halves, lifted straight out of a join link.
1251    Pair(String, String),
1252}
1253
1254/// Creates `~/.choir` at 0700 if it is not there.
1255///
1256/// 0700 rather than the umask's answer because everything this directory
1257/// is about to hold — an actor key, a bearer token — is a secret, and a
1258/// directory somebody else can list is a directory whose filenames tell
1259/// them what to come back for.
1260fn ensure_state_dir() -> Result<(), String> {
1261    let dir = state_dir();
1262    if !dir.is_dir() {
1263        std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
1264    }
1265    #[cfg(unix)]
1266    {
1267        use std::os::unix::fs::PermissionsExt;
1268        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
1269            .map_err(|e| format!("chmod 700 {}: {e}", dir.display()))?;
1270    }
1271    Ok(())
1272}
1273
1274/// The `next` line out of a node's structured rejection.
1275///
1276/// Every rejection body carries `code`, `error` and `next` (see
1277/// `ERRORS.md`), and `next` is the only one of the three that says what
1278/// to do. Printing the body alone left it as the fourth field of a JSON
1279/// object, which is where a reader who is already stuck stops reading.
1280fn refusal_next(body: &str) -> Option<String> {
1281    let doc: serde_json::Value = serde_json::from_str(body).ok()?;
1282    doc.get("next")?.as_str().map(str::to_string)
1283}
1284
1285/// The repair for a refused redemption, worked out from the node's
1286/// message.
1287///
1288/// Reads the `error` string because this endpoint has no rejection code
1289/// to branch on. Substring matching is the weaker test and is used
1290/// knowingly: the fallback is a true sentence for every message that
1291/// does not match, so a reworded node message costs a specific line and
1292/// never produces a wrong one.
1293fn redeem_next(body: &str) -> String {
1294    let message = serde_json::from_str::<serde_json::Value>(body)
1295        .ok()
1296        .and_then(|doc| doc.get("error")?.as_str().map(str::to_string))
1297        .unwrap_or_default();
1298    if message.contains("no such invite") {
1299        return "ask the operator for a new link; this one was never valid, or has been used"
1300            .to_string();
1301    }
1302    if message.contains("expired") {
1303        return "ask the operator for a new link; this one has expired".to_string();
1304    }
1305    if message.contains("--invite-binds-keys") {
1306        return "send the operator the line `choir key <key-file> <channel>` prints, \
1307                and ask them to register it"
1308            .to_string();
1309    }
1310    if message.contains("already taken") || message.contains("username") {
1311        return "choir join '<link>' --user <another-name>".to_string();
1312    }
1313    "send the operator that message; it describes their node, not your machine".to_string()
1314}
1315
1316/// `choir join <link>`, or `choir join <api> <invite-file> <key-file>`
1317///
1318/// Admission in one command: mint an actor key, redeem the operator's
1319/// invite, store the token where the rest of the CLI reads it, and — for
1320/// the link form — leave git and `~/.choir/config` set up so that the
1321/// next thing the reader types is `git clone` and the thing after it is
1322/// `choir propose`.
1323///
1324/// **The link is the whole input.** It carries the node and the invite,
1325/// which is why the one thing a person was actually sent is now the one
1326/// thing they have to paste. It reaches `curl` over stdin like every
1327/// other credential here, never on an argv, where `ps` would show it.
1328///
1329/// The three-argument form stays because agents and provisioning
1330/// scripts hold those paths. It answers with JSON, writes the token
1331/// beside the key rather than into `~/.choir`, and touches neither git
1332/// nor the home directory: a command that edits `~/.gitconfig` without
1333/// being asked is one nobody can run under an automation account twice.
1334///
1335/// What this does **not** do is decide admission. The operator issued
1336/// the invite, and the invite carries the grants; this only spares them
1337/// the second out-of-band step of pasting a key line into a file. A node
1338/// started without `--invite-binds-keys` refuses the key and says so,
1339/// and admission there still ends with an operator's edit.
1340fn join(api: &str, invite: Invite<'_>, key_file: Option<&str>, rest: &[&str]) -> ! {
1341    let (mut channel, mut ssh_key, mut token_file, mut chosen_user) = (None, None, None, None);
1342    let mut key_flag = None;
1343    let mut index = 0;
1344    while index < rest.len() {
1345        let Some(value) = rest.get(index + 1).copied() else {
1346            usage();
1347        };
1348        let slot = match rest[index] {
1349            "--channel" if channel.is_none() => &mut channel,
1350            "--ssh-key" if ssh_key.is_none() => &mut ssh_key,
1351            "--token-file" if token_file.is_none() => &mut token_file,
1352            // Says "yes, that path, I know what is there" -- the one way
1353            // past the refusal below, and the reason the refusal can be
1354            // flat rather than a prompt.
1355            "--key-file" if key_flag.is_none() => &mut key_flag,
1356            // The name this account will hold forever (D75). Required by
1357            // an invite that left the seat open, which is the ordinary
1358            // kind: the node no longer picks on anybody's behalf.
1359            "--user" if chosen_user.is_none() => &mut chosen_user,
1360            _ => usage(),
1361        };
1362        *slot = Some(value);
1363        index += 2;
1364    }
1365    let from_link = key_file.is_none();
1366    if from_link {
1367        if let Err(error) = ensure_state_dir() {
1368            eprintln!("choir join: {error}\nnext: choir join '<link>' --key-file <path>");
1369            std::process::exit(1);
1370        }
1371    }
1372    // Three ways to name the key, most explicit first.
1373    //
1374    // The default path is refused when this machine has *already
1375    // joined*, because a key is an identity the node has bound and
1376    // overwriting one silently would strand every operation the old one
1377    // signed. The test for "already joined" is the token beside it, not
1378    // the key alone: `load_key` mints before the request, so any
1379    // redemption that was refused -- a name this invite left open, a
1380    // node that does not bind keys, a network that dropped -- leaves a
1381    // key behind with no token. Refusing on the key alone made every one
1382    // of those refusals permanent, which is the opposite of what this
1383    // guard is for. A key with no token was never successfully redeemed,
1384    // so redeeming with it is exactly right, and is what makes
1385    // `load_key`'s idempotency reachable.
1386    let default_key = state_dir().join("agent.key").display().to_string();
1387    let key_file = match (key_flag, key_file) {
1388        (Some(named), _) | (None, Some(named)) => named.to_string(),
1389        (None, None) => {
1390            let joined =
1391                std::path::Path::new(&default_key).exists() && state_dir().join("auth").exists();
1392            if joined {
1393                eprintln!(
1394                    "choir join: this machine has already joined a node: there is a key at \
1395                     {default_key} and a token beside it.\n\
1396                     A key is an identity the node has bound, so this will not replace one.\n\
1397                     next: choir join '<link>' --key-file <another-path> --token-file <another-path>"
1398                );
1399                std::process::exit(1);
1400            }
1401            default_key
1402        }
1403    };
1404    let key_file = key_file.as_str();
1405    let invite_file = match invite {
1406        Invite::File(path) => {
1407            if !std::path::Path::new(path).is_file() {
1408                eprintln!(
1409                    "choir join: {path} does not exist.\n\
1410                     Write the invite the operator sent you into it, as one line: <id>:<secret>\n\
1411                     next: choir join '<link>'   (the link needs no file at all)"
1412                );
1413                std::process::exit(2);
1414            }
1415            Some(path)
1416        }
1417        Invite::Pair(..) => None,
1418    };
1419    // Minted before the request, so the key exists whatever the node
1420    // answers. `load_key` is idempotent, which makes a retry after a
1421    // network failure redeem the key that already exists rather than a
1422    // second one the operator never saw.
1423    let key = load_key(key_file);
1424    let mut body = serde_json::json!({ "actor_key": hex_encode(&key.public_key_bytes()) });
1425    if let Some(user) = chosen_user {
1426        body["user"] = serde_json::json!(user);
1427    }
1428    if let Some(channel) = channel {
1429        body["channel"] = serde_json::json!(channel);
1430    }
1431    if let Some(path) = ssh_key {
1432        match std::fs::read_to_string(path) {
1433            Ok(line) => body["ssh_key"] = serde_json::json!(line.trim()),
1434            Err(error) => {
1435                eprintln!("choir join: cannot read {path}: {error}");
1436                std::process::exit(2);
1437            }
1438        }
1439    }
1440
1441    let endpoint = choir_cli::surface::endpoint("POST", "/api/accounts/redeem")
1442        .expect("redemption is in the endpoint table");
1443    let client = match &invite {
1444        Invite::File(_) => {
1445            choir_cli::mcp::HttpClient::new(api, invite_file.map(std::path::Path::new), None)
1446        }
1447        Invite::Pair(id, secret) => choir_cli::mcp::HttpClient::with_credential(api, id, secret),
1448    };
1449    let client = match client {
1450        Ok(client) => client,
1451        Err(error) => {
1452            eprintln!("choir join: {error}\nnext: choir join '<link>'");
1453            std::process::exit(2);
1454        }
1455    };
1456    let send = |body: &serde_json::Value| match client.request(endpoint, body) {
1457        Ok(response) => response,
1458        Err(error) => {
1459            eprintln!(
1460                "choir join: {error}\n\
1461                 next: choir doctor   (it says which of curl, the network or the node is at fault)"
1462            );
1463            std::process::exit(1);
1464        }
1465    };
1466    let (mut status, mut response) = send(&body);
1467    // An invite that left the seat open is the ordinary kind, and the
1468    // node says so by refusing rather than by advertising it beforehand
1469    // -- so the name is asked for here, after the refusal, and only ever
1470    // once. The refusal happens before the invite is consumed, which is
1471    // what makes retrying it safe.
1472    if status == 400 && response.contains("this invite lets you pick your name") {
1473        match choir_cli::prompt::ask("Pick the name this account keeps (letters, digits, - and _):")
1474        {
1475            Some(name) => {
1476                body["user"] = serde_json::json!(name);
1477                (status, response) = send(&body);
1478            }
1479            None => {
1480                eprintln!(
1481                    "choir join: this invite lets you pick your name, and nothing here can \
1482                     ask for one.\n\
1483                     next: choir join '<link>' --user <name>"
1484                );
1485                std::process::exit(2);
1486            }
1487        }
1488    }
1489    if !(200..300).contains(&status) {
1490        println!("{response}");
1491        // `/api/accounts/redeem` predates the structured-rejection table
1492        // and answers with a bare `error`, so the repair is worked out
1493        // here rather than read off the body. Four of them, because
1494        // those are the four a contributor can actually reach: the rest
1495        // are the operator's node being misconfigured, and the generic
1496        // line is the true thing to say about those.
1497        eprintln!(
1498            "\nnext: {}",
1499            refusal_next(&response).unwrap_or_else(|| redeem_next(&response))
1500        );
1501        std::process::exit(1);
1502    }
1503    let account: serde_json::Value = match serde_json::from_str(&response) {
1504        Ok(account) => account,
1505        Err(error) => {
1506            // Both of these describe the operator's node rather than
1507            // this machine, so the repair is theirs and the `next:` says
1508            // whose it is. Retrying changes nothing.
1509            eprintln!(
1510                "choir join: the node's answer did not parse: {error}\n\
1511                 next: send the operator that line; their node answered something \
1512                 this version cannot read"
1513            );
1514            std::process::exit(1);
1515        }
1516    };
1517    let (Some(user), Some(token)) = (account["user"].as_str(), account["token"].as_str()) else {
1518        eprintln!(
1519            "choir join: the node issued no token\n\
1520             next: send the operator this; the invite was accepted and nothing was handed back"
1521        );
1522        std::process::exit(1);
1523    };
1524
1525    // An invite is single-use, so the token is shown exactly once and
1526    // this is the only chance to keep it. Written 0600 before anything
1527    // is printed: a token this process holds and never stored is one the
1528    // contributor has to ask for a second invite to replace.
1529    // The link form defaults to the path every other command already
1530    // looks for, which is what makes "and nothing else" true: a
1531    // credential written next to the key would need `--auth-file` typed
1532    // on every command after it. The positional form keeps the name it
1533    // has always written, because agents and scripts hold that path.
1534    let token_path = match (token_file, from_link) {
1535        (Some(named), _) => std::path::PathBuf::from(named),
1536        (None, true) => state_dir().join("auth"),
1537        (None, false) => std::path::Path::new(key_file)
1538            .parent()
1539            .unwrap_or(std::path::Path::new("."))
1540            .join("choir.auth"),
1541    };
1542    if let Err(error) = choir_fs::write_atomic_private(&token_path, format!("{user}:{token}\n")) {
1543        eprintln!(
1544            "choir join: the node issued a token but it could not be stored at {}: {error}\n\
1545             The invite is spent, so this token cannot be reissued.\n\
1546             next: ask the operator for another link, then run \
1547             `choir join '<link>' --token-file <a-writable-path>`",
1548            token_path.display()
1549        );
1550        std::process::exit(1);
1551    }
1552
1553    let bound = account["actor_key_bound"] == serde_json::Value::Bool(true);
1554    let channel = account["channel"].as_str().unwrap_or(user);
1555    // Only the link form writes to git and to `~/.choir`. The positional
1556    // form is what tests and provisioning scripts run, often as a user
1557    // whose `~/.gitconfig` belongs to somebody else's automation, and a
1558    // command that edits it without being asked is one nobody can run
1559    // twice safely.
1560    let (git_config, home_config) = match from_link {
1561        false => (None, None),
1562        true => (
1563            configure_git_credential(api, &token_path.display().to_string()),
1564            write_home_config(api, channel, key_file),
1565        ),
1566    };
1567    let next = if bound {
1568        "choir propose".to_string()
1569    } else {
1570        // Said as an instruction rather than a warning, because it is
1571        // the step that is still outstanding: nothing this contributor
1572        // does next will work until the operator registers the key.
1573        format!("choir key {key_file} {channel}   — send the operator that line")
1574    };
1575    let summary = serde_json::json!({
1576        "user": user,
1577        "channel": account["channel"],
1578        "grants": account["grants"],
1579        "auth_file": token_path.display().to_string(),
1580        "key_file": key_file,
1581        "actor_key_bound": bound,
1582        "git_config": git_config,
1583        "config": home_config,
1584        "next": next,
1585    });
1586    if !from_link {
1587        note(
1588            &format!("joined as {user}"),
1589            &[
1590                ("token", format!("{} (0600)", token_path.display())),
1591                ("key", key_file.to_string()),
1592                ("next", next),
1593            ],
1594        );
1595        finish(
1596            200,
1597            &serde_json::to_string_pretty(&summary).expect("summary is serializable"),
1598        );
1599    }
1600
1601    // The link form's reader is a person, so the answer is the report
1602    // rather than the JSON. What was written is listed in full because
1603    // every line of it is a file on their machine they did not choose,
1604    // and the last line is one they can copy.
1605    let style = choir_cli::style::Style::for_stdout();
1606    println!("\n  {}\n", style.green(&format!("Joined {api} as {user}.")));
1607    println!("  {}  {}", style.dim("channel "), channel);
1608    println!(
1609        "  {}  {} {}",
1610        style.dim("key     "),
1611        key_file,
1612        style.dim("(0600)")
1613    );
1614    println!(
1615        "  {}  {} {}",
1616        style.dim("token   "),
1617        token_path.display(),
1618        style.dim("(0600)")
1619    );
1620    if let Some(path) = &home_config {
1621        println!(
1622            "  {}  {} {}",
1623            style.dim("node    "),
1624            path,
1625            style.dim("(so no command has to be told the node again)")
1626        );
1627    }
1628    match &git_config {
1629        Some(path) => println!(
1630            "  {}  {} {}",
1631            style.dim("git     "),
1632            path,
1633            style.dim("(clone and push need no token in the URL)")
1634        ),
1635        None => println!(
1636            "  {}  {}",
1637            style.dim("git     "),
1638            style.cyan(&format!(
1639                "not configured; run: git config --global credential.{api}.helper \
1640                 '!choir git-credential {}'",
1641                token_path.display()
1642            )),
1643        ),
1644    }
1645    if bound {
1646        println!(
1647            "\n  {}\n  {}\n",
1648            style.dim("Clone anything you were granted, commit on a branch, then:"),
1649            style.cyan("choir propose")
1650        );
1651    } else {
1652        println!(
1653            "\n  {}\n  {}\n",
1654            style.dim(
1655                "This node does not register keys at redemption. Send the operator this line:"
1656            ),
1657            style.cyan(&format!("choir key {key_file} {channel}")),
1658        );
1659    }
1660    std::process::exit(0);
1661}
1662
1663/// Points git at the token for one node, and returns the file it wrote.
1664///
1665/// Scoped to the node's origin with `credential.<origin>.helper` rather
1666/// than set as the bare `credential.helper`, so this cannot answer for
1667/// GitHub or for anybody else's server: a helper configured unscoped is
1668/// asked about every host git ever talks to.
1669///
1670/// It goes in `~/.gitconfig` because that is the only config a clone
1671/// that does not exist yet will read, and the whole point is that the
1672/// next `git clone` works. `None` when git could not be run or refused,
1673/// which is reported rather than fatal — the join itself succeeded, and
1674/// the line to run by hand is printed instead.
1675fn configure_git_credential(api: &str, auth_file: &str) -> Option<String> {
1676    let exe = std::env::current_exe()
1677        .map(|p| p.display().to_string())
1678        .unwrap_or_else(|_| "choir".to_string());
1679    let origin = api.trim_end_matches('/');
1680    let out = std::process::Command::new("git")
1681        .args([
1682            "config",
1683            "--global",
1684            &format!("credential.{origin}.helper"),
1685            &format!("!{exe} git-credential {auth_file}"),
1686        ])
1687        .output()
1688        .ok()?;
1689    if !out.status.success() {
1690        return None;
1691    }
1692    let out = std::process::Command::new("git")
1693        .args([
1694            "config",
1695            "--global",
1696            "--list",
1697            "--show-origin",
1698            "--name-only",
1699        ])
1700        .output()
1701        .ok()?;
1702    // `--show-origin` prints `file:<path>\t<name>`; the path is the same
1703    // for every line, so the first will do. Asking git rather than
1704    // spelling `~/.gitconfig` here is what keeps this honest on a
1705    // machine using `$XDG_CONFIG_HOME/git/config`.
1706    String::from_utf8_lossy(&out.stdout)
1707        .lines()
1708        .next()
1709        .and_then(|line| line.split('\t').next())
1710        .and_then(|origin| origin.strip_prefix("file:"))
1711        .map(str::to_string)
1712}
1713
1714/// Records the node, channel and key in `~/.choir/config`.
1715///
1716/// The fallback the `.choir/config` walk reaches when no directory above
1717/// the working one names a node — see [`configured`]. Existing keys are
1718/// preserved rather than the file being rewritten, because `choir init`
1719/// writes this same file when it is run from `$HOME`.
1720///
1721/// `None` when it could not be written, which is reported rather than
1722/// fatal for the same reason [`configure_git_credential`]'s failure is.
1723fn write_home_config(api: &str, channel: &str, key_file: &str) -> Option<String> {
1724    let path = state_dir().join("config");
1725    let existing = std::fs::read_to_string(&path).unwrap_or_default();
1726    let mut out = String::new();
1727    let written = [
1728        ("node", api.trim_end_matches('/')),
1729        ("channel", channel),
1730        ("key", key_file),
1731    ];
1732    for line in existing.lines() {
1733        let key = line.split_once('=').map(|(k, _)| k.trim()).unwrap_or("");
1734        if !written.iter().any(|(name, _)| *name == key) {
1735            out.push_str(line);
1736            out.push('\n');
1737        }
1738    }
1739    for (name, value) in written {
1740        out.push_str(&format!("{name} = {value}\n"));
1741    }
1742    choir_fs::write_atomic_private(&path, out).ok()?;
1743    Some(path.display().to_string())
1744}
1745
1746/// `choir git-credential <auth-file> [--auth-user <name>] <operation>`
1747///
1748/// A git credential helper, so a token reaches git over stdin instead of
1749/// living in a remote URL.
1750///
1751/// A URL-embedded credential is written into `.git/config`, echoed by
1752/// `git remote -v`, and copied into every shell history and bug report
1753/// that quotes a clone line. Git's helper protocol exists to avoid
1754/// exactly that: git runs the helper as a subprocess, writes the request
1755/// as `key=value` lines on stdin, and reads the answer the same way.
1756///
1757/// Configure it once per checkout:
1758///
1759/// ```text
1760/// git config credential.helper '!choir git-credential ~/.choir/auth'
1761/// ```
1762///
1763/// `store` and `erase` are accepted and do nothing, deliberately. The
1764/// auth file is written by `choir join` and owned by the contributor;
1765/// a helper that honoured `erase` would let a routine authentication
1766/// failure delete the credential the operator issued once.
1767fn git_credential(auth_file: &str, user: Option<&str>, operation: &str) -> ! {
1768    match operation {
1769        // Git ignores unknown operations from a helper, and so does
1770        // this: answering a `store` with a credential would be a helper
1771        // volunteering one nobody asked for.
1772        "store" | "erase" => std::process::exit(0),
1773        "get" => {}
1774        _ => {
1775            eprintln!("choir git-credential: unknown operation `{operation}`");
1776            std::process::exit(2);
1777        }
1778    }
1779    // Git's request arrives on stdin and is *not* echoed back: replying
1780    // with a host or path git did not ask about is how a helper hands a
1781    // credential to the wrong server. Only the two fields git wants are
1782    // printed, and git matches them against the request itself.
1783    let mut request = String::new();
1784    use std::io::Read;
1785    if std::io::stdin().read_to_string(&mut request).is_err() {
1786        std::process::exit(1);
1787    }
1788    let (user, token) = match choir_cli::mcp::credential_pair(std::path::Path::new(auth_file), user)
1789    {
1790        Ok(pair) => pair,
1791        Err(error) => {
1792            // Exit 0 with no output: git reads that as "this helper
1793            // has nothing", and falls through to the next one or to
1794            // prompting. Exiting nonzero would abort the whole
1795            // operation over a helper that simply does not apply.
1796            eprintln!("choir git-credential: {error}");
1797            std::process::exit(0);
1798        }
1799    };
1800    println!("username={user}");
1801    println!("password={token}");
1802    std::process::exit(0);
1803}
1804
1805/// Runs `git` in `dir` and returns its trimmed stdout.
1806///
1807/// Failure carries git's own stderr rather than a paraphrase. Every
1808/// error this can hit -- not a repository, no such remote, no upstream
1809/// -- already has a message git words better than a wrapper would, and
1810/// the contributor is going to fix it with git.
1811fn git_capture(dir: &std::path::Path, args: &[&str]) -> Result<String, String> {
1812    let out = std::process::Command::new("git")
1813        .args(args)
1814        .current_dir(dir)
1815        .output()
1816        .map_err(|e| format!("cannot run git: {e}"))?;
1817    if !out.status.success() {
1818        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
1819    }
1820    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1821}
1822
1823/// Runs `git` in `dir` for effect, streaming its output to this
1824/// process's own.
1825fn git_run(dir: &std::path::Path, args: &[&str]) -> Result<(), String> {
1826    let status = std::process::Command::new("git")
1827        .args(args)
1828        .current_dir(dir)
1829        .status()
1830        .map_err(|e| format!("cannot run git: {e}"))?;
1831    if status.success() {
1832        Ok(())
1833    } else {
1834        Err(format!("git {} failed", args.join(" ")))
1835    }
1836}
1837
1838/// Aborts a proposal, naming the step that failed.
1839///
1840/// Every abort here is mid-sequence by construction, so it says which
1841/// step stopped and leaves the contributor's checkout untouched. A
1842/// proposal is resumable precisely because its identifiers are derived
1843/// rather than minted: running the same command again re-reaches the
1844/// same change instead of forking a second one.
1845fn propose_abort(step: &str, detail: &str) -> ! {
1846    eprintln!("choir propose: {step}: {detail}");
1847    // Every refusal in this command ends with a line the reader can act
1848    // on. Some details carry their own -- the ones that know which flag
1849    // is missing -- and the rest get the step-shaped one, which is the
1850    // most specific true thing left to say. `choir doctor` is the
1851    // fallback rather than a shrug: it is the command that tells apart
1852    // "the node is down" from "git is not installed", and those are what
1853    // the remaining steps fail on.
1854    if !detail.contains("next:") {
1855        let next = match step {
1856            "checkout" => "run this from inside a git checkout",
1857            "identity" => "choir join '<link>'",
1858            "remote" | "base" => {
1859                "git fetch, then re-run; or name it: choir propose --api <url> --repo <owner/repo>"
1860            }
1861            "push" => "check the push error above; the credential comes from ~/.choir/auth",
1862            _ => "choir doctor",
1863        };
1864        eprintln!("next: {next}");
1865    }
1866    std::process::exit(1);
1867}
1868
1869/// Flags of `choir propose`, after parsing.
1870struct ProposeOptions<'a> {
1871    api: Option<&'a str>,
1872    repo: Option<&'a str>,
1873    remote: &'a str,
1874    onto: Option<&'a str>,
1875    change: Option<&'a str>,
1876    key_file: Option<&'a str>,
1877    channel: Option<&'a str>,
1878    cone: Vec<String>,
1879    reviewers: Vec<String>,
1880}
1881
1882/// Splits off the deprecated `<key-file> <channel>` prefix, if this
1883/// invocation carries one.
1884///
1885/// The zero-argument form and the positional form cannot be told apart
1886/// by counting, because a bare `choir propose alice bea` names two
1887/// reviewers. They *can* be told apart by what the first argument is: a
1888/// key file is a file that exists, and a reviewer is a channel name.
1889/// Requiring the file to exist rather than merely to look like a path is
1890/// deliberate — a typo'd key path then reads as a reviewer and is
1891/// refused by the node by name, instead of being opened and minting a
1892/// key nobody registered.
1893fn positional_propose<'a>(rest: &'a [&'a str]) -> Option<(&'a str, &'a str, &'a [&'a str])> {
1894    let [key_file, channel, tail @ ..] = rest else {
1895        return None;
1896    };
1897    let positional = !key_file.starts_with("--")
1898        && !channel.starts_with("--")
1899        && std::path::Path::new(key_file).is_file();
1900    positional.then_some((key_file, channel, tail))
1901}
1902
1903fn parse_propose<'a>(rest: &[&'a str]) -> ProposeOptions<'a> {
1904    let (mut api, mut repo, mut onto, mut change) = (None, None, None, None);
1905    let (mut key_file, mut channel) = (None, None);
1906    let mut remote = "origin";
1907    let (mut cone, mut reviewers) = (Vec::new(), Vec::new());
1908    let mut index = 0;
1909    while index < rest.len() {
1910        let flag = rest[index];
1911        if !flag.starts_with("--") {
1912            reviewers.push(flag.to_string());
1913            index += 1;
1914            continue;
1915        }
1916        let Some(value) = rest.get(index + 1).copied() else {
1917            usage();
1918        };
1919        match flag {
1920            "--api" if api.is_none() => api = Some(value),
1921            "--repo" if repo.is_none() => repo = Some(value),
1922            "--remote" => remote = value,
1923            "--onto" if onto.is_none() => onto = Some(value),
1924            "--change" if change.is_none() => change = Some(value),
1925            "--path" => cone.push(value.to_string()),
1926            // The two the command used to take positionally. Both are
1927            // inferred when absent; these override the inference for a
1928            // machine holding more than one identity.
1929            "--key-file" if key_file.is_none() => key_file = Some(value),
1930            "--channel" if channel.is_none() => channel = Some(value),
1931            _ => usage(),
1932        }
1933        index += 2;
1934    }
1935    // Same canonical ordering `choir workspace` applies: the node
1936    // rebuilds these bytes to verify the owner signature, so two
1937    // clients naming the same subtrees in a different order must sign
1938    // identical authorizations.
1939    cone.sort();
1940    cone.dedup();
1941    ProposeOptions {
1942        api,
1943        repo,
1944        remote,
1945        onto,
1946        change,
1947        key_file,
1948        channel,
1949        cone,
1950        reviewers,
1951    }
1952}
1953
1954/// `choir propose [flags] [reviewer]...`
1955///
1956/// The five-step path -- provision, commit, push, checkpoint, request
1957/// review -- as one command, run from the contributor's own checkout.
1958/// Nothing here is a new endpoint; the command is the inference that
1959/// supplies each step's identifiers from what the checkout already
1960/// knows, plus the ordering between them.
1961///
1962/// Unlike every other subcommand, the API base is a flag rather than the
1963/// first positional. That is the point of the command: the git remote
1964/// already names the node, and asking a newcomer to repeat it is the
1965/// friction being removed. `--api` and `--repo` override the inference
1966/// for a checkout whose remote is an `ssh://` URL or a proxy.
1967///
1968/// The sequence stops at the first refusal and says which step stopped.
1969/// It is safe to re-run: the change, workspace and idempotency key are
1970/// derived from the branch name, so a second run resumes the same
1971/// proposal rather than opening a second one.
1972fn propose(rest: &[&str], auth: AuthOptions<'_>) -> ! {
1973    let positional = positional_propose(rest);
1974    let options = parse_propose(positional.map_or(rest, |(_, _, tail)| tail));
1975    // Most explicit first: the two positionals the command used to take,
1976    // then their flags, then what `choir join` left behind. Nothing is
1977    // guessed -- an identity that cannot be found is refused with the
1978    // command that creates one, because signing as the wrong actor is
1979    // worse than not signing.
1980    let key_file = positional
1981        .map(|(key_file, _, _)| key_file.to_string())
1982        .or_else(|| options.key_file.map(str::to_string))
1983        .or_else(|| configured("key"))
1984        .unwrap_or_else(|| state_dir().join("agent.key").display().to_string());
1985    if !std::path::Path::new(&key_file).is_file() {
1986        propose_abort(
1987            "identity",
1988            &format!(
1989                "no key at {key_file}\nnext: choir join '<link>'   \
1990                 (or name one: choir propose --key-file <path>)"
1991            ),
1992        );
1993    }
1994    // The channel, in the same order. The auth file's user is the last
1995    // resort rather than the first because an account's channel is not
1996    // always its user name -- `--channel` at join time makes them
1997    // differ, and `choir join` writes the answer down for exactly this.
1998    let channel = positional
1999        .map(|(_, channel, _)| channel.to_string())
2000        .or_else(|| options.channel.map(str::to_string))
2001        .or_else(|| configured("channel"))
2002        .or_else(|| {
2003            let path = discovered_auth_file()?;
2004            choir_cli::mcp::credential_pair(std::path::Path::new(&path), auth.user)
2005                .ok()
2006                .map(|(user, _)| user)
2007        })
2008        .unwrap_or_else(|| {
2009            propose_abort(
2010                "identity",
2011                "nothing here says which channel to sign as\nnext: choir propose --channel <name>",
2012            )
2013        });
2014    let (key_file, channel) = (key_file.as_str(), channel.as_str());
2015    let cwd = std::env::current_dir().unwrap_or_else(|e| propose_abort("checkout", &e.to_string()));
2016    let top = match git_capture(&cwd, &["rev-parse", "--show-toplevel"]) {
2017        Ok(top) => std::path::PathBuf::from(top),
2018        Err(error) => propose_abort("checkout", &error),
2019    };
2020
2021    // 1. Where to send it. An explicit --api wins; otherwise the remote
2022    //    URL carries both the node and the repository.
2023    let remote_url = git_capture(&top, &["remote", "get-url", options.remote]);
2024    let inferred = match (&options.api, &options.repo, &remote_url) {
2025        // Both named explicitly: the remote need not even exist, which
2026        // is what makes this work from a checkout cloned from elsewhere.
2027        (Some(api), Some(repo), _) => choir_cli::propose::Remote {
2028            api: (*api).to_string(),
2029            repo: (*repo).to_string(),
2030        },
2031        (_, _, Ok(url)) => match choir_cli::propose::Remote::parse(url) {
2032            Ok(remote) => remote,
2033            Err(failure) => propose_abort("remote", &failure.message),
2034        },
2035        (_, _, Err(error)) => propose_abort("remote", error),
2036    };
2037    let api = options.api.map_or(inferred.api, str::to_string);
2038    let repo = options.repo.map_or(inferred.repo, str::to_string);
2039
2040    // 2. What is being proposed, and onto what. The branch name is the
2041    //    change identity, so an amend or a rebase reaches the same
2042    //    change rather than forking a second one.
2043    let branch =
2044        git_capture(&top, &["symbolic-ref", "--quiet", "--short", "HEAD"]).unwrap_or_default();
2045    let onto = options.onto.map_or_else(
2046        || {
2047            // The remote's own default branch when the clone recorded
2048            // one, and `main` only as the last resort. Guessing first
2049            // would silently propose onto the wrong branch on a
2050            // repository whose default is `master` or `trunk`.
2051            git_capture(
2052                &top,
2053                &[
2054                    "symbolic-ref",
2055                    "--short",
2056                    &format!("refs/remotes/{}/HEAD", options.remote),
2057                ],
2058            )
2059            .ok()
2060            .and_then(|head| head.rsplit('/').next().map(str::to_string))
2061            .unwrap_or_else(|| "main".to_string())
2062        },
2063        str::to_string,
2064    );
2065    let proposal = match choir_cli::propose::Proposal::derive(&repo, &branch, &onto) {
2066        Ok(proposal) => proposal,
2067        Err(failure) => propose_abort("branch", &failure.message),
2068    };
2069    let change_id = options
2070        .change
2071        .map_or(proposal.identity.change_id.clone(), str::to_string);
2072    let head = match git_capture(&top, &["rev-parse", "HEAD"]) {
2073        Ok(head) => head,
2074        Err(error) => propose_abort("checkout", &error),
2075    };
2076
2077    // 3. Does this change already exist? Reading first is what makes a
2078    //    re-run resume. Provisioning again would be refused for a
2079    //    rebased proposal, whose merge base has moved since the change
2080    //    was bound to the older one.
2081    let (status, body) = http(&api, auth, "choir_view", serde_json::json!({}));
2082    if !(200..300).contains(&status) {
2083        propose_abort("view", &format!("GET /api/view returned {status}: {body}"));
2084    }
2085    let view: serde_json::Value = serde_json::from_str(&body)
2086        .unwrap_or_else(|e| propose_abort("view", &format!("response is not JSON: {e}")));
2087    // Read before writing anything: whether a review is already open
2088    // decides step 5, and asking after the checkpoint would race a
2089    // reviewer's verdict landing in between.
2090    let review_open = view["reviews"]
2091        .get(&change_id)
2092        .is_some_and(|review| !review.is_null() && review["status"] != "archived");
2093    let existing = view["changes"]
2094        .get(&change_id)
2095        .filter(|state| !state.is_null());
2096    let workspace_id = match existing {
2097        Some(state) => {
2098            let workspace = state["active_workspace"]
2099                .as_str()
2100                .unwrap_or_else(|| {
2101                    propose_abort(
2102                        "change",
2103                        "this change's workspace has been archived; propose from a new branch",
2104                    )
2105                })
2106                .to_string();
2107            eprintln!(
2108                "choir propose: updating change {}",
2109                choir_cli::propose::short_change_id(&change_id)
2110            );
2111            workspace
2112        }
2113        None => {
2114            // The fork point, not the local branch tip: the node can only
2115            // bind a base its bare repository already has, and the merge
2116            // base is the newest commit both sides are known to share.
2117            let base = git_capture(
2118                &top,
2119                &["merge-base", "HEAD", &format!("{}/{onto}", options.remote)],
2120            )
2121            .unwrap_or_else(|error| propose_abort(
2122                "base",
2123                &format!("{error}\ncannot find where this branch left {}/{onto}; fetch first, or pass --onto", options.remote),
2124            ));
2125            let workspace_name = proposal.identity.workspace_name.clone();
2126            let mut body = serde_json::json!({
2127                "repo": repo,
2128                "name": workspace_name,
2129                "base": base,
2130                "owner": channel,
2131                "change": change_id,
2132                "idempotency_key": proposal.identity.idempotency_key,
2133            });
2134            let Some(base_revision) = choir_hash::ContentHash::from_git_oid(&base) else {
2135                propose_abort("base", "merge-base did not return a git object id");
2136            };
2137            let authorization = CreateAuthorization::new(
2138                change_id.clone(),
2139                channel.into(),
2140                format!("{repo}/{workspace_name}"),
2141                base_revision,
2142                proposal.identity.idempotency_key.clone(),
2143            )
2144            .with_cone(options.cone.clone());
2145            let signed = signed_payload_body(key_file, channel, &authorization.to_payload());
2146            for field in ["channel", "payload_hex", "key_id", "signature_hex"] {
2147                body[field] = signed[field].clone();
2148            }
2149            let (status, response) = http(&api, auth, "choir_workspace", body);
2150            if !(200..300).contains(&status) {
2151                propose_abort("create change", &response);
2152            }
2153            eprintln!(
2154                "choir propose: created change {} on {base}",
2155                choir_cli::propose::short_change_id(&change_id)
2156            );
2157            format!("{repo}/{workspace_name}")
2158        }
2159    };
2160
2161    // 4. Send the objects. A checkpoint records identity and a CAS; it
2162    //    does not transfer objects, so a checkpoint of a commit the node
2163    //    does not have would name a revision nothing can check out.
2164    //    The ref is named after the commit, so this adds one and never
2165    //    moves one -- see `Proposal::revision_ref`.
2166    let revision_ref = proposal.revision_ref(&head);
2167    let refspec = format!("HEAD:{revision_ref}");
2168    if let Err(error) = git_run(&top, &["push", options.remote, &refspec]) {
2169        propose_abort("push", &error);
2170    }
2171
2172    // 5. Publish the revision, then ask for review. In that order: a
2173    //    review names a commit, and a reviewer drawn onto a revision the
2174    //    change has not published yet is being asked about work the node
2175    //    cannot show them.
2176    let Some(revision) = choir_hash::ContentHash::from_git_oid(&head) else {
2177        propose_abort("checkpoint", "HEAD is not a git object id");
2178    };
2179    let prev_revision = current_change_revision(&api, auth, &change_id);
2180    if prev_revision != revision {
2181        let op = ViewOp::new(OpKind::CheckpointChange {
2182            id: change_id.clone(),
2183            workspace: workspace_id.clone(),
2184            revision: revision.clone(),
2185            prev_revision,
2186        });
2187        let signed = signed_body(&api, key_file, channel, &op, auth);
2188        let (status, response) = http(&api, auth, "choir_submit", signed);
2189        if !(200..300).contains(&status) {
2190            propose_abort("checkpoint", &response);
2191        }
2192    }
2193
2194    // A review is one long-lived object per change, not one per
2195    // revision: re-posting a verdict *is* the re-review flow, so asking
2196    // again would be refused and, if it were not, would discard the
2197    // discussion and the verdicts already posted. What advances instead
2198    // is the change's revision, which is where a reviewer reads the
2199    // current commit from.
2200    if !review_open {
2201        let op = ViewOp::new(OpKind::RequestReview {
2202            id: change_id.clone(),
2203            target: revision,
2204            reviewers: options.reviewers.clone(),
2205            target_ref: Some(proposal.review_target(&repo)),
2206        });
2207        let signed = signed_body(&api, key_file, channel, &op, auth);
2208        let (status, response) = http(&api, auth, "choir_submit", signed);
2209        if !(200..300).contains(&status) {
2210            propose_abort("request review", &response);
2211        }
2212    }
2213
2214    let summary = serde_json::json!({
2215        "change": change_id,
2216        "workspace": workspace_id,
2217        "commit": head,
2218        "pushed_ref": revision_ref,
2219        "fetch": format!("git fetch {} {revision_ref}", options.remote),
2220        "target_ref": proposal.review_target(&repo),
2221        "reviewers": if options.reviewers.is_empty() {
2222            serde_json::json!("drawn by the node")
2223        } else {
2224            serde_json::json!(options.reviewers)
2225        },
2226        "review": if review_open {
2227            // Said plainly because the review's own `target` still names
2228            // the commit it was opened on. The change's `revision_id` is
2229            // the live pointer, and this is the sentence that stops a
2230            // reviewer reading the superseded one.
2231            "already open; it now needs re-review against this revision"
2232        } else {
2233            "opened"
2234        },
2235        "next": format!("choir state {api} {channel}"),
2236    });
2237    finish(
2238        200,
2239        &serde_json::to_string_pretty(&summary).expect("summary is serializable"),
2240    );
2241}
2242
2243/// Prints the checks on `subject` and exits with the trichotomy.
2244///
2245/// The only command here that has a third answer, and it is the reason
2246/// the third answer exists: a caller asking "may I land this" gets three
2247/// materially different instructions back, and two exit codes cannot
2248/// carry three instructions. `0` land it, `1` do not, `3` not yet.
2249///
2250/// **Nothing reported exits `1`, not `3`.** `3` means a check said it
2251/// was running, which is a promise that an outcome is coming. No check
2252/// at all is not that promise -- there may be no runner configured, and
2253/// a caller that waited would wait forever. Both `1` cases print a
2254/// distinguishing `verdict`, so a human is never left guessing which of
2255/// the two they hit.
2256fn check_exit(subject: &ContentHash, body: &str) -> ! {
2257    let view: serde_json::Value = match serde_json::from_str(body) {
2258        Ok(view) => view,
2259        Err(error) => {
2260            eprintln!("choir checks: the node's view did not parse: {error}");
2261            std::process::exit(1);
2262        }
2263    };
2264    let prefix = format!("{}:", subject.to_hex());
2265    let rows: serde_json::Map<String, serde_json::Value> = view
2266        .get("checks")
2267        .and_then(serde_json::Value::as_object)
2268        .map(|checks| {
2269            checks
2270                .iter()
2271                .filter(|(key, _)| key.starts_with(&prefix))
2272                .map(|(key, value)| (key[prefix.len()..].to_string(), value.clone()))
2273                .collect()
2274        })
2275        .unwrap_or_default();
2276    let status_of = |value: &serde_json::Value| {
2277        value
2278            .get("status")
2279            .and_then(serde_json::Value::as_str)
2280            .map(str::to_lowercase)
2281    };
2282    // Ranked the way `View::checks_verdict` ranks, and for its reasons:
2283    // failed, then errored, then running. Each rank has its own exit
2284    // code because each implies a different next command -- 1 fix it,
2285    // 4 re-run it, 3 wait -- and a script that only asks whether the
2286    // code is zero is unaffected by the new one.
2287    let (verdict, code) = if rows.is_empty() {
2288        ("unreported", 1)
2289    } else if rows
2290        .values()
2291        .any(|v| status_of(v).as_deref() == Some("failed"))
2292    {
2293        ("failed", 1)
2294    } else if rows
2295        .values()
2296        .any(|v| status_of(v).as_deref() == Some("errored"))
2297    {
2298        ("errored", 4)
2299    } else if rows
2300        .values()
2301        .any(|v| status_of(v).as_deref() == Some("running"))
2302    {
2303        ("running", 3)
2304    } else {
2305        ("passed", 0)
2306    };
2307    println!(
2308        "{}",
2309        serde_json::json!({
2310            "subject": subject.to_hex(),
2311            "verdict": verdict,
2312            "checks": rows,
2313        })
2314    );
2315    std::process::exit(code);
2316}
2317
2318/// Prints the response body and exits nonzero unless the status is 2xx.
2319fn finish(status: u16, body: &str) -> ! {
2320    println!("{body}");
2321    // The body stays exactly what the node said, on stdout, for whatever
2322    // is parsing it. The repair is repeated on stderr because `next` is
2323    // the fourth field of a JSON object and a reader who is already
2324    // stuck does not read that far.
2325    if !(200..300).contains(&status) {
2326        if let Some(next) = refusal_next(body) {
2327            eprintln!("\nnext: {next}");
2328        }
2329    }
2330    std::process::exit(if (200..300).contains(&status) { 0 } else { 1 });
2331}
2332
2333/// The binding `/api/view` currently reports for `actor_id`, if any.
2334///
2335/// Best-effort by design: any failure to read returns `None` so the bind
2336/// still goes out. Refusing to submit because a *read* failed would turn
2337/// a reporting problem into an availability problem, and the node is the
2338/// authority on whether a binding is admissible regardless of what this
2339/// saw.
2340fn current_binding(api: &str, auth: AuthOptions<'_>, actor_id: &str) -> Option<serde_json::Value> {
2341    let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
2342    if !(200..300).contains(&status) {
2343        return None;
2344    }
2345    let view: serde_json::Value = serde_json::from_str(&body).ok()?;
2346    let binding = view.get("bindings")?.get(actor_id)?;
2347    (!binding.is_null()).then(|| binding.clone())
2348}
2349
2350/// The log identity to sign a scope against: `(node, head)` from
2351/// `/api/view`.
2352///
2353/// Unlike [`current_binding`], a failed read here is fatal. The two are
2354/// different kinds of read: that one reports on a decision the node will
2355/// make anyway, while this one is *part of what gets signed*. Guessing a
2356/// scope, or quietly signing without one, would produce a signature that
2357/// is either refused or — worse, on a node that does not require scopes —
2358/// admissible forever and everywhere.
2359/// The id of the ref-state attestation this node is currently serving,
2360/// read from `/api/view` (D67).
2361///
2362/// Read rather than accepted as an argument, and fatal on failure, for
2363/// the same reason as [`log_scope`]: this value is *what gets signed*.
2364/// A witness that pasted a stale id would be attesting a ref-state that
2365/// is no longer current, which is the one thing a witness must never do
2366/// by accident — and the node would refuse it, so the only outcome of
2367/// allowing it is a confusing error instead of a correct signature.
2368fn latest_snapshot(api: &str, auth: AuthOptions<'_>) -> ContentHash {
2369    let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
2370    let fail = |why: &str| -> ! {
2371        eprintln!("choir: cannot read the ref-state attestation from {api}: {why}");
2372        eprintln!("choir: not signing a witness statement about a snapshot nobody read.");
2373        std::process::exit(1);
2374    };
2375    if !(200..300).contains(&status) {
2376        fail(&format!("GET /api/view returned {status}"));
2377    }
2378    let view: serde_json::Value = match serde_json::from_str(&body) {
2379        Ok(view) => view,
2380        Err(error) => fail(&format!("response is not JSON: {error}")),
2381    };
2382    match view["snapshot"]["id"].as_str().and_then(hash_from_hex) {
2383        Some(id) => id,
2384        // Two different absences, one message: a node that has taken no
2385        // snapshot yet, and a reader whose grants hide the section. Both
2386        // mean the same thing to a witness -- there is nothing here to
2387        // attest -- and distinguishing them would disclose the section
2388        // to somebody the ACL just withheld it from.
2389        None => fail(
2390            "no `snapshot.id` in the view: either the node has attested no ref-state yet, \
2391             or this credential may not read node-wide sections (`@node auditor`)",
2392        ),
2393    }
2394}
2395
2396fn log_scope(api: &str, auth: AuthOptions<'_>) -> (ContentHash, Option<ContentHash>) {
2397    let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
2398    let fail = |why: &str| -> ! {
2399        eprintln!("choir: cannot read the log scope from {api}: {why}");
2400        eprintln!("choir: not signing an op that names no log. Retry when the node answers.");
2401        std::process::exit(1);
2402    };
2403    if !(200..300).contains(&status) {
2404        fail(&format!("GET /api/view returned {status}"));
2405    }
2406    let view: serde_json::Value = match serde_json::from_str(&body) {
2407        Ok(view) => view,
2408        Err(error) => fail(&format!("response is not JSON: {error}")),
2409    };
2410    let Some(node) = view["log"]["node"].as_str().and_then(hash_from_hex) else {
2411        fail("response has no `log.node`; this node predates op scopes");
2412    };
2413    let head = match view["log"]["head"].as_str() {
2414        Some(hex) => match hash_from_hex(hex) {
2415            Some(head) => Some(head),
2416            None => fail("`log.head` is not a content hash"),
2417        },
2418        // A null head is an empty log, which is a real state and the one
2419        // a genesis op is signed against.
2420        None => None,
2421    };
2422    (node, head)
2423}
2424
2425/// Parses a `<codec>-<digest>` content hash as served by the node.
2426fn hash_from_hex(hex: &str) -> Option<ContentHash> {
2427    let (codec, digest) = hex.split_once('-')?;
2428    let codec = u8::from_str_radix(codec, 16).ok()?;
2429    let digest: Option<Vec<u8>> = (0..digest.len())
2430        .step_by(2)
2431        .map(|i| u8::from_str_radix(digest.get(i..i + 2)?, 16).ok())
2432        .collect();
2433    Some(ContentHash {
2434        codec,
2435        digest: digest?,
2436    })
2437}
2438
2439/// Signs `op` on attribution channel `channel` and builds its submission body.
2440///
2441/// The op is scoped to the node's current head first, so the signature
2442/// is admissible on this log once and nowhere else.
2443fn signed_body(
2444    api: &str,
2445    key_file: &str,
2446    channel: &str,
2447    op: &ViewOp,
2448    auth: AuthOptions<'_>,
2449) -> serde_json::Value {
2450    let (node, head) = log_scope(api, auth);
2451    let op = op.clone().in_scope(node, head);
2452    signed_payload_body(key_file, channel, &op.to_payload())
2453}
2454
2455/// Signs raw `payload` bytes on `channel`. Owner authorizations travel
2456/// inside a request body rather than the op log, so unlike [`signed_body`]
2457/// this carries no log scope.
2458fn signed_payload_body(key_file: &str, channel: &str, payload: &[u8]) -> serde_json::Value {
2459    let key = load_key(key_file);
2460    let sig = key.sign_submission(channel, payload);
2461    serde_json::json!({
2462        "channel": channel,
2463        "payload_hex": hex_encode(payload),
2464        "key_id": sig.key_id,
2465        "signature_hex": hex_encode(&sig.signature),
2466    })
2467}
2468
2469fn submit(api: &str, key_file: &str, channel: &str, op: &ViewOp, auth: AuthOptions<'_>) -> ! {
2470    let body = signed_body(api, key_file, channel, op, auth);
2471    let (status, resp) = http(api, auth, "choir_submit", body);
2472    finish(status, &resp);
2473}
2474
2475/// The trusted keys this client holds, in the operator's own file
2476/// format: one key per line, hex, with an optional channel name before
2477/// it.
2478///
2479/// Reusing that spelling means the file an operator already keeps is the
2480/// file a verifying client already has, rather than a second format that
2481/// can disagree with the first.
2482fn load_registry(path: &str) -> Registry {
2483    let text = match std::fs::read_to_string(path) {
2484        Ok(text) => text,
2485        Err(error) => {
2486            eprintln!("choir log: {path}: {error}");
2487            std::process::exit(2);
2488        }
2489    };
2490    let mut registry = Registry::new();
2491    for (number, line) in text.lines().enumerate() {
2492        let line = line.split('#').next().unwrap_or("").trim();
2493        if line.is_empty() {
2494            continue;
2495        }
2496        // `<name> <hex>` or bare `<hex>`: the name is admission's
2497        // business and this command only needs the key material.
2498        let hex = line.split_whitespace().last().unwrap_or_default();
2499        let Some(bytes) = hex_decode(hex).and_then(|b| <[u8; 32]>::try_from(b).ok()) else {
2500            eprintln!("choir log: {path}:{}: not 64 hex characters", number + 1);
2501            std::process::exit(2);
2502        };
2503        if registry.register(&bytes).is_err() {
2504            eprintln!("choir log: {path}:{}: not a valid ed25519 key", number + 1);
2505            std::process::exit(2);
2506        }
2507    }
2508    registry
2509}
2510
2511/// Reads log entries from a cursor and, with `--verify`, checks them
2512/// the way `SYNC.md` says a client should (D17).
2513///
2514/// `/api/log` was the last agent-facing endpoint with no command, and it
2515/// is the one where that cost most: the repository ships a 177-line
2516/// contract telling clients how to establish that the pages they were
2517/// handed really are the chain — continuity, hash recomputation,
2518/// authorship — and every step of it was prose. An agent following it
2519/// hand-rolled hash-chain and ed25519 checking, and the doc has to warn
2520/// about the subtleties it gets wrong.
2521///
2522/// **What `--verify` establishes, and what it does not.** Continuity and
2523/// recomputation need nothing but the page: they are fully independent
2524/// of the node. Authorship needs the public key, which this command only
2525/// has for actors named in `--keys`; an entry whose key it does not hold
2526/// is reported as **unverified**, never as verified. Saying "checked"
2527/// for a signature nobody could check is the one failure that would make
2528/// this worse than no command at all.
2529///
2530/// A passkey entry carries its own credential key (D45), so it needs
2531/// nothing from `--keys` — and is counted separately for the same
2532/// reason: the key came with the signature, so the bytes are proven
2533/// intact and nothing proves the credential was that channel's.
2534///
2535/// **This is a first-party client and says so.** It decodes into the
2536/// same `OpEntry` the node encodes from, so a hash agreeing here proves
2537/// the node agrees with *this build's* definition of the format rather
2538/// than with an independent reading of `SYNC.md`.
2539/// `choir-node/tests/it/sync_contract.rs` is the independent one: it
2540/// rebuilds the canonical bytes by hand and deliberately never calls
2541/// `content_hash`.
2542/// The revocation positions `/api/view` reports, keyed by actor id.
2543///
2544/// An empty map on any failure, including a node that cannot be reached
2545/// or serves no bindings. That is the honest default: it verifies fewer
2546/// claims rather than more, and the alternative — treating an
2547/// unanswerable question as "revoked" — would report a sound log as
2548/// broken.
2549fn revocations(api: &str, auth: AuthOptions<'_>) -> choir_cli::verify::Revocations {
2550    let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
2551    if !(200..300).contains(&status) {
2552        eprintln!("choir log: cannot read bindings ({status}); revocations not checked");
2553        return choir_cli::verify::Revocations::new();
2554    }
2555    let view: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
2556    view["bindings"]
2557        .as_object()
2558        .map(|bindings| {
2559            bindings
2560                .iter()
2561                .filter_map(|(key_id, binding)| {
2562                    Some((key_id.clone(), binding["revoked"]["at"].as_u64()?))
2563                })
2564                .collect()
2565        })
2566        .unwrap_or_default()
2567}
2568
2569fn log(api: &str, from: u64, verify: bool, keys: Option<&str>, auth: AuthOptions<'_>) -> ! {
2570    let (status, body) = http(api, auth, "choir_log", serde_json::json!({ "from": from }));
2571    if !(200..300).contains(&status) {
2572        // A 409 is the gap rule in `SYNC.md`: this node cannot reach
2573        // back that far. Pass its own words through rather than
2574        // paraphrasing a refusal that names the window it does have.
2575        println!("{body}");
2576        std::process::exit(1);
2577    }
2578    let page: serde_json::Value = match serde_json::from_str(&body) {
2579        Ok(page) => page,
2580        Err(error) => {
2581            eprintln!("choir log: response is not JSON: {error}");
2582            std::process::exit(1);
2583        }
2584    };
2585    let entries = page["entries"].as_array().cloned().unwrap_or_default();
2586    for entry in &entries {
2587        println!("{entry}");
2588    }
2589    if !verify {
2590        eprintln!("choir log: {} entries, not verified", entries.len());
2591        std::process::exit(0);
2592    }
2593
2594    let registry = keys.map(load_registry).unwrap_or_default();
2595    // A second request, and a deliberate one. Revocations decide whether
2596    // a good signature was still authorized at the position it sits at
2597    // (D44), and they are not on the log page -- the `RevokeKey` that
2598    // matters may be outside the window. Asking the node costs a round
2599    // trip and does not cost trust: a node that hides a revocation only
2600    // makes its own log verify, while one that invents one is caught by
2601    // the entry it points at.
2602    let revoked = revocations(api, auth);
2603    let report = choir_cli::verify::page(&entries, &registry, &revoked);
2604    for note in &report.notes {
2605        eprintln!("choir log: {note}");
2606    }
2607    for failure in &report.failures {
2608        eprintln!("choir log: {failure}");
2609    }
2610    // The passkey count is named separately rather than folded into
2611    // "verified" (D45). It is a real result — the bytes are intact and
2612    // were signed by the credential named — and it is not the same
2613    // result: no key set vouches for a credential the entry carries
2614    // itself. One number covering both would report the weaker claim in
2615    // the stronger word, on every line, forever.
2616    let passkeys = if report.integrity_only == 0 {
2617        String::new()
2618    } else {
2619        format!(
2620            ", {} passkey signatures intact but unanchored",
2621            report.integrity_only
2622        )
2623    };
2624    eprintln!(
2625        "choir log: {} entries, chain {}, {} signatures verified, {} unverified{passkeys}",
2626        entries.len(),
2627        if report.failures.is_empty() {
2628            "holds"
2629        } else {
2630            "BROKEN"
2631        },
2632        report.checked,
2633        report.unverified
2634    );
2635    std::process::exit(i32::from(!report.failures.is_empty()));
2636}
2637
2638/// Signs every op in `source` on one channel and submits them as one
2639/// batch (D17).
2640///
2641/// The node has told agents since D26 that `/api/submit-batch` is the
2642/// primary path for their workloads — one durability barrier per batch
2643/// against one per operation — and until now the CLI could not reach it.
2644/// An agent taking that advice had to hand-roll ed25519 signing and
2645/// `curl`, which is the thing this binary exists to prevent.
2646///
2647/// **The log scope is read once, not once per op.** `submit` reads it
2648/// per call because it sends one op; doing that here would put an HTTP
2649/// round trip in front of every operation and spend exactly what the
2650/// batch endpoint saves. One read is also correct rather than merely
2651/// cheaper: admission checks that the head an op names is still *in the
2652/// window*, not that it is the current head, so ops signed against one
2653/// head are admissible in sequence behind each other.
2654///
2655/// **Output is one line per op, in request order**, so a script can read
2656/// line *n* for op *n* without counting brackets — and the accepted and
2657/// rejected totals go to stderr, following the rule the runner already
2658/// documents: machine-facing on stdout, human-facing on stderr.
2659fn batch(api: &str, key_file: &str, channel: &str, source: &str, auth: AuthOptions<'_>) -> ! {
2660    let text = if source == "-" {
2661        let mut buffer = String::new();
2662        if let Err(error) = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer) {
2663            eprintln!("choir batch: cannot read stdin: {error}");
2664            std::process::exit(2);
2665        }
2666        buffer
2667    } else {
2668        match std::fs::read_to_string(source) {
2669            Ok(text) => text,
2670            Err(error) => {
2671                eprintln!("choir batch: {source}: {error}");
2672                std::process::exit(2);
2673            }
2674        }
2675    };
2676
2677    // One op per line. Blank lines are skipped so a generated file may
2678    // end with a newline, or be built by appending, without the last
2679    // entry being a parse error nobody can see.
2680    let mut ops: Vec<ViewOp> = Vec::new();
2681    for (number, line) in text.lines().enumerate() {
2682        if line.trim().is_empty() {
2683            continue;
2684        }
2685        match serde_json::from_str::<ViewOp>(line) {
2686            Ok(op) => ops.push(op),
2687            Err(error) => {
2688                // Named by line, because the whole point of a batch is
2689                // that there are many and "bad op json" would not say
2690                // which.
2691                eprintln!("choir batch: {source}:{}: {error}", number + 1);
2692                std::process::exit(2);
2693            }
2694        }
2695    }
2696    if ops.is_empty() {
2697        eprintln!("choir batch: {source} contains no operations");
2698        std::process::exit(2);
2699    }
2700
2701    let (node, head) = log_scope(api, auth);
2702    let signed: Vec<serde_json::Value> = ops
2703        .into_iter()
2704        .map(|op| {
2705            let op = op.in_scope(node.clone(), head.clone());
2706            signed_payload_body(key_file, channel, &op.to_payload())
2707        })
2708        .collect();
2709    let count = signed.len();
2710    let (status, resp) = http(
2711        api,
2712        auth,
2713        "choir_submit_batch",
2714        serde_json::json!({ "ops": signed }),
2715    );
2716
2717    let parsed: serde_json::Value = match serde_json::from_str(&resp) {
2718        Ok(value) => value,
2719        Err(_) => {
2720            // A batch the node refused before reading the array answers
2721            // in its own words rather than with per-op results. Pass it
2722            // through whole rather than inventing results for ops that
2723            // were never considered.
2724            println!("{resp}");
2725            std::process::exit(if (200..300).contains(&status) { 0 } else { 1 });
2726        }
2727    };
2728    let Some(results) = parsed["results"].as_array() else {
2729        println!("{resp}");
2730        std::process::exit(if (200..300).contains(&status) { 0 } else { 1 });
2731    };
2732    for result in results {
2733        println!("{result}");
2734    }
2735    let accepted = parsed["accepted"].as_u64().unwrap_or(0);
2736    let rejected = parsed["rejected"].as_u64().unwrap_or(0);
2737    eprintln!("choir batch: {accepted} accepted, {rejected} rejected, {count} submitted");
2738    // Nonzero when any op was refused, so `set -e` stops. The per-op
2739    // lines say which, which is the thing an exit code cannot carry.
2740    std::process::exit(
2741        if (200..300).contains(&status) && rejected == 0 && results.len() == count {
2742            0
2743        } else {
2744            1
2745        },
2746    );
2747}
2748
2749/// Emits a runner result and exits, data on stdout and nothing else.
2750///
2751/// An orchestrator parses stdout, so a diagnostic written there would be
2752/// indistinguishable from a result. Every human-facing word goes to
2753/// stderr and every machine-facing one to stdout, which is the same rule
2754/// the rest of the machine surface follows.
2755fn runner_finish(result: &Result<serde_json::Value, choir_cli::runner::Failure>) -> ! {
2756    match result {
2757        Ok(value) => {
2758            println!("{value}");
2759            std::process::exit(0);
2760        }
2761        Err(failure) => {
2762            println!("{}", failure.to_json());
2763            eprintln!("choir runner: {}: {}", failure.code, failure.message);
2764            std::process::exit(1);
2765        }
2766    }
2767}
2768
2769/// Reads a JSON document, mapping every failure to a typed refusal.
2770fn runner_json(
2771    source: &str,
2772    code: &str,
2773    what: &str,
2774) -> Result<serde_json::Value, choir_cli::runner::Failure> {
2775    serde_json::from_str(source)
2776        .map_err(|error| choir_cli::runner::Failure::terminal(code, format!("{what}: {error}")))
2777}
2778
2779/// One lifecycle step for an orchestrator adapter.
2780///
2781/// The adapter supplies its own wire format and its namespace; every
2782/// decision that is about the lifecycle rather than the orchestrator is
2783/// made in [`choir_cli::runner`], where it is unit-tested. This function
2784/// is the I/O around those decisions and deliberately holds none of them.
2785fn runner(config_file: &str, auth: AuthOptions<'_>) -> ! {
2786    use choir_cli::runner::{Config, Failure, Operation, Request};
2787
2788    let outcome = (|| -> Result<serde_json::Value, Failure> {
2789        let raw = std::fs::read_to_string(config_file).map_err(|error| {
2790            Failure::terminal("invalid_config", format!("cannot read config: {error}"))
2791        })?;
2792        let config = Config::parse(&runner_json(&raw, "invalid_config", "config is not JSON")?)?;
2793
2794        let mut stdin = String::new();
2795        std::io::Read::read_to_string(&mut std::io::stdin(), &mut stdin).map_err(|error| {
2796            Failure::terminal("invalid_request", format!("cannot read stdin: {error}"))
2797        })?;
2798        let request = Request::parse(
2799            &runner_json(&stdin, "invalid_request", "request is not JSON")?,
2800            &config,
2801        )?;
2802
2803        // Configured credentials win over inherited flags: the operator
2804        // chose them at install time, and a scheduler's environment is
2805        // not a place to pick up an identity from.
2806        let auth = AuthOptions {
2807            file: config.auth_file.as_deref().or(auth.file_for(&config.api)),
2808            user: config.auth_user.as_deref().or(auth.user),
2809            explicit: true,
2810        };
2811        let id = &request.identity;
2812
2813        match request.operation {
2814            Operation::Ensure => {
2815                let base = match &request.base {
2816                    Some(base) => base.clone(),
2817                    None => {
2818                        let base_ref = config.base_ref.as_ref().ok_or_else(|| {
2819                            Failure::terminal(
2820                                "invalid_config",
2821                                "ensure needs either a request base or a config base_ref",
2822                            )
2823                        })?;
2824                        let (status, body) =
2825                            http(&config.api, auth, "choir_view", serde_json::json!({}));
2826                        if !(200..300).contains(&status) {
2827                            return Err(choir_cli::runner::failure_from_response(
2828                                &body,
2829                                "base revision lookup",
2830                            ));
2831                        }
2832                        choir_cli::runner::base_from_view(
2833                            &runner_json(&body, "invalid_response", "view is not JSON")?,
2834                            base_ref,
2835                        )?
2836                    }
2837                };
2838                let flags: Vec<&str> = vec![
2839                    "--base",
2840                    &base,
2841                    "--owner",
2842                    &config.owner,
2843                    "--key-file",
2844                    &config.key_file,
2845                    "--change",
2846                    &id.change_id,
2847                    "--idempotency-key",
2848                    &id.idempotency_key,
2849                ];
2850                let body = workspace_body(&config.repo, &id.workspace_name, &flags);
2851                let (status, resp) = http(&config.api, auth, "choir_workspace", body);
2852                if !(200..300).contains(&status) {
2853                    return Err(choir_cli::runner::failure_from_response(
2854                        &resp,
2855                        "workspace creation",
2856                    ));
2857                }
2858                let response = runner_json(&resp, "invalid_response", "creation is not JSON")?;
2859                choir_cli::runner::verify_binding(&response, id)?;
2860                Ok(serde_json::json!({
2861                    "protocol_version": choir_cli::runner::PROTOCOL_VERSION,
2862                    "operation": "ensure",
2863                    "workspace": {
2864                        "path": response.get("path").cloned().unwrap_or(serde_json::Value::Null),
2865                        "created_now": response.get("created") == Some(&serde_json::json!(true)),
2866                    },
2867                    "binding": binding_json(
2868                        id,
2869                        &config,
2870                        Some(&choir_cli::runner::bound_base(&response, &base)),
2871                    ),
2872                    "receipt": response.get("operation").cloned()
2873                        .unwrap_or_else(|| serde_json::json!({})),
2874                }))
2875            }
2876            Operation::Checkpoint => {
2877                let revision = request.base.clone().ok_or_else(|| {
2878                    Failure::terminal(
2879                        "invalid_request",
2880                        "checkpoint needs the exact committed and pushed Git object id as base",
2881                    )
2882                })?;
2883                let Some(revision_hash) = choir_hash::ContentHash::from_git_oid(&revision) else {
2884                    return Err(Failure::terminal(
2885                        "invalid_request",
2886                        "checkpoint base must be a 40- or 64-char hex Git object id",
2887                    ));
2888                };
2889                let prev_revision = current_change_revision(&config.api, auth, &id.change_id);
2890                let op = ViewOp::new(OpKind::CheckpointChange {
2891                    id: id.change_id.clone(),
2892                    workspace: id.workspace_id.clone(),
2893                    revision: revision_hash,
2894                    prev_revision,
2895                });
2896                let body = signed_body(&config.api, &config.key_file, &config.owner, &op, auth);
2897                let (status, resp) = http(&config.api, auth, "choir_submit", body);
2898                if !(200..300).contains(&status) {
2899                    return Err(choir_cli::runner::failure_from_response(
2900                        &resp,
2901                        "revision checkpoint",
2902                    ));
2903                }
2904                Ok(serde_json::json!({
2905                    "protocol_version": choir_cli::runner::PROTOCOL_VERSION,
2906                    "operation": "checkpoint",
2907                    "checkpoint": {
2908                        "change_id": id.change_id,
2909                        "workspace_id": id.workspace_id,
2910                        "revision_id": revision,
2911                    },
2912                    "receipt": runner_json(&resp, "invalid_response", "checkpoint is not JSON")
2913                        .unwrap_or_else(|_| serde_json::json!({})),
2914                }))
2915            }
2916            Operation::Archive => {
2917                let prev_revision = current_change_revision(&config.api, auth, &id.change_id);
2918                let authorization = ArchiveAuthorization::new(
2919                    id.change_id.clone(),
2920                    id.workspace_id.clone(),
2921                    prev_revision,
2922                );
2923                let mut body = signed_payload_body(
2924                    &config.key_file,
2925                    &config.owner,
2926                    &authorization.to_payload(),
2927                );
2928                body["repo"] = serde_json::json!(config.repo);
2929                body["name"] = serde_json::json!(id.workspace_name);
2930                body["change"] = serde_json::json!(id.change_id);
2931                body["idempotency_key"] = serde_json::json!(id.idempotency_key);
2932                let (status, resp) = http(&config.api, auth, "choir_workspace_archive", body);
2933                if !(200..300).contains(&status) {
2934                    return Err(choir_cli::runner::failure_from_response(
2935                        &resp,
2936                        "workspace archive",
2937                    ));
2938                }
2939                let response = runner_json(&resp, "invalid_response", "archive is not JSON")?;
2940                choir_cli::runner::verify_binding(&response, id)?;
2941                Ok(serde_json::json!({
2942                    "protocol_version": choir_cli::runner::PROTOCOL_VERSION,
2943                    "operation": "archive",
2944                    "archive": {
2945                        "workspace_id": id.workspace_id,
2946                        "change_id": id.change_id,
2947                        "archived_path": response.get("archived_path").cloned()
2948                            .unwrap_or(serde_json::Value::Null),
2949                        "already_archived":
2950                            response.get("already_archived") == Some(&serde_json::json!(true)),
2951                    },
2952                    "receipt": response.get("operation").cloned()
2953                        .unwrap_or_else(|| serde_json::json!({})),
2954                }))
2955            }
2956        }
2957    })();
2958    runner_finish(&outcome);
2959}
2960
2961/// The binding an adapter echoes back so its orchestrator can store it.
2962fn binding_json(
2963    id: &choir_cli::runner::Identity,
2964    config: &choir_cli::runner::Config,
2965    base: Option<&str>,
2966) -> serde_json::Value {
2967    serde_json::json!({
2968        "repo": config.repo,
2969        "owner": config.owner,
2970        "scheme": id.scheme.as_str(),
2971        "workspace_id": id.workspace_id,
2972        "workspace_name": id.workspace_name,
2973        "change_id": id.change_id,
2974        "idempotency_key": id.idempotency_key,
2975        "base": base,
2976    })
2977}
2978
2979fn parse_auth(args: &[String]) -> (AuthOptions<'_>, &[String]) {
2980    let mut file = None;
2981    let mut user = None;
2982    let mut index = 0;
2983    while let Some(flag) = args.get(index) {
2984        let slot = match flag.as_str() {
2985            "--auth-file" if file.is_none() => &mut file,
2986            "--auth-user" if user.is_none() => &mut user,
2987            "--auth-file" | "--auth-user" => usage(),
2988            _ => break,
2989        };
2990        let Some(value) = args.get(index + 1) else {
2991            usage();
2992        };
2993        *slot = Some(value.as_str());
2994        index += 2;
2995    }
2996    if user.is_some() && file.is_none() {
2997        eprintln!("choir: --auth-user needs --auth-file");
2998        std::process::exit(2);
2999    }
3000    (
3001        AuthOptions {
3002            file,
3003            user,
3004            explicit: index > 0,
3005        },
3006        &args[index..],
3007    )
3008}
3009
3010fn workspace_body(repo: &str, name: &str, rest: &[&str]) -> serde_json::Value {
3011    if rest.is_empty() {
3012        return serde_json::json!({ "repo": repo, "name": name });
3013    }
3014    let (mut base, mut owner, mut key_file, mut change, mut idempotency_key) =
3015        (None, None, None, None, None);
3016    // Repeatable, unlike the five above: a change works within as many
3017    // subtrees as it works within, and one flag per prefix is the same
3018    // spelling `git sparse-checkout set` takes.
3019    let mut cone: Vec<String> = Vec::new();
3020    let mut index = 0;
3021    while index < rest.len() {
3022        let Some(value) = rest.get(index + 1).copied() else {
3023            usage();
3024        };
3025        if rest[index] == "--path" {
3026            cone.push(value.to_string());
3027            index += 2;
3028            continue;
3029        }
3030        let slot = match rest[index] {
3031            "--base" if base.is_none() => &mut base,
3032            "--owner" if owner.is_none() => &mut owner,
3033            "--key-file" if key_file.is_none() => &mut key_file,
3034            "--change" if change.is_none() => &mut change,
3035            "--idempotency-key" if idempotency_key.is_none() => &mut idempotency_key,
3036            _ => usage(),
3037        };
3038        *slot = Some(value);
3039        index += 2;
3040    }
3041    // Sorted and deduplicated before signing so that two clients naming
3042    // the same subtrees in different orders produce the same
3043    // authorization bytes. Canonical ordering is not decoration here:
3044    // the node rebuilds these bytes to verify the signature.
3045    cone.sort();
3046    cone.dedup();
3047    let (Some(base), Some(owner), Some(key_file), Some(change), Some(idempotency_key)) =
3048        (base, owner, key_file, change, idempotency_key)
3049    else {
3050        usage();
3051    };
3052    let Some(base_revision) = choir_hash::ContentHash::from_git_oid(base) else {
3053        eprintln!("<git-oid> must be a 40- or 64-char hex object id");
3054        std::process::exit(2);
3055    };
3056    let authorization = CreateAuthorization::new(
3057        change.into(),
3058        owner.into(),
3059        format!("{repo}/{name}"),
3060        base_revision,
3061        idempotency_key.into(),
3062    )
3063    .with_cone(cone);
3064    let mut body = signed_payload_body(key_file, owner, &authorization.to_payload());
3065    body["repo"] = serde_json::json!(repo);
3066    body["name"] = serde_json::json!(name);
3067    body["base"] = serde_json::json!(base);
3068    body["owner"] = serde_json::json!(owner);
3069    body["change"] = serde_json::json!(change);
3070    body["idempotency_key"] = serde_json::json!(idempotency_key);
3071    body
3072}
3073
3074fn parse_content_hash_hex(value: &str) -> Option<choir_hash::ContentHash> {
3075    let (codec, digest) = value.split_once('-')?;
3076    let codec = u8::from_str_radix(codec, 16).ok()?;
3077    if digest.is_empty() || digest.len() % 2 != 0 {
3078        return None;
3079    }
3080    let digest = (0..digest.len())
3081        .step_by(2)
3082        .map(|index| u8::from_str_radix(digest.get(index..index + 2)?, 16).ok())
3083        .collect::<Option<Vec<_>>>()?;
3084    Some(choir_hash::ContentHash { codec, digest })
3085}
3086
3087fn current_change_revision(
3088    api: &str,
3089    auth: AuthOptions<'_>,
3090    change_id: &str,
3091) -> choir_hash::ContentHash {
3092    let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
3093    if !(200..300).contains(&status) {
3094        finish(status, &body);
3095    }
3096    let view: serde_json::Value = match serde_json::from_str(&body) {
3097        Ok(view) => view,
3098        Err(error) => {
3099            eprintln!("choir: view returned invalid JSON: {error}");
3100            std::process::exit(1);
3101        }
3102    };
3103    let Some(revision) = view["changes"][change_id]["revision_id"].as_str() else {
3104        eprintln!("choir: no such change or revision in GET /api/view");
3105        std::process::exit(1);
3106    };
3107    match parse_content_hash_hex(revision) {
3108        Some(revision) => revision,
3109        None => {
3110            eprintln!("choir: change revision has an invalid content-hash envelope");
3111            std::process::exit(1);
3112        }
3113    }
3114}
3115
3116/// The node URL configured for this directory, if any.
3117///
3118/// Walks up from the working directory looking for `.choir/config`, the
3119/// way git finds a repository. A *file* rather than an environment
3120/// variable on purpose: this workspace takes configuration from flags
3121/// and files, and the handful of environment reads that exist are
3122/// deliberately not configuration.
3123///
3124/// Walking up rather than reading one fixed path means a checkout can
3125/// name the node it belongs to, which is the same thing a git remote
3126/// does and needs no explaining to anybody who has used one.
3127/// What `choir node serve` and `choir node install` were told, after
3128/// defaults.
3129///
3130/// One parser for both because they describe the same node: a
3131/// supervised node and a hand-started one must be the same command with
3132/// the same arguments, and two parsers is how they stop being.
3133struct NodeOptions {
3134    state: std::path::PathBuf,
3135    port: u16,
3136    create: Vec<String>,
3137    extra: Vec<String>,
3138}
3139
3140/// Parses the options both node-starting commands take.
3141///
3142/// Everything after `--` belongs to the daemon and is not looked at, so
3143/// a daemon flag this side has never heard of still reaches it. Without
3144/// that, every new daemon flag would be a reason to stop using these
3145/// commands and go back to spelling out the whole invocation.
3146fn node_options(command: &str, rest: &[&str]) -> NodeOptions {
3147    let (mine, extra) = match rest.iter().position(|a| *a == "--") {
3148        Some(at) => (&rest[..at], &rest[at + 1..]),
3149        None => (rest, &rest[rest.len()..]),
3150    };
3151    let mut state: Option<String> = None;
3152    let mut port: Option<u16> = None;
3153    let mut create: Vec<String> = Vec::new();
3154    let mut i = 0;
3155    while i < mine.len() {
3156        let name = mine[i];
3157        let Some(value) = mine.get(i + 1) else {
3158            eprintln!("{command}: {name} needs a value");
3159            std::process::exit(2);
3160        };
3161        match name {
3162            "--state" => state = Some((*value).to_string()),
3163            "--create" => create.push((*value).to_string()),
3164            "--port" => match value.parse::<u16>() {
3165                Ok(n) => port = Some(n),
3166                Err(_) => {
3167                    eprintln!("{command}: --port needs a port number, not {value:?}");
3168                    std::process::exit(2);
3169                }
3170            },
3171            other => {
3172                eprintln!(
3173                    "{command}: unknown option {other:?}\n\n  \
3174                     daemon flags go after `--`: {command} -- {other} ..."
3175                );
3176                std::process::exit(2);
3177            }
3178        }
3179        i += 2;
3180    }
3181    NodeOptions {
3182        state: state
3183            .map(std::path::PathBuf::from)
3184            .unwrap_or_else(state_dir),
3185        // The configured node names the port every client command will
3186        // use, so reading it back is what keeps the daemon and its
3187        // clients agreeing without the port being written down twice.
3188        port: port
3189            .or_else(|| {
3190                configured_node()
3191                    .as_deref()
3192                    .and_then(choir_cli::serve::port_of)
3193            })
3194            .unwrap_or(8417),
3195        create,
3196        extra: extra.iter().map(|a| (*a).to_string()).collect(),
3197    }
3198}
3199
3200/// `~/.choir`, the layout `choir init` writes.
3201///
3202/// `HOME` describes the machine rather than carrying a setting of ours,
3203/// which is the same reason `choir init` may read it.
3204fn state_dir() -> std::path::PathBuf {
3205    std::env::var_os("HOME")
3206        .map(std::path::PathBuf::from)
3207        .unwrap_or_default()
3208        .join(".choir")
3209}
3210
3211/// The home directory the service manager keeps its units under.
3212fn home_dir() -> std::path::PathBuf {
3213    std::env::var_os("HOME")
3214        .map(std::path::PathBuf::from)
3215        .unwrap_or_default()
3216}
3217
3218/// The service manager, or a refusal naming what this machine is.
3219fn supervisor(command: &str) -> choir_cli::supervise::Supervisor {
3220    match choir_cli::supervise::Supervisor::detect() {
3221        Some(supervisor) => supervisor,
3222        None => {
3223            eprintln!(
3224                "{command}: no service manager known for {}\n\n  \
3225                 run it in the foreground instead: choir node serve",
3226                std::env::consts::OS
3227            );
3228            std::process::exit(1);
3229        }
3230    }
3231}
3232
3233/// One line of `choir host`'s progress.
3234///
3235/// Every sub-step prints one of these as it happens rather than a
3236/// summary at the end, because the steps have wildly different
3237/// durations — `init` is a few files, `certbot` is a network round trip
3238/// with a challenge in it — and a command that prints nothing for
3239/// fifteen seconds is a command people interrupt.
3240fn step(what: &str, detail: &str) {
3241    let style = choir_cli::style::Style::for_stderr();
3242    eprintln!("  {}  {:14}  {detail}", style.green("ok"), style.dim(what));
3243}
3244
3245/// `choir host` stopping to hand one thing back to the person running it.
3246///
3247/// Exit 3, not 1: "everything up to here worked and something only you
3248/// can supply is missing" is a different outcome from "this failed", and
3249/// `choir restore` already spends 3 on exactly that distinction. A
3250/// `sudo` line and a re-run is the shape, every time.
3251fn handover(done: &[String], why: &str, paste: &[String], retry: &str) -> ! {
3252    let style = choir_cli::style::Style::for_stderr();
3253    eprintln!("\n  {} {why}\n", style.cyan("next:"));
3254    for line in paste {
3255        eprintln!("    {line}");
3256    }
3257    eprintln!("\n  {} {retry}", style.dim("then:"));
3258    if !done.is_empty() {
3259        eprintln!("\n  {} {}", style.dim("already done:"), done.join(", "));
3260    }
3261    eprintln!();
3262    std::process::exit(3)
3263}
3264
3265/// `choir host` giving up, having said what it got through.
3266fn host_failed(done: &[String], what: &str, why: &str, retry: &str) -> ! {
3267    let style = choir_cli::style::Style::for_stderr();
3268    eprintln!("\n  {} {what}: {why}\n", style.red("failed"));
3269    if !done.is_empty() {
3270        eprintln!("  {} {}\n", style.dim("already done:"), done.join(", "));
3271    }
3272    eprintln!("  {} {retry}\n", style.dim("retry:"));
3273    std::process::exit(1)
3274}
3275
3276/// Renders the supervision file and hands the node to the service
3277/// manager, returning the unit written and the `choir` it runs.
3278///
3279/// Factored out of `choir node install` when `choir host` needed to do
3280/// exactly this as one of its steps. Not "install, but quieter": the
3281/// same refusals in the same order, because the way a first-run command
3282/// goes wrong is by being a *second* implementation of the thing it is
3283/// composing, one refusal short.
3284///
3285/// # Errors
3286///
3287/// Returns the sentence the caller should print, for a machine with no
3288/// service manager, a `choir` in a build directory, a state directory
3289/// with no node in it, or a service manager that refused.
3290fn install_unit(
3291    state: &std::path::Path,
3292    port: u16,
3293    extra: &[String],
3294) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
3295    let Some(supervisor) = choir_cli::supervise::Supervisor::detect() else {
3296        return Err(format!(
3297            "no service manager known for {}\n\n  \
3298             run it in the foreground instead: choir node serve",
3299            std::env::consts::OS
3300        ));
3301    };
3302    // Refused rather than warned about: a unit pointing into a build
3303    // directory breaks on the next `cargo clean`, and it breaks at
3304    // reboot, which is the moment nobody is watching.
3305    let exe = std::env::current_exe().map_err(|_| "cannot find my own path".to_string())?;
3306    if choir_cli::supervise::in_build_directory(&exe) {
3307        return Err(format!(
3308            "this `choir` lives in a build directory:\n    {}\n\n  \
3309             a unit pointing there stops working at the next `cargo clean`,\n  \
3310             and it stops working at reboot. install it first:\n\n    \
3311             cargo build --release -p choir-cli -p choir-node\n    \
3312             cp target/release/choir target/release/choir-node ~/.local/bin/",
3313            exe.display()
3314        ));
3315    }
3316    // The same refusal `serve` makes, made before a unit exists rather
3317    // than after the service manager has started failing to run it every
3318    // ten seconds.
3319    let layout = choir_cli::serve::Layout::new(state, port);
3320    if !layout.missing().is_empty() {
3321        return Err(format!(
3322            "no node in {} yet\n\n  create one: choir init",
3323            state.display()
3324        ));
3325    }
3326    let home = home_dir();
3327    let unit = supervisor.unit_path(&home);
3328    if let Some(parent) = unit.parent() {
3329        std::fs::create_dir_all(parent)
3330            .map_err(|error| format!("create {}: {error}", parent.display()))?;
3331    }
3332    let body = supervisor.render(&exe, state, port, extra);
3333    choir_fs::write_atomic(&unit, body)
3334        .map_err(|error| format!("write {}: {error}", unit.display()))?;
3335    let steps = supervisor.commands(choir_cli::supervise::Action::Install, &home);
3336    // The teardown step fails when nothing is loaded, which is exactly
3337    // the first-install case.
3338    let last = steps.len().saturating_sub(1);
3339    for (at, step) in steps.iter().enumerate() {
3340        if !run_step(step, at != last) {
3341            return Err("the service manager refused".to_string());
3342        }
3343    }
3344    Ok((unit, exe))
3345}
3346
3347/// Runs one service-manager command, reporting the ones that matter.
3348///
3349/// `launchctl bootout` on a job that is not loaded fails, and that
3350/// failure is the normal case on a first install — so a step is allowed
3351/// to fail only when the caller says which one.
3352fn run_step(argv: &[String], allow_failure: bool) -> bool {
3353    let Some((program, args)) = argv.split_first() else {
3354        return true;
3355    };
3356    match std::process::Command::new(program).args(args).output() {
3357        Ok(out) if out.status.success() => true,
3358        Ok(out) => {
3359            if !allow_failure {
3360                let text = String::from_utf8_lossy(&out.stderr);
3361                eprintln!("  {} {}", argv.join(" "), text.trim());
3362            }
3363            allow_failure
3364        }
3365        Err(error) => {
3366            if !allow_failure {
3367                eprintln!("  {}: {error}", argv.join(" "));
3368            }
3369            allow_failure
3370        }
3371    }
3372}
3373
3374fn configured_node() -> Option<String> {
3375    configured("node")
3376}
3377
3378/// One key out of the nearest `.choir/config`.
3379///
3380/// Generalised from the `node` lookup when the credential gained the
3381/// same need: a checkout that names its node and a checkout that names
3382/// the credential for it are the same question asked twice, and two
3383/// parsers for one file is one of them drifting.
3384/// The walk order, stated once so the surface can quote it: every
3385/// `.choir/config` from the working directory up to the filesystem root,
3386/// then `~/.choir/config`.
3387///
3388/// The home file is the fallback and not the first stop, so a checkout
3389/// that names its own node still wins on a machine that has joined a
3390/// different one. It exists because the walk alone cannot answer for a
3391/// contributor who joins in one directory and clones into another:
3392/// `~/src/foo` is not under `~` in any sense the walk can see once they
3393/// have `cd`'d into it — it is, but only because `$HOME` happens to be a
3394/// parent, which stops being true the moment they clone into `/srv` or
3395/// onto another volume.
3396///
3397/// The alternative considered was writing the node into each clone at
3398/// clone time through the credential helper. It was rejected because git
3399/// gives a helper no hook that fires on `git clone` — the helper is
3400/// asked for a credential, not told a repository was created — so the
3401/// write would have to happen on the first *authenticated* fetch, which
3402/// is after the contributor has already run a command that needed it.
3403fn configured(want: &str) -> Option<String> {
3404    let mut dir = std::env::current_dir().ok();
3405    while let Some(here) = dir {
3406        if let Some(found) = configured_in(&here.join(".choir/config"), want) {
3407            return Some(found);
3408        }
3409        let mut up = here;
3410        if !up.pop() {
3411            break;
3412        }
3413        dir = Some(up);
3414    }
3415    configured_in(&state_dir().join("config"), want)
3416}
3417
3418/// One key out of one `.choir/config` file.
3419fn configured_in(path: &std::path::Path, want: &str) -> Option<String> {
3420    let text = std::fs::read_to_string(path).ok()?;
3421    for line in text.lines() {
3422        let line = line.trim();
3423        if line.starts_with('#') {
3424            continue;
3425        }
3426        if let Some((key, value)) = line.split_once('=') {
3427            if key.trim() == want {
3428                let value = value.trim();
3429                if !value.is_empty() {
3430                    return Some(value.to_string());
3431                }
3432            }
3433        }
3434    }
3435    None
3436}
3437
3438/// Fills in the node URL for a command that takes one and was not given
3439/// one.
3440///
3441/// Every command whose spec begins with `<api>` takes it as its first
3442/// argument, and an api is always a URL, so "the first argument is not a
3443/// URL" is an unambiguous test rather than a guess. The set of such
3444/// commands is read from the surface table rather than listed here,
3445/// because a second list is a second thing to forget.
3446///
3447/// An explicit URL always wins: this only ever fills a gap.
3448fn with_configured_node(args: &[String]) -> Vec<String> {
3449    if args.is_empty() {
3450        return args.to_vec();
3451    }
3452    // A command name can be two words -- `acl render`, `node status`,
3453    // `repo create` -- and the api follows the whole name, not the first
3454    // word of it. Matching only `args[0]` meant every two-word command
3455    // silently lost `.choir/config`: `choir repo create me/thing.git`
3456    // was read as a one-word command with a repository where its node
3457    // should be, and refused.
3458    let two = (args.len() >= 2).then(|| format!("{} {}", args[0], args[1]));
3459    let (name, words) = match two {
3460        Some(two) if choir_cli::surface::COMMANDS.iter().any(|c| c.name == two) => (two, 2),
3461        _ => (args[0].clone(), 1),
3462    };
3463    let takes_api = choir_cli::surface::COMMANDS
3464        .iter()
3465        .any(|c| c.name == name && c.args.starts_with("<api>"));
3466    if !takes_api {
3467        return args.to_vec();
3468    }
3469    let given = args.get(words).map(String::as_str).unwrap_or("");
3470    if given.starts_with("http://") || given.starts_with("https://") {
3471        return args.to_vec();
3472    }
3473    let Some(node) = configured_node() else {
3474        return args.to_vec();
3475    };
3476    let mut filled = Vec::with_capacity(args.len() + 1);
3477    filled.extend(args[..words].iter().cloned());
3478    filled.push(node);
3479    filled.extend(args[words..].iter().cloned());
3480    filled
3481}
3482
3483fn main() {
3484    let args: Vec<String> = std::env::args().skip(1).collect();
3485    // `choir <command> --help` before anything else parses: a reader
3486    // asking what a command takes must not have to satisfy its argument
3487    // rules to be told.
3488    let style = choir_cli::style::Style::for_stdout();
3489    // What this binary was built from, before anything else parses.
3490    //
3491    // The stamp is the node crate's, which is this workspace's, which is
3492    // the commit this file was compiled at -- not a `git rev-parse` in
3493    // whatever directory the reader happens to be standing in. That
3494    // difference is the whole point: "I rebuilt it" and "the rebuild is
3495    // what is running" are separate claims, and only the binary can
3496    // settle the second.
3497    if args.first().is_some_and(|a| a == "--version" || a == "-V") {
3498        println!("choir {}", choir_node::build_line());
3499        std::process::exit(0);
3500    }
3501    // `choir acl render --help` names a two-word command; every other
3502    // command's help is under args[1].
3503    //
3504    // Recognised in place rather than by rewriting `-h` to `--help`
3505    // first: a rewrite pass has to guess how far right a flag can stand
3506    // before it becomes somebody's argument, and it guesses wrong.
3507    // `choir key <file> -h` names an actor `-h`, and the rewriting
3508    // version of this printed a binding for an actor called `--help`.
3509    let asked = match args.iter().position(|a| is_help(a)) {
3510        Some(1) => Some(args[0].clone()),
3511        // Any two-word command, not just `acl render`: the table knows
3512        // which names have a space in them, and a second list here is a
3513        // second thing to forget when one is added.
3514        Some(2)
3515            if choir_cli::surface::COMMANDS
3516                .iter()
3517                .any(|c| c.name == format!("{} {}", args[0], args[1])) =>
3518        {
3519            Some(format!("{} {}", args[0], args[1]))
3520        }
3521        _ => None,
3522    };
3523    if let Some(name) = asked {
3524        if let Some(help) = choir_cli::surface::command_help_in(&name, style) {
3525            print!("{help}");
3526            std::process::exit(0);
3527        }
3528    }
3529    if args.first().is_some_and(|a| is_help(a)) {
3530        print!("{}", choir_cli::surface::usage_in(style));
3531        std::process::exit(0);
3532    }
3533    let (auth, args) = parse_auth(&args);
3534    let args = with_configured_node(args);
3535    let args: Vec<&str> = args.iter().map(String::as_str).collect();
3536    match args.as_slice() {
3537        // With a name, prints the line that *binds* this key to one
3538        // review channel; without, the unconstrained form. Either way the
3539        // operator appends the output to the node's trusted-keys file.
3540        ["key", key_file, rest @ ..] if rest.len() <= 1 && auth.is_empty() => {
3541            let key = load_key(key_file);
3542            let hex = hex_encode(&key.public_key_bytes());
3543            match rest.first() {
3544                Some(name) => println!("{name} {hex}"),
3545                None => println!("{hex}"),
3546            }
3547        }
3548        // The first command anybody runs, so it takes no <api>: there
3549        // is no node yet to name.
3550        ["init", rest @ ..] if auth.is_empty() => {
3551            let (mut dir, mut port, mut force) = (None, 8417u16, false);
3552            let mut it = rest.iter();
3553            while let Some(arg) = it.next() {
3554                match *arg {
3555                    "--force" => force = true,
3556                    "--port" => {
3557                        let Some(value) = it.next().and_then(|v| v.parse().ok()) else {
3558                            usage()
3559                        };
3560                        port = value;
3561                    }
3562                    other if !other.starts_with('-') && dir.is_none() => dir = Some(other),
3563                    _ => usage(),
3564                }
3565            }
3566            let style = choir_cli::style::Style::for_stdout();
3567            // `HOME` describes the machine rather than carrying a
3568            // setting of ours, which is the same reason `choir-queue`
3569            // may read it. A state directory can still be given
3570            // explicitly, and is the only way to get one elsewhere.
3571            let state = match dir {
3572                Some(dir) => std::path::PathBuf::from(dir),
3573                None => match std::env::var_os("HOME") {
3574                    Some(home) => std::path::PathBuf::from(home).join(".choir"),
3575                    None => {
3576                        eprintln!(
3577                            "{} no HOME, so there is no default state directory\n\
3578                             \n  choir init <state-dir>",
3579                            style.red("choir init:")
3580                        );
3581                        std::process::exit(2);
3582                    }
3583                },
3584            };
3585            let plan = choir_cli::init::Plan::new(&state, port);
3586            match choir_cli::init::run(&plan, force) {
3587                Ok(made) => {
3588                    let rows: Vec<(&str, String)> = vec![
3589                        ("repos", plan.repos.display().to_string()),
3590                        ("auth", format!("{} (0600)", plan.auth.display())),
3591                        ("key", format!("{} (0600)", plan.key.display())),
3592                        ("trusted", plan.trusted.display().to_string()),
3593                        (
3594                            "config",
3595                            format!("{} -> {}", plan.config.display(), plan.node_url()),
3596                        ),
3597                    ];
3598                    note("ready", &rows);
3599                    if !made.replaced.is_empty() {
3600                        eprintln!(
3601                            "  {} replaced {} existing file(s); the previous credential is gone\n",
3602                            style.red("--force:"),
3603                            made.replaced.len()
3604                        );
3605                    }
3606                    // The two commands that follow, because knowing what
3607                    // was created is not the same as knowing what to do
3608                    // with it. On stdout so `$(choir init)` is the
3609                    // command it names; the commentary around it is not.
3610                    //
3611                    // `choir node serve`, not the daemon's own argv:
3612                    // this printed the six-flag `choir-node` line until
3613                    // there was a command that derived those flags, and
3614                    // a first run that begins by pasting paths teaches
3615                    // the paths rather than the tool.
3616                    let serve = match dir {
3617                        Some(_) => format!("choir node serve --state {}", state.display()),
3618                        None => "choir node serve".to_string(),
3619                    };
3620                    println!("{serve}");
3621                    eprintln!(
3622                        "  {} run the line above, then:\n    choir repo create {} me/thing.git\n",
3623                        style.dim("next"),
3624                        plan.node_url()
3625                    );
3626                    let _ = made.user;
3627                }
3628                Err(error) => {
3629                    eprintln!("{} {error}", style.red("choir init:"));
3630                    std::process::exit(1);
3631                }
3632            }
3633        }
3634        // Beside `init` rather than under `node`, and for the same
3635        // reason: `choir node …` is the family for a node that exists,
3636        // and this is the command you run when there is not one yet.
3637        ["host", rest @ ..] if auth.is_empty() => host(rest),
3638        // The privileged step, with a name, so that it appears in
3639        // `--help`, in the shell history and in sudo's log as itself
3640        // rather than as an argument to something friendlier.
3641        ["node", "tls", rest @ ..] => node_tls(rest),
3642        // The one command whose whole point is that the node is
3643        // already running: everything else about a repository assumes
3644        // it exists, and until this there was no way to make one
3645        // without stopping the daemon and naming it in `--create`.
3646        ["repo", "create", api, name] => {
3647            let client = match choir_cli::mcp::HttpClient::new(
3648                api,
3649                auth.file_for(api).map(std::path::Path::new),
3650                auth.user,
3651            ) {
3652                Ok(client) => client,
3653                Err(error) => {
3654                    eprintln!("choir: {error}");
3655                    std::process::exit(2);
3656                }
3657            };
3658            let endpoint = choir_cli::surface::endpoint("POST", "/api/repo")
3659                .expect("the repo endpoint is in the table");
3660            let body = serde_json::json!({ "name": name });
3661            match client.request(endpoint, &body) {
3662                Ok((status, response)) => {
3663                    // The clone URL is assembled here rather than by the
3664                    // node, because the node does not know how the
3665                    // caller reached it: behind a proxy its own base is
3666                    // not the one that works from out here.
3667                    //
3668                    // On stderr, through `note`, because stdout is the
3669                    // node's own JSON byte for byte -- read by agents,
3670                    // by `jq` and by the tests. A convenience line
3671                    // printed above it would break all three.
3672                    if (200..300).contains(&status) {
3673                        note(
3674                            "repository created",
3675                            &[("clone", format!("{}/{name}", api.trim_end_matches('/')))],
3676                        );
3677                    }
3678                    finish(status, &response);
3679                }
3680                Err(error) => {
3681                    eprintln!("choir: {error}");
3682                    std::process::exit(1);
3683                }
3684            }
3685        }
3686        // Two words, like `acl render`: `node` is a family rather than
3687        // a command, and `choir node` alone should say so rather than
3688        // guessing which member was meant.
3689        // Everything after `--` belongs to the daemon, so a flag this
3690        // command has never heard of is still reachable. The split is
3691        // done here rather than in `serve::plan` because only this side
3692        // sees the raw argv.
3693        ["node", "serve", rest @ ..] => {
3694            let style = choir_cli::style::Style::for_stdout();
3695            let options = node_options("choir node serve", rest);
3696            let layout = choir_cli::serve::Layout::new(&options.state, options.port);
3697            let port = options.port;
3698            let program = match choir_cli::serve::find_daemon() {
3699                Ok(program) => program,
3700                Err(error) => {
3701                    eprintln!("{} {error}", style.red("choir node serve:"));
3702                    std::process::exit(1);
3703                }
3704            };
3705            let invocation =
3706                match choir_cli::serve::plan(program, &layout, &options.create, &options.extra) {
3707                    Ok(invocation) => invocation,
3708                    Err(error) => {
3709                        eprintln!("{} {error}", style.red("choir node serve:"));
3710                        std::process::exit(1);
3711                    }
3712                };
3713            if choir_cli::serve::port_taken(port) {
3714                eprintln!(
3715                    "{} something is already listening on 127.0.0.1:{port}\n\n  \
3716                     is it yours?  choir node status\n  \
3717                     use another:  choir node serve --port <n>",
3718                    style.red("choir node serve:")
3719                );
3720                std::process::exit(1);
3721            }
3722            // On stderr: stdout belongs to the daemon from the next line
3723            // onwards, and a reader piping it should not receive ours.
3724            eprintln!("{}", style.dim(&invocation.display()));
3725            eprintln!("{} {}", style.dim("serving"), layout.port);
3726            eprintln!("{}", style.red(&choir_cli::serve::exec(&invocation)));
3727            std::process::exit(1);
3728        }
3729        ["repo", "list", api] => {
3730            let style = choir_cli::style::Style::for_stdout();
3731            let client = match choir_cli::mcp::HttpClient::new(
3732                api,
3733                auth.file_for(api).map(std::path::Path::new),
3734                auth.user,
3735            ) {
3736                Ok(client) => client,
3737                Err(error) => {
3738                    eprintln!("{} {error}", style.red("choir repo list:"));
3739                    std::process::exit(2);
3740                }
3741            };
3742            let (status, body) = match client.get("/api/repos") {
3743                Ok(answer) => answer,
3744                Err(error) => {
3745                    eprintln!("{} {error}", style.red("choir repo list:"));
3746                    std::process::exit(1);
3747                }
3748            };
3749            if status != 200 {
3750                eprintln!("{} {status}: {body}", style.red("choir repo list:"));
3751                std::process::exit(1);
3752            }
3753            let answer: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
3754            let names: Vec<&str> = answer["repos"]
3755                .as_array()
3756                .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect())
3757                .unwrap_or_default();
3758            for name in &names {
3759                println!("{name}");
3760            }
3761            // The two empty answers are different problems, and a bare
3762            // blank line does not say which one this is.
3763            if names.is_empty() {
3764                if answer["narrowed"].as_bool().unwrap_or(false) {
3765                    eprintln!(
3766                        "  {}\n",
3767                        style.dim("no repositories this credential can read")
3768                    );
3769                } else {
3770                    eprintln!(
3771                        "  {}\n    choir repo create {api} me/thing.git\n",
3772                        style.dim("no repositories on this node yet — make one:")
3773                    );
3774                }
3775            }
3776        }
3777        // The credential is deliberately not in the URL. A clone URL is
3778        // pasted into shells, screenshots and issue trackers, and a
3779        // token in one is a token in all three; the credential helper
3780        // line beside it does the same job and leaves no copy behind.
3781        ["repo", "url", api, name] => {
3782            let style = choir_cli::style::Style::for_stdout();
3783            let name = if name.ends_with(".git") {
3784                (*name).to_string()
3785            } else {
3786                format!("{name}.git")
3787            };
3788            let url = format!("{}/{name}", api.trim_end_matches('/'));
3789            println!("{url}");
3790            let credential = auth
3791                .file_for(api)
3792                .map(str::to_string)
3793                .unwrap_or_else(|| format!("{}/auth", state_dir().display()));
3794            eprintln!(
3795                "\n  {}\n    git clone {url}\n    git -C {} config credential.helper \\\n      \
3796                 '!choir git-credential {credential}'\n",
3797                style.dim("clone it, then teach git the credential:"),
3798                name.trim_end_matches(".git")
3799                    .rsplit('/')
3800                    .next()
3801                    .unwrap_or("repo")
3802            );
3803        }
3804        // The unit runs `choir node serve`, so it names a command
3805        // rather than a configuration: a daemon flag changing later
3806        // never means re-rendering supervision, which is how a plist
3807        // that lints clean and restarts cleanly ends up launching
3808        // yesterday's arguments.
3809        ["node", "install", rest @ ..] => {
3810            let style = choir_cli::style::Style::for_stdout();
3811            let command = "choir node install";
3812            let options = node_options(command, rest);
3813            let layout = choir_cli::serve::Layout::new(&options.state, options.port);
3814            let unit = match install_unit(&options.state, options.port, &options.extra) {
3815                Ok((unit, _)) => unit,
3816                Err(error) => {
3817                    eprintln!("{} {error}", style.red(&format!("{command}:")));
3818                    std::process::exit(1);
3819                }
3820            };
3821            note(
3822                "installed",
3823                &[
3824                    ("unit", unit.display().to_string()),
3825                    ("state", options.state.display().to_string()),
3826                    ("log", layout.log.display().to_string()),
3827                ],
3828            );
3829            eprintln!("  {} choir node status\n", style.dim("check it"));
3830        }
3831        ["node", "stop"] => {
3832            let style = choir_cli::style::Style::for_stdout();
3833            let supervisor = supervisor("choir node stop");
3834            let home = home_dir();
3835            for step in supervisor.commands(choir_cli::supervise::Action::Stop, &home) {
3836                if !run_step(&step, false) {
3837                    eprintln!("{} nothing was running", style.red("choir node stop:"));
3838                    std::process::exit(1);
3839                }
3840            }
3841            eprintln!(
3842                "stopped — the unit is still installed, so it returns at next login\n  \
3843                 to end it: choir node uninstall"
3844            );
3845        }
3846        // Torn down and re-bootstrapped, never kicked: a restart
3847        // relaunches the definition the service manager cached, so a
3848        // unit whose arguments changed restarts cleanly into the old
3849        // ones.
3850        ["node", "restart"] => {
3851            let style = choir_cli::style::Style::for_stdout();
3852            let command = "choir node restart";
3853            let supervisor = supervisor(command);
3854            let home = home_dir();
3855            let unit = supervisor.unit_path(&home);
3856            if !unit.exists() {
3857                eprintln!(
3858                    "{} nothing is installed here\n\n  install it: choir node install",
3859                    style.red(&format!("{command}:"))
3860                );
3861                std::process::exit(1);
3862            }
3863            let steps = supervisor.commands(choir_cli::supervise::Action::Install, &home);
3864            let last = steps.len().saturating_sub(1);
3865            for (at, step) in steps.iter().enumerate() {
3866                if !run_step(step, at != last) {
3867                    eprintln!(
3868                        "{} the service manager refused",
3869                        style.red(&format!("{command}:"))
3870                    );
3871                    std::process::exit(1);
3872                }
3873            }
3874            eprintln!("restarted {}", unit.display());
3875        }
3876        // The state directory is kept. It holds the keys, the
3877        // repositories and the op log, and no command of ours deletes
3878        // those — an uninstall that took the data with it would be the
3879        // one mistake this whole tool cannot undo.
3880        ["node", "uninstall"] => {
3881            let style = choir_cli::style::Style::for_stdout();
3882            let supervisor = supervisor("choir node uninstall");
3883            let home = home_dir();
3884            let unit = supervisor.unit_path(&home);
3885            for step in supervisor.commands(choir_cli::supervise::Action::Uninstall, &home) {
3886                run_step(&step, true);
3887            }
3888            match std::fs::remove_file(&unit) {
3889                Ok(()) => eprintln!("uninstalled {}", unit.display()),
3890                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3891                    eprintln!("nothing was installed at {}", unit.display());
3892                }
3893                Err(error) => {
3894                    eprintln!(
3895                        "{} remove {}: {error}",
3896                        style.red("choir node uninstall:"),
3897                        unit.display()
3898                    );
3899                    std::process::exit(1);
3900                }
3901            }
3902            eprintln!(
3903                "  {} {} — keys, repositories and the op log\n",
3904                style.dim("kept"),
3905                state_dir().display()
3906            );
3907            // The one thing `choir host` created that is not under the
3908            // state directory, and the one this command cannot remove:
3909            // it belongs to root. Left in place it is harmless — it
3910            // exits early when the marker is gone — but a renewal hook
3911            // nobody knows about is a renewal hook nobody removes, so it
3912            // is named rather than merely survived.
3913            let hook =
3914                std::path::Path::new(choir_cli::tls::HOOK_DIR).join(choir_cli::tls::HOOK_NAME);
3915            if hook.exists() {
3916                eprintln!(
3917                    "  {} {}\n    it does nothing once {} is gone; to remove it and the\n    \
3918                     certificate as well:\n\n      sudo rm {}\n      sudo certbot delete\n",
3919                    style.cyan("renewal hook still installed:"),
3920                    hook.display(),
3921                    state_dir().join("tls.enabled").display(),
3922                    hook.display(),
3923                );
3924            }
3925        }
3926        ["node", "logs", rest @ ..] => {
3927            let style = choir_cli::style::Style::for_stdout();
3928            let (lines, rest) = match rest.split_first() {
3929                Some((first, tail)) if !first.starts_with('-') => match first.parse::<usize>() {
3930                    Ok(n) => (n, tail),
3931                    Err(_) => {
3932                        eprintln!("choir node logs: <lines> must be a number, not {first:?}");
3933                        std::process::exit(2);
3934                    }
3935                },
3936                _ => (30, rest),
3937            };
3938            let options = node_options("choir node logs", rest);
3939            let log = choir_cli::serve::Layout::new(&options.state, options.port).log;
3940            match std::fs::read_to_string(&log) {
3941                Ok(text) => {
3942                    let all: Vec<&str> = text.lines().collect();
3943                    for line in all.iter().skip(all.len().saturating_sub(lines)) {
3944                        println!("{line}");
3945                    }
3946                }
3947                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3948                    eprintln!(
3949                        "{} no log at {}\n\n  \
3950                         a node started by hand writes to your terminal, not here;\n  \
3951                         the log is written by a supervised node: choir node install",
3952                        style.red("choir node logs:"),
3953                        log.display()
3954                    );
3955                    std::process::exit(1);
3956                }
3957                Err(error) => {
3958                    eprintln!(
3959                        "{} {}: {error}",
3960                        style.red("choir node logs:"),
3961                        log.display()
3962                    );
3963                    std::process::exit(1);
3964                }
3965            }
3966        }
3967        ["node", "status", rest @ ..] if rest.len() <= 1 => {
3968            let api = rest
3969                .first()
3970                .copied()
3971                .map(str::to_string)
3972                .or_else(configured_node);
3973            let Some(api) = api else {
3974                eprintln!(
3975                    "choir node status: no node given and none configured\n\
3976                     \n\
3977                       write `node = <url>` to .choir/config, or pass the URL"
3978                );
3979                std::process::exit(2);
3980            };
3981            let style = choir_cli::style::Style::for_stdout();
3982            match choir_cli::node::status(&api, auth.file_for(&api).map(std::path::Path::new)) {
3983                Ok((health, view)) => {
3984                    print!(
3985                        "{}",
3986                        choir_cli::node::status_report(&api, health, &view, style)
3987                    );
3988                    std::process::exit(health.exit_code());
3989                }
3990                Err(error) => {
3991                    eprintln!("{} {error}", style.red("choir node status:"));
3992                    std::process::exit(1);
3993                }
3994            }
3995        }
3996        // The only command that takes its node as an *optional*
3997        // argument. Everything else refuses without one; this one has
3998        // to keep working on a machine that has no node yet, because
3999        // "there is no node configured" is one of the things it reports.
4000        ["doctor", rest @ ..] if rest.len() <= 3 => {
4001            // `--state` for the same reason `node serve` takes one: a
4002            // machine can hold more than one node's state directory, and
4003            // the host half of this report is entirely about one of them.
4004            let (state, rest) = match rest {
4005                [head @ .., "--state", dir] | ["--state", dir, head @ ..] => {
4006                    (std::path::PathBuf::from(*dir), head.to_vec())
4007                }
4008                other if other.len() <= 1 => (state_dir(), other.to_vec()),
4009                _ => usage(),
4010            };
4011            let configured = configured_node();
4012            let api = rest.first().copied().map(str::to_string).or(configured);
4013            // The effective credential, not merely a given one: the
4014            // check that reports "no auth file" must not report it about
4015            // a node whose credential this command would have used.
4016            let effective = api.as_deref().and_then(|api| auth.file_for(api));
4017            let credential = effective.or(auth.file);
4018            let mut checks = choir_cli::doctor::run(api.as_deref(), credential);
4019            // The six host facts, and only on a machine that has a node:
4020            // "linger is off" told to a laptop that only ever clones is
4021            // six rows of noise on a report whose whole value is that
4022            // every row means something.
4023            if choir_cli::serve::Layout::new(&state, 8417)
4024                .missing()
4025                .is_empty()
4026            {
4027                checks.extend(choir_cli::doctor::host(&state, credential));
4028            }
4029            let style = choir_cli::style::Style::for_stdout();
4030            // Above the checks, because the first question is "what is
4031            // this machine" and every check below reads differently
4032            // depending on the answer: a missing `choir-node` matters to
4033            // an operator and is nothing to a contributor.
4034            let role = choir_cli::join::Role::of(&state_dir(), credential);
4035            print!("{}", choir_cli::doctor::heading(role, style));
4036            print!("{}", choir_cli::doctor::report(&checks, style));
4037            std::process::exit(choir_cli::doctor::exit_code(&checks));
4038        }
4039        // Exit 3 is its own outcome and not a failure: "the backup is
4040        // fine and something only you can supply is missing" is the
4041        // documented recovery path — stop, supply it, re-run — and
4042        // collapsing it into 1 would make that indistinguishable from a
4043        // corrupt backup.
4044        ["backup", "restore", src, root] => {
4045            let style = choir_cli::style::Style::for_stdout();
4046            let (src, root) = (std::path::Path::new(src), std::path::Path::new(root));
4047            if !src.is_dir() {
4048                eprintln!(
4049                    "{} no backup directory at {}",
4050                    style.red("choir backup restore:"),
4051                    src.display()
4052                );
4053                std::process::exit(2);
4054            }
4055            let daemon = match choir_cli::serve::find_daemon() {
4056                Ok(daemon) => daemon,
4057                Err(error) => {
4058                    eprintln!("{} {error}", style.red("choir backup restore:"));
4059                    std::process::exit(1);
4060                }
4061            };
4062            // The restored node's own credential, in its own root. Not
4063            // the caller's: a restore is building somebody else's node,
4064            // and the credential it will serve with lives beside the
4065            // log it will serve.
4066            let auth = auth
4067                .file
4068                .map(std::path::PathBuf::from)
4069                .unwrap_or_else(|| root.join(".choir/auth"));
4070            let mut say = |line: &str| eprintln!("  {} {line}", style.dim("restore:"));
4071            match choir_cli::restore::run(src, root, &daemon, &auth, &mut say) {
4072                Ok(done) => {
4073                    // On stdout, and not through `note`, which prints
4074                    // only to a terminal. This is the receipt: it is
4075                    // read once, on a bad day, and pasted into an
4076                    // incident log, so it has to survive a pipe.
4077                    println!(
4078                        "restore: {} ops replayed, {} repos unbundled, canary landed at seq {}",
4079                        done.ops, done.repos, done.ops
4080                    );
4081                    println!(
4082                        "restore: the canary ref is {} in {} — it is evidence, delete it when you no longer want it",
4083                        done.canary, done.landed_in
4084                    );
4085                    println!(
4086                        "restore: root is {} — start it under your supervisor:",
4087                        root.display()
4088                    );
4089                    println!("  choir node install --state {}", root.display());
4090                }
4091                Err(refusal) => {
4092                    let tag = if refusal.code == 3 {
4093                        style.red("choir backup restore: DECIDE")
4094                    } else {
4095                        style.red("choir backup restore:")
4096                    };
4097                    eprintln!("{tag} {}", refusal.message);
4098                    std::process::exit(refusal.code);
4099                }
4100            }
4101        }
4102        // A backup you can only verify by asking the thing it is a
4103        // backup of is not a backup, so nothing here opens a connection.
4104        ["backup", "verify", dir] => {
4105            let style = choir_cli::style::Style::for_stdout();
4106            let dir = std::path::Path::new(dir);
4107            if !dir.is_dir() {
4108                eprintln!(
4109                    "{} {} is not a directory",
4110                    style.red("choir backup verify:"),
4111                    dir.display()
4112                );
4113                std::process::exit(2);
4114            }
4115            let daemon = choir_cli::serve::find_daemon().ok();
4116            let checks = choir_cli::backup::verify(dir, daemon.as_deref());
4117            print!("{}", choir_cli::doctor::report(&checks, style));
4118            std::process::exit(i32::from(!choir_cli::backup::restorable(&checks)));
4119        }
4120        // The mode is required, never defaulted. A repair tool that
4121        // picks its own action is the one thing this must not be: the
4122        // difference between "tell me what is wrong" and "change my log"
4123        // is the operator's to make, and a default would make it by
4124        // habit.
4125        ["repair", log_file, rest @ ..] => repair(log_file, rest),
4126        ["workspace", api, repo, name, rest @ ..] => {
4127            let body = workspace_body(repo, name, rest);
4128            let (status, resp) = http(api, auth, "choir_workspace", body);
4129            finish(status, &resp);
4130        }
4131        ["checkpoint", api, key_file, channel, change_id, workspace, oid] => {
4132            let Some(revision) = choir_hash::ContentHash::from_git_oid(oid) else {
4133                eprintln!("<git-oid> must be a 40- or 64-char hex object id");
4134                std::process::exit(2);
4135            };
4136            let prev_revision = current_change_revision(api, auth, change_id);
4137            let op = ViewOp::new(OpKind::CheckpointChange {
4138                id: (*change_id).into(),
4139                workspace: (*workspace).into(),
4140                revision,
4141                prev_revision,
4142            });
4143            submit(api, key_file, channel, &op, auth);
4144        }
4145        ["workspace-archive", api, key_file, channel, repo, name, change_id, idempotency_key] => {
4146            let prev_revision = current_change_revision(api, auth, change_id);
4147            let authorization = ArchiveAuthorization::new(
4148                (*change_id).into(),
4149                format!("{repo}/{name}"),
4150                prev_revision,
4151            );
4152            let mut body = signed_payload_body(key_file, channel, &authorization.to_payload());
4153            body["repo"] = serde_json::json!(repo);
4154            body["name"] = serde_json::json!(name);
4155            body["change"] = serde_json::json!(change_id);
4156            body["idempotency_key"] = serde_json::json!(idempotency_key);
4157            let (status, resp) = http(api, auth, "choir_workspace_archive", body);
4158            finish(status, &resp);
4159        }
4160        // Deliberately not `<api>`-first like its neighbours: the git
4161        // remote already names the node, and repeating it is exactly the
4162        // friction this command exists to remove.
4163        ["propose", rest @ ..] => propose(rest, auth),
4164        // The link form first, and tested on the *path* rather than on
4165        // the argument count: `choir join <link> --user bea` has three
4166        // arguments too, and read as the positional form it would name
4167        // an invite file `--user`.
4168        ["join", link, rest @ ..] if auth.is_empty() && choir_cli::join::Link::looks_like(link) => {
4169            match choir_cli::join::Link::parse(link) {
4170                Ok(choir_cli::join::Link { api, id, secret }) => {
4171                    join(&api, Invite::Pair(id, secret), None, rest)
4172                }
4173                Err(why) => {
4174                    eprintln!("choir join: {why}");
4175                    std::process::exit(2);
4176                }
4177            }
4178        }
4179        ["join", api, invite_file, key_file, rest @ ..] if auth.is_empty() => {
4180            join(api, Invite::File(invite_file), Some(key_file), rest)
4181        }
4182        // Argument order is git's, not ours: it appends the operation
4183        // to whatever the configured helper line already carried.
4184        ["git-credential", auth_file, operation] if auth.is_empty() => {
4185            git_credential(auth_file, None, operation)
4186        }
4187        ["git-credential", auth_file, "--auth-user", user, operation] if auth.is_empty() => {
4188            git_credential(auth_file, Some(user), operation)
4189        }
4190        ["runner", config_file] => runner(config_file, auth),
4191        // The description a client generates against, and what this
4192        // node will accept. In the CLI so the shell library never needs
4193        // raw `curl` with a credential on its command line — a secret on
4194        // an argv is visible to every process through `ps`.
4195        ["schema", api] => {
4196            let (status, body) = http(api, auth, "choir_schema", serde_json::json!({}));
4197            finish(status, &body);
4198        }
4199        ["log", api, rest @ ..] => {
4200            let (mut from, mut verify, mut keys) = (0u64, false, None);
4201            let mut it = rest.iter();
4202            while let Some(arg) = it.next() {
4203                match *arg {
4204                    "--from" => {
4205                        let Some(value) = it.next().and_then(|v| v.parse().ok()) else {
4206                            usage()
4207                        };
4208                        from = value;
4209                    }
4210                    "--verify" => verify = true,
4211                    "--keys" => {
4212                        let Some(path) = it.next() else { usage() };
4213                        keys = Some(*path);
4214                    }
4215                    _ => usage(),
4216                }
4217            }
4218            log(api, from, verify, keys, auth);
4219        }
4220        ["batch", api, key_file, channel, ops_file] => {
4221            batch(api, key_file, channel, ops_file, auth);
4222        }
4223        ["submit", api, key_file, channel, op_json] => {
4224            // Round-trip through ViewOp so the signed bytes are exactly
4225            // what the daemon will decode.
4226            let op: ViewOp = match serde_json::from_str(op_json) {
4227                Ok(op) => op,
4228                Err(e) => {
4229                    eprintln!("bad op json: {e}");
4230                    std::process::exit(2);
4231                }
4232            };
4233            submit(api, key_file, channel, &op, auth);
4234        }
4235        // No reviewer names = ask the node to assign them (D24 layer 5;
4236        // needs the daemon started with --reviewers-file). `--ref` says
4237        // where the change wants to land, which is what per-ref policy
4238        // reads; omitting it leaves the review unbound.
4239        ["review", api, key_file, channel, id, oid, rest @ ..] => {
4240            let Some(target) = choir_hash::ContentHash::from_git_oid(oid) else {
4241                eprintln!("<git-oid> must be a 40- or 64-char hex object id");
4242                std::process::exit(2);
4243            };
4244            let mut target_ref = None;
4245            let mut reviewers = Vec::new();
4246            let mut it = rest.iter();
4247            while let Some(arg) = it.next() {
4248                if *arg == "--ref" {
4249                    let Some(name) = it.next() else { usage() };
4250                    target_ref = Some((*name).to_string());
4251                } else {
4252                    reviewers.push((*arg).to_string());
4253                }
4254            }
4255            let op = ViewOp::new(OpKind::RequestReview {
4256                id: (*id).into(),
4257                target,
4258                reviewers,
4259                target_ref,
4260            });
4261            submit(api, key_file, channel, &op, auth);
4262        }
4263        ["verdict", api, key_file, reviewer, id, verdict, rest @ ..] if rest.len() <= 1 => {
4264            let verdict = match *verdict {
4265                "approve" => Verdict::Approve,
4266                "request-changes" => Verdict::RequestChanges,
4267                _ => usage(),
4268            };
4269            let op = ViewOp::new(OpKind::PostVerdict {
4270                id: (*id).into(),
4271                reviewer: (*reviewer).into(),
4272                verdict,
4273                note: rest.first().copied().unwrap_or("").into(),
4274            });
4275            // The channel is the reviewer name: admission policy rejects
4276            // any verdict whose reviewer differs from the signed channel.
4277            submit(api, key_file, reviewer, &op, auth);
4278        }
4279        // D38. The comment id is caller-chosen and refused if the review
4280        // already holds it, so resubmitting a comment whose response was
4281        // lost is safe and never doubles it. Nothing here can be edited
4282        // or deleted afterwards: a correction is another comment.
4283        ["comment", api, key_file, channel, id, comment, body] => {
4284            let op = ViewOp::new(OpKind::PostComment {
4285                id: (*id).into(),
4286                comment: (*comment).into(),
4287                author: (*channel).into(),
4288                body: (*body).into(),
4289            });
4290            // The channel is the author: admission rejects any comment
4291            // whose author differs from the signed channel.
4292            submit(api, key_file, channel, &op, auth);
4293        }
4294        // A read receipt: lets the review's
4295        // author tell "reviewed and ignored" from "nobody looked yet".
4296        // First read only; resubmitting is refused, so a lost response
4297        // is safe to retry and a receipt never doubles.
4298        ["viewed", api, key_file, viewer, id] => {
4299            let op = ViewOp::new(OpKind::ViewedReview {
4300                id: (*id).into(),
4301                viewer: (*viewer).into(),
4302            });
4303            // The channel is the viewer: admission rejects any receipt
4304            // whose viewer differs from the signed channel.
4305            submit(api, key_file, viewer, &op, auth);
4306        }
4307        // D65. The voucher is not an argument: it is the operator half
4308        // of the channel being signed on, derived here so the two can
4309        // never be given different values. Admission checks the same
4310        // derivation, so a hand-rolled submission that disagrees is
4311        // refused rather than believed.
4312        ["witness", api, key_file, channel] => {
4313            let op = ViewOp::new(OpKind::CountersignSnapshot {
4314                witness: reviewer_operator(channel).into(),
4315                snapshot: latest_snapshot(api, auth),
4316            });
4317            submit(api, key_file, channel, &op, auth);
4318        }
4319        ["vouch", api, key_file, channel, subject, rest @ ..] if rest.len() <= 1 => {
4320            let op = ViewOp::new(OpKind::Vouch {
4321                voucher: reviewer_operator(channel).into(),
4322                subject: (*subject).into(),
4323                note: rest.first().copied().unwrap_or("").into(),
4324            });
4325            submit(api, key_file, channel, &op, auth);
4326        }
4327        ["unvouch", api, key_file, channel, subject, reason] => {
4328            let op = ViewOp::new(OpKind::WithdrawVouch {
4329                voucher: reviewer_operator(channel).into(),
4330                subject: (*subject).into(),
4331                reason: (*reason).into(),
4332            });
4333            submit(api, key_file, channel, &op, auth);
4334        }
4335        ["slash", api, node_key_file, id, reviewer, reason] => {
4336            require_node_key_file(node_key_file);
4337            let op = ViewOp::new(OpKind::SlashApproval {
4338                id: (*id).into(),
4339                reviewer: (*reviewer).into(),
4340                reason: (*reason).into(),
4341            });
4342            // This operator-only command uses the same signed-op endpoint
4343            // as every other mutation. Admission checks the key identity,
4344            // not this attribution string.
4345            submit(api, node_key_file, "node/slash", &op, auth);
4346        }
4347        // The operator's retention verb for a review that will never
4348        // finish: settle it as lapsed-unapproved. `lapsed` is hardwired
4349        // true because freezing a *complete* review is the retention
4350        // policy's job, and the fold refuses the other two shapes
4351        // anyway. Node-key-only at admission, same reasoning as slash:
4352        // archiving drops verdicts, so an unguarded verb would let an
4353        // agent erase a RequestChanges it did not like.
4354        ["abandon", api, node_key_file, id] => {
4355            require_node_key_file(node_key_file);
4356            let op = ViewOp::new(OpKind::ArchiveReview {
4357                id: (*id).into(),
4358                lapsed: true,
4359            });
4360            submit(api, node_key_file, "node/abandon", &op, auth);
4361        }
4362        // The operator's path to the durable identity record. Without
4363        // this, `BindKey` is node-only and the node has no CLI, so the
4364        // record stays empty and D24 T3 attribution — which reads it —
4365        // reports `indeterminate` with no way for an operator to fix it.
4366        //
4367        // `<key-hex>` is the *public key* hex that `choir key` prints and
4368        // the trusted-keys file already carries, not a content hash. The
4369        // actor id is derived here, the same way the node derives it, so
4370        // an operator never hand-computes a hash to bind a key.
4371        ["bind", api, node_key_file, operator, key_hex, rest @ ..] if rest.len() <= 1 => {
4372            require_node_key_file(node_key_file);
4373            let key = actor_id_from_hex(key_hex);
4374            let channel = rest.first().map(|c| (*c).to_string());
4375            // A re-bind that changes nothing is admissible -- the fold
4376            // allows re-binding so a channel typo can be corrected -- so
4377            // it costs a log entry and moves no state. That is a poor
4378            // reason to add a fold rule: refusing A->A inside `validate`
4379            // means distinguishing it from A->B in persisted semantics.
4380            // Catching it here keeps the record's rules unchanged.
4381            //
4382            // Deliberately narrow: only an exact match on operator and
4383            // channel, and only while unrevoked. Anything else is the
4384            // node's call, and the fold already refuses a cross-operator
4385            // move and a re-bind of a revoked key with `identity_state`.
4386            if let Some(existing) = current_binding(api, auth, &key.to_hex()) {
4387                if existing["operator"] == serde_json::json!(operator)
4388                    && existing["channel"] == serde_json::json!(channel)
4389                    && existing["revoked"].is_null()
4390                {
4391                    finish(
4392                        200,
4393                        &serde_json::json!({
4394                            "already_bound": true,
4395                            "operator": operator,
4396                            "channel": channel,
4397                            "bound_at": existing["bound_at"],
4398                        })
4399                        .to_string(),
4400                    );
4401                }
4402            }
4403            let op = ViewOp::new(OpKind::BindKey {
4404                operator: (*operator).into(),
4405                key,
4406                channel,
4407            });
4408            submit(api, node_key_file, "node/bind", &op, auth);
4409        }
4410        ["revoke", api, node_key_file, key_hex, reason] => {
4411            require_node_key_file(node_key_file);
4412            let op = ViewOp::new(OpKind::RevokeKey {
4413                key: actor_id_from_hex(key_hex),
4414                reason: (*reason).into(),
4415            });
4416            submit(api, node_key_file, "node/revoke", &op, auth);
4417        }
4418        ["appeal", api, attempt_id] => {
4419            let attempt_id = attempt_id.parse::<u64>().unwrap_or_else(|_| usage());
4420            let (status, resp) = http(
4421                api,
4422                auth,
4423                "choir_appeal",
4424                serde_json::json!({ "attempt_id": attempt_id }),
4425            );
4426            finish(status, &resp);
4427        }
4428        ["intent", api, key_file, channel, subject, kind, body] => {
4429            // D22 provenance record: task spec / plan / rationale for
4430            // `subject`; latest per (subject, kind) wins in the view.
4431            let op = ViewOp::new(OpKind::RecordProvenance {
4432                subject: (*subject).into(),
4433                kind: (*kind).into(),
4434                body: (*body).into(),
4435            });
4436            submit(api, key_file, channel, &op, auth);
4437        }
4438        // The write half of D49. `channel` is the reporter: admission
4439        // rejects a report whose reporter differs from the signed
4440        // channel, the same binding `viewed` relies on.
4441        ["check", api, key_file, channel, oid, name, status, rest @ ..] if rest.len() <= 3 => {
4442            let Some(subject) = choir_hash::ContentHash::from_git_oid(oid) else {
4443                eprintln!("<git-oid> must be a 40- or 64-char hex object id");
4444                std::process::exit(2);
4445            };
4446            let Some(status) = CheckStatus::parse(status) else {
4447                eprintln!("<status> must be passed, failed or running");
4448                std::process::exit(2);
4449            };
4450            let mut evidence = String::new();
4451            let mut target_ref = None;
4452            let mut it = rest.iter();
4453            while let Some(arg) = it.next() {
4454                if *arg == "--ref" {
4455                    let Some(name) = it.next() else { usage() };
4456                    target_ref = Some((*name).to_string());
4457                } else {
4458                    evidence = (*arg).to_string();
4459                }
4460            }
4461            let op = ViewOp::new(OpKind::RecordCheck {
4462                subject,
4463                name: (*name).into(),
4464                status,
4465                evidence,
4466                reporter: (*channel).into(),
4467                target_ref,
4468            });
4469            submit(api, key_file, channel, &op, auth);
4470        }
4471        // The read half, and the one command in this binary that exits 3.
4472        // See `check_exit`.
4473        ["checks", api, oid] => {
4474            let Some(subject) = choir_hash::ContentHash::from_git_oid(oid) else {
4475                eprintln!("<git-oid> must be a 40- or 64-char hex object id");
4476                std::process::exit(2);
4477            };
4478            let (status, resp) = http(api, auth, "choir_view", serde_json::json!({}));
4479            if !(200..300).contains(&status) {
4480                finish(status, &resp);
4481            }
4482            check_exit(&subject, &resp);
4483        }
4484        ["profile", api, channel] => {
4485            let (status, resp) = http(
4486                api,
4487                auth,
4488                "choir_profile",
4489                serde_json::json!({ "channel": channel }),
4490            );
4491            finish(status, &resp);
4492        }
4493        ["search", api, term, rest @ ..] => {
4494            // The flags are optional and the node validates every one of
4495            // them, so they are forwarded rather than re-checked here: a
4496            // second copy of "in must be one of files, code, commits"
4497            // is a second copy that can drift from the first.
4498            let mut arguments = serde_json::Map::new();
4499            arguments.insert("q".into(), serde_json::json!(term));
4500            let mut it = rest.iter();
4501            while let Some(arg) = it.next() {
4502                let field = match *arg {
4503                    "--in" => "in",
4504                    "--repo" => "repo",
4505                    "--rev" => "rev",
4506                    "--limit" => "limit",
4507                    _ => usage(),
4508                };
4509                let Some(value) = it.next() else { usage() };
4510                // `limit` is a number in the schema and a string on the
4511                // command line. Sent as a string it would fail schema
4512                // validation before it ever reached the node, which
4513                // would report a type error about an argument the caller
4514                // spelled correctly.
4515                let value = match field {
4516                    "limit" => match value.parse::<u64>() {
4517                        Ok(n) => serde_json::json!(n),
4518                        Err(_) => usage(),
4519                    },
4520                    _ => serde_json::json!(value),
4521                };
4522                arguments.insert(field.to_string(), value);
4523            }
4524            let (status, resp) = http(api, auth, "choir_search", arguments.into());
4525            finish(status, &resp);
4526        }
4527        ["reviews", api, reviewer] => {
4528            let (status, resp) = http(
4529                api,
4530                auth,
4531                "choir_reviews",
4532                serde_json::json!({ "reviewer": reviewer }),
4533            );
4534            finish(status, &resp);
4535        }
4536        ["view", api, rest @ ..] => {
4537            // The view is bounded by default, so the CLI has to be able
4538            // to reach page two: a command that could only ever print the
4539            // first 200 rows of each section would hide the rest behind a
4540            // `paging.next` it gave the caller no way to follow.
4541            let mut arguments = serde_json::Map::new();
4542            let mut it = rest.iter();
4543            while let Some(arg) = it.next() {
4544                let field = match *arg {
4545                    "--limit" => "limit",
4546                    "--offset" => "offset",
4547                    _ => usage(),
4548                };
4549                let Some(value) = it.next().and_then(|v| v.parse::<u64>().ok()) else {
4550                    usage()
4551                };
4552                arguments.insert(field.to_string(), serde_json::json!(value));
4553            }
4554            let (status, resp) = http(api, auth, "choir_view", arguments.into());
4555            finish(status, &resp);
4556        }
4557        ["docs", rest @ ..] => {
4558            let open = match rest {
4559                [] => false,
4560                ["--open"] => true,
4561                _ => usage(),
4562            };
4563            let cwd = std::env::current_dir().unwrap_or_else(|e| {
4564                eprintln!("choir: cannot read the working directory: {e}");
4565                std::process::exit(1);
4566            });
4567            let Some(root) = choir_cli::docs::find_root(&cwd) else {
4568                eprintln!("choir: {}", choir_cli::docs::Failure::NotACheckout);
4569                std::process::exit(1);
4570            };
4571            let built = match choir_cli::docs::build(&root) {
4572                Ok(built) => built,
4573                Err(failure) => {
4574                    eprintln!("choir: {failure}");
4575                    std::process::exit(1);
4576                }
4577            };
4578            let opened = open && choir_cli::docs::open(&built.book.join("index.html"));
4579            note(
4580                "documentation built",
4581                &[
4582                    ("book", built.book.join("index.html").display().to_string()),
4583                    ("api", built.api.join("index.html").display().to_string()),
4584                    ("crates", built.crates.len().to_string()),
4585                ],
4586            );
4587            let doc = serde_json::json!({
4588                "root": built.root.display().to_string(),
4589                "book": built.book.display().to_string(),
4590                "api": built.api.display().to_string(),
4591                "crates": built.crates,
4592                "opened": opened,
4593            });
4594            finish(200, &doc.to_string());
4595        }
4596        ["skill", "install", rest @ ..] => {
4597            let into = match rest {
4598                [] => ".claude/skills",
4599                ["--into", dir] => dir,
4600                _ => usage(),
4601            };
4602            let dir = std::path::Path::new(into).join(choir_cli::surface::SKILL_DIR);
4603            let path = dir.join("SKILL.md");
4604            let rendered = choir_cli::surface::skill_md();
4605            // Byte-compare before writing: a re-install after `cargo
4606            // install` refreshes a stale skill and leaves a current one
4607            // untouched, so repeated installs produce no churn.
4608            let wrote = std::fs::read_to_string(&path).ok().as_deref() != Some(rendered.as_str());
4609            if wrote {
4610                if let Err(error) = choir_fs::write_atomic(&path, &rendered) {
4611                    eprintln!("choir: cannot write {}: {error}", path.display());
4612                    std::process::exit(1);
4613                }
4614            }
4615            let doc = serde_json::json!({ "path": path.display().to_string(), "wrote": wrote });
4616            note(
4617                if wrote {
4618                    "skill installed"
4619                } else {
4620                    "skill already current"
4621                },
4622                &[("path", path.display().to_string())],
4623            );
4624            finish(200, &doc.to_string());
4625        }
4626        ["invite", api, name, repo] => invite(api, auth, name, repo, "write"),
4627        ["invite", api, name, repo, level] => invite(api, auth, name, repo, level),
4628        ["asks", api] => asks(api, auth),
4629        ["grant", api, id, repo] => grant(api, auth, id, repo, "write"),
4630        ["grant", api, id, repo, level] => grant(api, auth, id, repo, level),
4631        ["decline", api, id] => decline(api, auth, id),
4632        ["acl", "render", api, acl_file] => acl_render(api, auth, acl_file),
4633        ["funnel", api] => {
4634            println!("{}", derived_view(api, auth, choir_cli::triage::funnel));
4635        }
4636        ["triage", api] => {
4637            let doc = derived_view(api, auth, choir_cli::triage::triage);
4638            finish(200, &doc);
4639        }
4640        ["state", api, channel] => {
4641            let doc = derived_view(api, auth, |view| {
4642                choir_cli::triage::next_actions(view, api, channel)
4643            });
4644            finish(200, &doc);
4645        }
4646        _ => usage(),
4647    }
4648}