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