Skip to main content

choir_cli/
surface.rs

1//! The agent-facing surface, as data, and the generators that render it.
2//!
3//! `choir --help`, `docs/using/cli.md`, the README's cheat-sheet, the
4//! three `templates/` snippets, the root `AGENTS.md` and the node's
5//! `llms.txt` all describe one surface. Hand-maintained, they drift —
6//! and they had already started to: the API table carried a throughput
7//! figure that three later measurements had superseded.
8//!
9//! So the surface is described once, here, and everything else is
10//! rendered from it. [`crate::surface`] is the source; a staleness test
11//! re-renders and compares, so a committed artifact cannot silently fall
12//! behind the table.
13//!
14//! # What is generated and what is not
15//!
16//! Only the *signatures* — command names, arguments, endpoints,
17//! purposes. The conventions around them stay hand-written, because
18//! `templates/` is a product deliverable whose value is judgement
19//! ("name no reviewers; the node draws them") rather than syntax, and
20//! generating prose would flatten exactly the part worth shipping. In
21//! the templates the generated region is bounded by
22//! [`GEN_START`]/[`GEN_END`] markers and the prose lives outside them.
23//!
24//! No dependency is added for any of this: rendering markdown from a
25//! const table is a few `format!` calls.
26
27/// Opening marker of a generated region in an otherwise authored file.
28pub const GEN_START: &str = "<!-- generated: choir surface, do not edit -->";
29/// Closing marker of a generated region.
30pub const GEN_END: &str = "<!-- /generated -->";
31
32/// The same pair for a shell file, where an HTML comment is a syntax
33/// error rather than a comment.
34///
35/// Learned by running `sh -n` on the first generated copy: the markers
36/// spliced in cleanly, the file was well-formed markdown, and `sh`
37/// refused it at line 120. A generated artifact nobody executes is one
38/// nobody notices is broken.
39pub const SH_GEN_START: &str = "# --- generated: choir surface, do not edit ---";
40/// Closing marker of a generated region in a shell file.
41pub const SH_GEN_END: &str = "# --- /generated ---";
42
43/// Explicit global options for authenticated node access.
44pub const AUTH_OPTIONS: &str = "[--auth-file <path>] [--auth-user <name>]";
45
46/// Version of the machine-readable API description (D17).
47///
48/// Bumped only for a change an existing client cannot ignore: an
49/// endpoint removed, a required field added, a field's meaning changed.
50/// Adding an endpoint or an optional field is additive and keeps the
51/// version, which is the same evolution rule every persisted struct in
52/// this workspace follows (invariant 1).
53///
54/// The row this implements is **one-way-leaning**: third parties build
55/// against this surface, so the fallback is a *versioned* API plus a
56/// deprecation policy rather than a way back. That is why the version is
57/// in the document from its first byte, before anyone depends on it.
58pub const API_VERSION: u32 = 1;
59
60use crate::style::Style;
61
62/// Wire names this API still accepts and no longer documents, with what
63/// replaced them.
64///
65/// Stated in the schema rather than left to prose, because a client
66/// generated from the schema is exactly the reader who would otherwise
67/// build on an alias without knowing it is one.
68pub const DEPRECATIONS: &[(&str, &str, &str)] = &[(
69    "workspace",
70    "channel",
71    "v1 alias for the signature-covered attribution channel; accepted on \
72     submission for compatibility, and refused when it disagrees with \
73     `channel` rather than one silently winning",
74)];
75
76/// One `choir` subcommand.
77pub struct Command {
78    /// Subcommand name.
79    pub name: &'static str,
80    /// Argument spec as shown in help, e.g. `<api> <key-file>`.
81    pub args: &'static str,
82    /// One line, imperative, no trailing period.
83    pub summary: &'static str,
84    /// Whether an agent is expected to reach for this routinely.
85    pub agent_facing: bool,
86    /// Which section of `--help` this belongs under.
87    ///
88    /// Help used to be one flat list of every command in the order they
89    /// were added, which is the order they were *written* rather than any
90    /// order they are read in. Thirty-two lines like that is a wall, and
91    /// the reader's actual question — "what do I run to get a change
92    /// reviewed" — was answered nowhere on the page.
93    ///
94    /// A field rather than a lookup table beside the list, so a new
95    /// command cannot be added without deciding where it belongs; the
96    /// compiler asks.
97    pub group: &'static str,
98}
99
100/// The help sections, in reading order: what you do first, then the loop
101/// you live in, then the things you reach for when something is wrong.
102pub const GROUPS: &[&str] = &[
103    "getting started",
104    "changing code",
105    "review",
106    "checks",
107    "trust",
108    "reading the node",
109    "operating a node",
110];
111
112/// One HTTP endpoint on the node.
113pub struct Endpoint {
114    /// HTTP method.
115    pub method: &'static str,
116    /// Path, including any query parameter that is part of the contract.
117    pub path: &'static str,
118    /// What it is for, one line.
119    pub purpose: &'static str,
120    /// MCP tool metadata when this endpoint is safe for agents to call.
121    /// Internal hook endpoints deliberately carry `None`.
122    pub mcp: Option<McpTool>,
123}
124
125/// How an MCP tool's arguments become one HTTP request.
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub enum McpArguments {
128    /// The endpoint takes no arguments or request body.
129    Empty,
130    /// The argument object is forwarded as the JSON request body.
131    Body,
132    /// The named arguments are URL-encoded as query parameters.
133    ///
134    /// A slice rather than one name because the bounded reads take
135    /// `limit` and `offset` alongside whatever they already took, and a
136    /// generator that could only carry one would have silently dropped
137    /// the paging contract out of every generated client.
138    Query {
139        /// Names of the arguments, which are also the parameter names.
140        parameters: &'static [&'static str],
141    },
142}
143
144/// The MCP-specific part of one HTTP endpoint.
145pub struct McpTool {
146    /// Stable programmatic tool name.
147    pub name: &'static str,
148    /// JSON Schema for the tool's argument object.
149    pub input_schema: &'static str,
150    /// How those arguments map onto the endpoint.
151    pub arguments: McpArguments,
152}
153
154const EMPTY_MCP_SCHEMA: &str = r#"{"type":"object","additionalProperties":false}"#;
155
156const SUBMISSION_MCP_SCHEMA: &str = r#"{
157  "type": "object",
158  "properties": {
159    "channel": { "type": "string", "description": "Signature-covered attribution channel" },
160    "workspace": { "type": "string", "description": "Deprecated v1 alias for channel" },
161    "payload_hex": { "type": "string", "description": "Hex-encoded ViewOp payload bytes" },
162    "key_id": { "type": "string", "description": "Actor key id" },
163    "signature_hex": { "type": "string", "description": "Hex-encoded submission signature" }
164  },
165  "required": ["payload_hex", "key_id", "signature_hex"],
166  "anyOf": [
167    { "required": ["channel"] },
168    { "required": ["workspace"] }
169  ],
170  "additionalProperties": false
171}"#;
172
173const BATCH_MCP_SCHEMA: &str = r#"{
174  "type": "object",
175  "properties": {
176    "ops": {
177      "type": "array",
178      "description": "Signed operations, admitted in array order",
179      "items": {
180        "type": "object",
181        "properties": {
182          "channel": { "type": "string", "description": "Signature-covered attribution channel" },
183          "workspace": { "type": "string", "description": "Deprecated v1 alias for channel" },
184          "payload_hex": { "type": "string" },
185          "key_id": { "type": "string" },
186          "signature_hex": { "type": "string" }
187        },
188        "required": ["payload_hex", "key_id", "signature_hex"],
189        "anyOf": [
190          { "required": ["channel"] },
191          { "required": ["workspace"] }
192        ],
193        "additionalProperties": false
194      }
195    }
196  },
197  "required": ["ops"],
198  "additionalProperties": false
199}"#;
200
201const WORKSPACE_MCP_SCHEMA: &str = r#"{
202  "type": "object",
203  "properties": {
204    "repo": { "type": "string", "description": "Repository name as owner/repo" },
205    "name": { "type": "string", "description": "Workspace name" },
206    "base": { "type": "string", "description": "Exact full Git commit oid; advanced requests provide this with owner, change and idempotency_key" },
207    "owner": { "type": "string", "description": "Registered signing channel allowed to checkpoint and archive the stable change" },
208    "change": { "type": "string", "description": "Stable logical change id" },
209    "idempotency_key": { "type": "string", "description": "Owner-scoped create retry identity" },
210    "channel": { "type": "string", "description": "Owner and signature-covered attribution channel" },
211    "payload_hex": { "type": "string", "description": "Hex-encoded CreateAuthorization for the exact binding" },
212    "key_id": { "type": "string", "description": "Owner key id" },
213    "signature_hex": { "type": "string", "description": "Owner signature over the create authorization" }
214  },
215  "required": ["repo", "name"],
216  "oneOf": [
217    {
218      "not": {
219        "anyOf": [
220          { "required": ["base"] }, { "required": ["owner"] },
221          { "required": ["change"] }, { "required": ["idempotency_key"] },
222          { "required": ["channel"] }, { "required": ["payload_hex"] },
223          { "required": ["key_id"] }, { "required": ["signature_hex"] }
224        ]
225      }
226    },
227    {
228      "required": ["base", "owner", "change", "idempotency_key", "channel", "payload_hex", "key_id", "signature_hex"]
229    }
230  ],
231  "additionalProperties": false
232}"#;
233
234const WORKSPACE_ARCHIVE_MCP_SCHEMA: &str = r#"{
235  "type": "object",
236  "properties": {
237    "repo": { "type": "string", "description": "Repository name as owner/repo" },
238    "name": { "type": "string", "description": "Workspace name" },
239    "change": { "type": "string", "description": "Stable logical change id returned by creation" },
240    "idempotency_key": { "type": "string", "description": "Bound create retry identity" },
241    "channel": { "type": "string", "description": "Bound change owner and signature-covered attribution channel" },
242    "payload_hex": { "type": "string", "description": "Hex-encoded ArchiveAuthorization naming this exact change, workspace and revision" },
243    "key_id": { "type": "string", "description": "Owner key id" },
244    "signature_hex": { "type": "string", "description": "Hex-encoded owner submission signature" }
245  },
246  "required": ["repo", "name", "change", "idempotency_key", "channel", "payload_hex", "key_id", "signature_hex"],
247  "additionalProperties": false
248}"#;
249
250const LOG_MCP_SCHEMA: &str = r#"{
251  "type": "object",
252  "properties": {
253    "from": { "type": "integer", "minimum": 0, "description": "Absolute sequence cursor" }
254  },
255  "required": ["from"],
256  "additionalProperties": false
257}"#;
258
259/// The two parameters every bounded read takes.
260///
261/// Neither is required, and that is the contract: a caller who names
262/// nothing is still served a bounded page. There is no value of `limit`
263/// that turns the budget off — the node clamps to `1..=1000` — so paging
264/// is the way to read a large view, not a fallback for one.
265const PAGING_MCP_SCHEMA: &str = r#"{
266  "type": "object",
267  "properties": {
268    "limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Rows per section; defaults to 200 and clamps to this range" },
269    "offset": { "type": "integer", "minimum": 0, "description": "Rows skipped per section, in key order" }
270  },
271  "additionalProperties": false
272}"#;
273
274const REVIEWS_MCP_SCHEMA: &str = r#"{
275  "type": "object",
276  "properties": {
277    "reviewer": { "type": "string", "description": "Reviewer channel name" },
278    "limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Rows per section; defaults to 200 and clamps to this range" },
279    "offset": { "type": "integer", "minimum": 0, "description": "Rows skipped per section, in key order" }
280  },
281  "required": ["reviewer"],
282  "additionalProperties": false
283}"#;
284
285const PROFILE_MCP_SCHEMA: &str = r#"{
286  "type": "object",
287  "properties": {
288    "channel": { "type": "string", "description": "The name an actor signs as" }
289  },
290  "required": ["channel"],
291  "additionalProperties": false
292}"#;
293
294const SEARCH_MCP_SCHEMA: &str = r#"{
295  "type": "object",
296  "properties": {
297    "q": { "type": "string", "description": "Literal term, not a pattern; matching is case-insensitive" },
298    "in": { "type": "string", "enum": ["files", "code", "commits"], "description": "What to look through; defaults to code" },
299    "repo": { "type": "string", "description": "One repository as owner/name; omit to search every repository you may read" },
300    "rev": { "type": "string", "description": "Revision to search, for a single repo= only; defaults to HEAD" },
301    "limit": { "type": "integer", "minimum": 1, "maximum": 200, "description": "Matches returned; defaults to 200. `matches` counts everything found either way" }
302  },
303  "required": ["q"],
304  "additionalProperties": false
305}"#;
306
307const APPEAL_MCP_SCHEMA: &str = r#"{
308  "type": "object",
309  "properties": {
310    "attempt_id": { "type": "integer", "minimum": 0, "description": "Newcomer attempt id returned with the rejection" }
311  },
312  "required": ["attempt_id"],
313  "additionalProperties": false
314}"#;
315
316/// Every `choir` subcommand, in help order.
317pub const COMMANDS: &[Command] = &[
318    Command {
319        name: "host",
320        args: "[--domain <name> | --public [--ip <addr>] | --public-name <name>] \
321               [--port <n>] [--repo <owner/name.git>] [--invite <name>] [--state <dir>] \
322               [--yes] [--dry-run] [--foreground] [-- <daemon flags>]",
323        summary: "take this machine from nothing to a running node and print its URL; bare binds loopback, --domain issues a Let's Encrypt certificate for a name you own, --public uses a magic-DNS name over this box's address, --foreground execs the daemon instead of installing a unit",
324        agent_facing: false,
325        group: "getting started",
326    },
327    Command {
328        name: "init",
329        args: "[<state-dir>] [--port <n>] [--force]",
330        summary: "set up a node's layout on this machine: repository root, credential at 0600, an actor key the node trusts, and .choir/config; refuses to overwrite what exists",
331        agent_facing: false,
332        group: "getting started",
333    },
334    Command {
335        name: "key",
336        args: "<key-file> [name]",
337        summary: "mint a key and print the line the operator registers; pass your channel name to print the bound form",
338        agent_facing: true,
339        group: "getting started",
340    },
341    Command {
342        name: "git-credential",
343        args: "<auth-file> [--auth-user <name>] get|store|erase",
344        summary: "git credential helper: hands git your token on stdin so it never lives in a remote URL; configure with `git config credential.helper '!choir git-credential <auth-file>'`",
345        agent_facing: false,
346        group: "getting started",
347    },
348    Command {
349        name: "join",
350        args: "<link> | <api> <invite-file> <key-file>  [--user <name>] [--channel <name>] [--key-file <path>] [--ssh-key <path>] [--token-file <path>] [--no-clone]",
351        summary: "redeem an invite link and set this machine up: actor key at ~/.choir/agent.key, token at ~/.choir/auth (0600), a git credential helper for that node, the node URL in ~/.choir/config, and a clone of each repository the invite names in the current directory (--no-clone skips it); the same link run again on this machine clones only what is missing; --user names the account when the invite left it open, asked on the terminal otherwise; the three-argument form takes the invite from a file, answers JSON and touches neither git nor your home directory",
352        agent_facing: true,
353        group: "getting started",
354    },
355    Command {
356        name: "invite",
357        args: "<api> <name> <owner/repo> [read|write]",
358        summary: "mint an invite and print the one link to send; the same thing the /people page does",
359        agent_facing: false,
360        group: "operating a node",
361    },
362    Command {
363        name: "asks",
364        args: "<api>",
365        summary: "who has asked for access and is waiting on an answer (D72)",
366        agent_facing: false,
367        group: "operating a node",
368    },
369    Command {
370        name: "grant",
371        args: "<api> <request-id> <owner/repo> [read|write]",
372        summary: "let one of them in; the link they already hold becomes their invite",
373        agent_facing: false,
374        group: "operating a node",
375    },
376    Command {
377        name: "decline",
378        args: "<api> <request-id>",
379        summary: "drop a pending request; their link then reads as never valid",
380        agent_facing: false,
381        group: "operating a node",
382    },
383    Command {
384        name: "workspace",
385        args: "<api> <owner/repo> <name> [--base <git-oid> --owner <channel> --key-file <path> --change <id> --idempotency-key <key>] [--path <prefix>]...",
386        summary: "provision a CoW workspace; advanced flags owner-sign an exact base and stable change, and each --path owner-signs a subtree",
387        agent_facing: true,
388        group: "changing code",
389    },
390    Command {
391        name: "checkpoint",
392        args: "<api> <key-file> <channel> <change-id> <workspace-id> <git-oid>",
393        summary: "publish an immutable change revision after committing and pushing its git object",
394        agent_facing: true,
395        group: "changing code",
396    },
397    Command {
398        name: "propose",
399        args: "[reviewer]... [--key-file <path>] [--channel <name>] [--api <url>] [--repo <owner/repo>] [--remote <name>] [--onto <branch>] [--change <id>] [--path <prefix>]...",
400        summary: "create a change, push its commits and request review, with no arguments; run from a git checkout, with the key and channel from ~/.choir, every value overridable by flag; re-running after an amend updates the same proposal; a leading `<key-file> <channel>` pair is still accepted",
401        agent_facing: true,
402        group: "changing code",
403    },
404    Command {
405        name: "workspace-archive",
406        args: "<api> <key-file> <channel> <owner/repo> <name> <change-id> <idempotency-key>",
407        summary: "owner-sign and recoverably archive a bound workspace; exact retries are idempotent",
408        agent_facing: true,
409        group: "changing code",
410    },
411    Command {
412        name: "runner",
413        args: "<config-file>",
414        summary: "drive one workspace lifecycle step for an orchestrator; JSON request on stdin, JSON result on stdout",
415        agent_facing: false,
416        group: "operating a node",
417    },
418    Command {
419        name: "submit",
420        args: "<api> <key-file> <channel> '<op-json>'",
421        summary: "sign and submit one raw operation",
422        agent_facing: false,
423        group: "changing code",
424    },
425    Command {
426        name: "schema",
427        args: "<api>",
428        summary: "print this node's machine-readable API description and its live capabilities",
429        agent_facing: true,
430        group: "reading the node",
431    },
432    Command {
433        name: "log",
434        args: "<api> [--from <n>] [--verify] [--keys <file>]",
435        summary: "read log entries from a cursor; --verify checks continuity, recomputes every hash and verifies the signatures whose keys you hold",
436        agent_facing: true,
437        group: "reading the node",
438    },
439    Command {
440        name: "batch",
441        args: "<api> <key-file> <channel> <ops-file>",
442        summary: "sign and submit many operations as one batch, the primary path for agent workloads; one op per line, `-` reads stdin, one result line per op",
443        agent_facing: true,
444        group: "changing code",
445    },
446    Command {
447        name: "review",
448        args: "<api> <key-file> <channel> <id> <git-oid> [--ref <repo:ref>] [reviewer]...",
449        summary: "request review on a commit; name no reviewers and the node draws them",
450        agent_facing: true,
451        group: "review",
452    },
453    Command {
454        name: "verdict",
455        args: "<api> <key-file> <reviewer> <id> approve|request-changes [note]",
456        summary: "answer a review you were assigned",
457        agent_facing: true,
458        group: "review",
459    },
460    Command {
461        name: "comment",
462        args: "<api> <key-file> <channel> <review-id> <comment-id> '<body>'",
463        summary: "say something on a review; append-only, and the comment id is your retry identity",
464        agent_facing: true,
465        group: "review",
466    },
467    Command {
468        name: "viewed",
469        args: "<api> <key-file> <viewer> <review-id>",
470        summary: "record that you read a review; first read only, resubmitting is refused",
471        agent_facing: true,
472        group: "review",
473    },
474    Command {
475        name: "witness",
476        args: "<api> <key-file> <channel>",
477        summary: "cosign the node's current ref-state attestation (D67); the snapshot id is read from the view, and the node may not witness its own",
478        agent_facing: true,
479        group: "trust",
480    },
481    Command {
482        name: "vouch",
483        args: "<api> <key-file> <channel> <subject> [note]",
484        summary: "vouch for another operator; both ends need a key bound in the log, and it authorizes nothing on its own",
485        agent_facing: true,
486        group: "trust",
487    },
488    Command {
489        name: "unvouch",
490        args: "<api> <key-file> <channel> <subject> '<reason>'",
491        summary: "withdraw a vouch; both ops stay in the log, and vouching again starts a fresh clock",
492        agent_facing: true,
493        group: "trust",
494    },
495    Command {
496        name: "slash",
497        args: "<api> <node-key-file> <id> <reviewer> '<reason>'",
498        summary: "invalidate one reviewer's approval; operator-only and never moves a ref",
499        agent_facing: false,
500        group: "review",
501    },
502    Command {
503        name: "abandon",
504        args: "<api> <node-key-file> <id>",
505        summary: "archive a stale incomplete review as lapsed, settling it unapproved; operator-only and never moves a ref",
506        agent_facing: false,
507        group: "review",
508    },
509    Command {
510        name: "bind",
511        args: "<api> <node-key-file> <operator> <key-hex> [channel]",
512        summary: "record in the log that a key belongs to an operator; operator-only and never moves a ref",
513        agent_facing: false,
514        group: "operating a node",
515    },
516    Command {
517        name: "revoke",
518        args: "<api> <node-key-file> <key-hex> '<reason>'",
519        summary: "withdraw a key binding; terminal, and the attribution row survives",
520        agent_facing: false,
521        group: "operating a node",
522    },
523    Command {
524        name: "appeal",
525        args: "<api> <attempt-id>",
526        summary: "appeal a rejected newcomer attempt for operator adjudication; never grants privilege",
527        agent_facing: true,
528        group: "reading the node",
529    },
530    Command {
531        name: "intent",
532        args: "<api> <key-file> <channel> <subject> <kind> '<body>'",
533        summary: "publish a task spec or plan so other agents can see intent",
534        agent_facing: true,
535        group: "changing code",
536    },
537    Command {
538        name: "check",
539        args: "<api> <key-file> <channel> <git-oid> <name> passed|failed|running|errored [evidence] [--ref <repo:ref>]",
540        summary: "report one automated check's outcome on a commit; any runner or person can report by signing, and the node never runs the check",
541        agent_facing: true,
542        group: "checks",
543    },
544    Command {
545        name: "checks",
546        args: "<api> <git-oid>",
547        summary: "every check reported on a commit, and one verdict; exits 0 passed, 1 failed or unreported, 3 still running, 4 could not be run",
548        agent_facing: true,
549        group: "checks",
550    },
551    Command {
552        name: "profile",
553        args: "<api> <channel>",
554        summary: "what the log records about one actor: keys and their age, changes owned, verdicts given, checks reported",
555        agent_facing: true,
556        group: "reading the node",
557    },
558    Command {
559        name: "search",
560        args: "<api> <term> [--in files|code|commits] [--repo owner/name] [--rev R] [--limit N]",
561        summary: "find a literal term across every repository you may read",
562        agent_facing: true,
563        group: "reading the node",
564    },
565    Command {
566        name: "reviews",
567        args: "<api> <reviewer>",
568        summary: "your pending review queue",
569        agent_facing: true,
570        group: "review",
571    },
572    Command {
573        name: "acl render",
574        args: "<api> <acl-file>",
575        summary: "rewrite an ACL file's trailing comments to name the person behind each handle; grants are copied through unchanged",
576        agent_facing: false,
577        group: "operating a node",
578    },
579    Command {
580        name: "triage",
581        args: "<api>",
582        summary: "every review and change in a bucket (landed, awaiting verdicts, changes requested, approved awaiting landing), most actionable first, capped, with truncation marked in-band",
583        agent_facing: true,
584        group: "reading the node",
585    },
586    Command {
587        name: "funnel",
588        args: "<api>",
589        summary: "the contribution funnel from admission to first verdict and the steepest drop between stages; counts what this credential may read, and reports an unmeasured stage as null",
590        agent_facing: false,
591        group: "reading the node",
592    },
593    Command {
594        name: "state",
595        args: "<api> <channel>",
596        summary: "list what you owe and what you are waiting on; every row carries the command that answers it and its risk",
597        agent_facing: true,
598        group: "changing code",
599    },
600    Command {
601        name: "docs",
602        args: "[--open]",
603        summary: "build the book from `docs/` with the API documentation inside it at `book/api/`; needs a checkout and `mdbook`, and names the install command if it is missing",
604        // A contributor's command, not an agent's: it builds a local
605        // tree from a checkout and touches no node. An agent that wants
606        // this surface reads `/llms.txt` from a running one.
607        agent_facing: false,
608        group: "getting started",
609    },
610    Command {
611        name: "skill",
612        args: "install [--into <dir>]",
613        summary: "install the choir agent skill (default .claude/skills), rendered from this binary's own surface table; re-run after upgrading",
614        agent_facing: true,
615        group: "getting started",
616    },
617    Command {
618        name: "view",
619        args: "<api> [--limit <n>] [--offset <n>]",
620        // The endpoint row in `docs/using/cli.md` enumerates every
621        // section this returns and is the reference for them. This line
622        // named all eight, which made it the longest summary in the
623        // table by a factor of four and a second copy of that list.
624        summary: "read the materialized view, its ref-state attestation and the node's health counters; map-shaped sections page 200 rows at a time, with `<section>_omitted` and `paging.next`",
625        agent_facing: true,
626        group: "reading the node",
627    },
628    Command {
629        name: "repo create",
630        args: "<api> <owner/repo.git>",
631        summary: "create a repository on a running node, sequenced from its first push; needs a node-wide write grant, answers 409 when it already exists, and prints the clone URL",
632        agent_facing: false,
633        group: "operating a node",
634    },
635    Command {
636        name: "repo list",
637        args: "<api>",
638        summary: "the repositories on a node this credential can read, one per line; an ACL narrows the list rather than refusing it",
639        agent_facing: false,
640        group: "operating a node",
641    },
642    Command {
643        name: "repo url",
644        args: "<api> <owner/repo.git>",
645        summary: "the clone URL for a repository, and the one line of git configuration that makes pushing work; the credential is never put in the URL",
646        agent_facing: false,
647        group: "operating a node",
648    },
649    Command {
650        name: "seed",
651        args: "<home-url> [--credential <file>] [--name <principal>] [--port <n> | --archival] \
652               [--state <dir>] [--foreground] [-- <daemon flags>]",
653        summary: "take this machine from nothing to a running seed of another node's log (D80): mints the seed's node key and prints the three lines the home registers, then, run again with the credential the home issued, starts and supervises the seed; --archival replicates and stores without serving",
654        agent_facing: false,
655        group: "operating a node",
656    },
657    Command {
658        name: "repo follower add",
659        args: "<owner/repo.git> <name> <url> [--state <dir>]",
660        summary: "on the node host, add a remote the repository's landings are pushed to (D21): `git remote add` on the bare repository, plus the marker that makes `node serve` pass --followers; restart the node once and every landing follows",
661        agent_facing: false,
662        group: "operating a node",
663    },
664    Command {
665        name: "repo follower list",
666        args: "[--state <dir>]",
667        summary: "every repository this node holds with the remotes it pushes to; a repository with none is named, since its objects have no second copy yet",
668        agent_facing: false,
669        group: "operating a node",
670    },
671    Command {
672        name: "repo follower push",
673        args: "[<owner/repo.git>] [--state <dir>]",
674        summary: "push every branch and tag to each remote now, no force and no prune; what the daemon does after each landing, for when it was not running with --followers",
675        agent_facing: false,
676        group: "operating a node",
677    },
678    Command {
679        name: "node serve",
680        args: "[--state <dir>] [--port <n>] [--create <owner/repo.git>] [-- <daemon flags>]",
681        summary: "run the node in this terminal, deriving root, credential and trusted keys from what `choir init` wrote; execs the daemon so signals and the exit code reach the real process",
682        agent_facing: false,
683        group: "operating a node",
684    },
685    Command {
686        name: "node install",
687        args: "[--state <dir>] [--port <n>] [-- <daemon flags>]",
688        summary: "hand the node to launchd (macOS) or a systemd user unit (Linux) so it survives logout, crash and reboot; the unit runs `choir node serve`",
689        agent_facing: false,
690        group: "operating a node",
691    },
692    Command {
693        name: "node tls",
694        args: "<domain> --user <account> [--port <n>] [--dry-run | --staging]",
695        summary: "obtain a Let's Encrypt certificate for this node and wire up renewal: certbot, a deploy hook that re-projects the pair and restarts the node, and the marker `node serve` reads; the only command here that expects root, and `--user` is required",
696        agent_facing: false,
697        group: "operating a node",
698    },
699    Command {
700        name: "node stop",
701        args: "",
702        summary: "stop the supervised node for this boot, leaving the unit in place; `node uninstall` is the one that ends it",
703        agent_facing: false,
704        group: "operating a node",
705    },
706    Command {
707        name: "node restart",
708        args: "",
709        summary: "reload the unit and start it again, which is how a rebuilt binary reaches the running node",
710        agent_facing: false,
711        group: "operating a node",
712    },
713    Command {
714        name: "node upgrade",
715        args: "--from <node-url> | --source <checkout> [--into <dir>] [--state <dir>] [--dry-run]",
716        summary: "newer binaries into the directory this `choir` runs from (or --into), a restart of the supervised node, and the build stamp read back from the running daemon; --from runs a node's own installer, --source builds a checkout with cargo",
717        agent_facing: false,
718        group: "operating a node",
719    },
720    Command {
721        name: "node uninstall",
722        args: "",
723        summary: "stop the node and remove its unit; the state directory, with the keys, repositories and op log, is kept",
724        agent_facing: false,
725        group: "operating a node",
726    },
727    Command {
728        name: "node logs",
729        args: "[<lines>] [--state <dir>]",
730        summary: "the tail of the node's log; defaults to the last 30 lines",
731        agent_facing: false,
732        group: "operating a node",
733    },
734    Command {
735        name: "node status",
736        args: "[<api>]",
737        summary: "health, the commit serving, the sequencer's position and its p99 against the 100 ms gate, and how much this credential can see",
738        agent_facing: false,
739        group: "operating a node",
740    },
741    Command {
742        name: "doctor",
743        args: "[<api>] [--state <dir>]",
744        summary: "check everything the other commands assume: the binaries shelled out to, the auth file and its mode, and whether a node answers; each failure prints the fix; on a hosting machine it adds bind address, TLS, certificate expiry, linger, unit state and whether the public URL answers; with `seeds =` in .choir/config, a fork check per seed",
745        // The one command worth reaching for when nothing else works,
746        // so it is not gated on being an agent's habit.
747        agent_facing: true,
748        group: "operating a node",
749    },
750    Command {
751        name: "backup verify",
752        args: "<backup-dir>",
753        summary: "whether a backup can be restored from: the four files, the manifest checksum, the hash chain, the policy archive and every git bundle, refusing a backup that carries a key or credential; every check local",
754        agent_facing: false,
755        group: "operating a node",
756    },
757    Command {
758        name: "backup take",
759        args: "<backup-dir> [--state <dir>]",
760        summary: "take a backup of this machine's node into a directory, in the shape `backup verify` reads: the log, checked to extend the copy already there and to verify as a chain, the node fingerprint, the attestation, the policy files as one archive with no secret in it, and one git bundle per repository, kept when its refs have not moved; then verifies what it wrote",
761        agent_facing: false,
762        group: "operating a node",
763    },
764    Command {
765        name: "backup schedule",
766        args: "<backup-dir> [--every <seconds>] [--state <dir>]",
767        summary: "run `backup take` into that directory on a timer, launchd or systemd, every hour unless told otherwise; `backup unschedule` removes it",
768        agent_facing: false,
769        group: "operating a node",
770    },
771    Command {
772        name: "backup unschedule",
773        args: "",
774        summary: "remove the backup timer; the backups already taken are kept",
775        agent_facing: false,
776        group: "operating a node",
777    },
778    Command {
779        name: "backup restore",
780        args: "<backup-dir> <target-root>",
781        summary: "turn a backup back into a node and prove it by accepting a real push: reads and refuses before writing, unbundles git objects before the first boot, rehearses on a port it picks; exit 3 means a secret only you can supply is missing",
782        agent_facing: false,
783        group: "operating a node",
784    },
785    Command {
786        name: "repair",
787        args: "<log-file> --verify | --truncate-tail",
788        summary: "inspect a stopped node's op log, or repair a tail that was still being written; `--verify` changes nothing, `--truncate-tail` quarantines the partial record before cutting, and damage anywhere but the tail is refused",
789        agent_facing: false,
790        group: "operating a node",
791    },
792];
793
794/// Every endpoint the node serves, in the order `docs/using/cli.md`
795/// lists them.
796pub const ENDPOINTS: &[Endpoint] = &[
797    Endpoint {
798        method: "POST",
799        path: "/api/submit",
800        purpose: "Submit one signed operation (hex payload, hex signature)",
801        mcp: Some(McpTool {
802            name: "choir_submit",
803            input_schema: SUBMISSION_MCP_SCHEMA,
804            arguments: McpArguments::Body,
805        }),
806    },
807    Endpoint {
808        method: "POST",
809        path: "/api/submit-batch",
810        purpose: "Same, in array order; the primary path for agent workloads",
811        mcp: Some(McpTool {
812            name: "choir_submit_batch",
813            input_schema: BATCH_MCP_SCHEMA,
814            arguments: McpArguments::Body,
815        }),
816    },
817    Endpoint {
818        method: "GET",
819        path: "/api/view?limit=N&offset=M",
820        purpose: "The materialized view plus the latest ref-state attestation, key bindings, T2 review outcomes, T3 concentration, T4 newcomer harm, view growth, the build commit, and the sequencer's p99 against the 100 ms gate; on a seed, `replica` says whose copy it is and how far behind. Under an ACL you get your own slice; node-wide sections need a node-wide grant, and a missing repository is one you were not granted. Map-shaped sections are bounded: `limit` rows (200 default, 1000 max), `offset`, `<section>_omitted`, and `paging.next`",
821        mcp: Some(McpTool {
822            name: "choir_view",
823            input_schema: PAGING_MCP_SCHEMA,
824            arguments: McpArguments::Query {
825                parameters: &["limit", "offset"],
826            },
827        }),
828    },
829    Endpoint {
830        method: "POST",
831        path: "/api/appeal",
832        purpose: "Record an appeal for a rejected newcomer attempt; requests operator adjudication and never changes privilege",
833        mcp: Some(McpTool {
834            name: "choir_appeal",
835            input_schema: APPEAL_MCP_SCHEMA,
836            arguments: McpArguments::Body,
837        }),
838    },
839    Endpoint {
840        method: "GET",
841        path: "/api/log?from=N",
842        purpose: "Ordered log entries, the catch-up and sync primitive. Absolute `from`; evicted entries are served from the persisted log (`source` says which), and a node that cannot reach back answers 409. Each entry carries hash, parent and author signature; SYNC.md is the verification procedure",
843        mcp: Some(McpTool {
844            name: "choir_log",
845            input_schema: LOG_MCP_SCHEMA,
846            arguments: McpArguments::Query { parameters: &["from"] },
847        }),
848    },
849    Endpoint {
850        method: "GET",
851        path: "/api/signers",
852        purpose: "This node's public key and every key it trusts, with the channel a key is bound to, versioned: what SYNC.md's authorship check needs, since the log names a key's id and never the key. Behind the same node-wide read grant as `/api/log`",
853        mcp: None,
854    },
855    Endpoint {
856        method: "GET",
857        path: "/api/witness",
858        purpose: "On a seed: its signed statements that it folded the home's attestations, the latest and the last 64, versioned. Compare one with the home's `snapshot` by ancestry in the `prev_snapshot` chain (SYNC.md, Seeds); neither an ancestor of the other is a fork. 404 on a home",
859        mcp: None,
860    },
861    Endpoint {
862        method: "POST",
863        path: "/api/workspace",
864        purpose: "Provision a CoW workspace; optional exact base/change binding makes retries idempotent",
865        mcp: Some(McpTool {
866            name: "choir_workspace",
867            input_schema: WORKSPACE_MCP_SCHEMA,
868            arguments: McpArguments::Body,
869        }),
870    },
871    Endpoint {
872        method: "POST",
873        path: "/api/workspace/archive",
874        purpose: "Recoverably archive a change-bound workspace and remove it from the active view",
875        mcp: Some(McpTool {
876            name: "choir_workspace_archive",
877            input_schema: WORKSPACE_ARCHIVE_MCP_SCHEMA,
878            arguments: McpArguments::Body,
879        }),
880    },
881    Endpoint {
882        method: "GET",
883        path: "/api/reviews?reviewer=X",
884        purpose: "One actor's pending review queue",
885        mcp: Some(McpTool {
886            name: "choir_reviews",
887            input_schema: REVIEWS_MCP_SCHEMA,
888            arguments: McpArguments::Query {
889                parameters: &["reviewer", "limit", "offset"],
890            },
891        }),
892    },
893    Endpoint {
894        method: "GET",
895        path: "/api/schema",
896        purpose: "This surface, machine-readable and versioned, plus what this node will accept; the description an agent generates a client from (D17)",
897        // A tool like any other: "what will this node accept" is a
898        // question an agent asks before branching, and routing it
899        // through the same authenticated client is what keeps a
900        // credential off a `curl` command line in the shell library.
901        mcp: Some(McpTool {
902            name: "choir_schema",
903            input_schema: EMPTY_MCP_SCHEMA,
904            arguments: McpArguments::Empty,
905        }),
906    },
907    Endpoint {
908        method: "GET",
909        path: "/api/search?q=X&in=code&repo=owner/name&rev=R&limit=N",
910        purpose: "Search repository contents, file names or commit messages across every repository you may read, each at HEAD; `rev` needs a single `repo`. Ungranted repositories are absent, or answered as nonexistent by name. Unindexed (`git grep`): `limit` bounds the results, `matches` counts everything, `truncated` says which",
911        mcp: Some(McpTool {
912            name: "choir_search",
913            input_schema: SEARCH_MCP_SCHEMA,
914            arguments: McpArguments::Query {
915                parameters: &["q", "in", "repo", "rev", "limit"],
916            },
917        }),
918    },
919    Endpoint {
920        method: "GET",
921        path: "/api/profile?channel=X",
922        purpose: "One actor's standing out of the view you may see: bound keys and their age, changes owned, reviews assigned and verdicts given, approvals slashed, checks reported, and `vouches` with direction. Two callers with different grants get different numbers. No score; time-locked grants (D66) live outside the log and are not counted",
923        mcp: Some(McpTool {
924            name: "choir_profile",
925            input_schema: PROFILE_MCP_SCHEMA,
926            arguments: McpArguments::Query {
927                parameters: &["channel"],
928            },
929        }),
930    },
931    Endpoint {
932        method: "GET",
933        path: "/llms.txt",
934        purpose: "This surface, as text, for an agent that has never seen choir",
935        mcp: None,
936    },
937    Endpoint {
938        method: "GET",
939        path: "/sync.md",
940        purpose: "The sync contract: cursor semantics and how to verify a page's hash chain and author signatures",
941        mcp: None,
942    },
943    Endpoint {
944        method: "GET",
945        path: "/api/repos",
946        purpose: "Which repositories this credential can see, read from the filesystem; `narrowed` says whether an ACL was applied",
947        mcp: None,
948    },
949    Endpoint {
950        method: "POST",
951        path: "/api/repo",
952        purpose: "Create a repository on a running node with the `pre-receive` hook that sequences its pushes; needs a node-wide write grant; appends nothing to the log",
953        mcp: None,
954    },
955    Endpoint {
956        method: "GET",
957        path: "/api/ref-agreement",
958        purpose: "Where the op log and the bare repos disagree about a ref, read-only",
959        mcp: None,
960    },
961    Endpoint {
962        method: "POST",
963        path: "/api/accounts/invite",
964        purpose: "Mint a single-use, expiring invite and the grants it will hold; needs a node-wide write grant and can never issue one; a grant may carry `until=<unix seconds>` (D66)",
965        mcp: None,
966    },
967    Endpoint {
968        method: "POST",
969        path: "/api/accounts/redeem",
970        purpose: "Redeem an invite, presented as the credential, for a token, once, and register an ssh key",
971        mcp: None,
972    },
973    Endpoint {
974        method: "POST",
975        path: "/api/accounts/request/grant",
976        purpose: "Answer an access request (D72): turns it into an invite under the id and secret the asker already holds",
977        mcp: None,
978    },
979    Endpoint {
980        method: "POST",
981        path: "/api/accounts/request/decline",
982        purpose: "Drop a pending access request; their link then reads as never valid",
983        mcp: None,
984    },
985    Endpoint {
986        method: "POST",
987        path: "/api/accounts/revoke",
988        purpose: "Delete an account: its token stops authenticating on the next request, and its grants and keys go with it",
989        mcp: None,
990    },
991    Endpoint {
992        method: "GET",
993        path: "/api/accounts",
994        purpose: "Who holds an account, what they were granted, and which invites are outstanding; never a secret or its hash",
995        mcp: None,
996    },
997    Endpoint {
998        method: "POST",
999        path: "/api/git-update",
1000        purpose: "Internal: the pre-receive hook callback",
1001        mcp: None,
1002    },
1003    Endpoint {
1004        method: "POST",
1005        path: "/api/git-abort",
1006        purpose: "Internal: retracts a refused push's already-accepted refs",
1007        mcp: None,
1008    },
1009];
1010
1011/// MCP tools in deterministic endpoint-table order.
1012///
1013/// The order is deliberately not sorted at runtime: stable tool order
1014/// improves client prompt-cache hits, and table order is the one source
1015/// shared with the reference page and the discovery documents.
1016#[must_use]
1017pub fn mcp_tools() -> Vec<serde_json::Value> {
1018    ENDPOINTS
1019        .iter()
1020        .filter_map(|endpoint| {
1021            let tool = endpoint.mcp.as_ref()?;
1022            let schema: serde_json::Value =
1023                serde_json::from_str(tool.input_schema).expect("static MCP schema is valid JSON");
1024            Some(serde_json::json!({
1025                "name": tool.name,
1026                "description": endpoint.purpose,
1027                "inputSchema": schema,
1028            }))
1029        })
1030        .collect()
1031}
1032
1033/// Finds the HTTP endpoint backing an MCP tool.
1034#[must_use]
1035pub fn mcp_endpoint(name: &str) -> Option<&'static Endpoint> {
1036    ENDPOINTS
1037        .iter()
1038        .find(|endpoint| endpoint.mcp.as_ref().is_some_and(|tool| tool.name == name))
1039}
1040
1041/// Finds an HTTP endpoint by method and path.
1042///
1043/// Separate from [`mcp_endpoint`] because a few endpoints are
1044/// deliberately outside the MCP surface — `GET /api/accounts` is the
1045/// roster, which is the operator's to read and not an agent tool — and
1046/// the CLI still has to reach them. Looking them up here rather than
1047/// spelling a path into a command keeps the table the one description of
1048/// what this node serves.
1049#[must_use]
1050pub fn endpoint(method: &str, path: &str) -> Option<&'static Endpoint> {
1051    ENDPOINTS
1052        .iter()
1053        .find(|endpoint| endpoint.method == method && endpoint.path == path)
1054}
1055
1056/// The `choir` usage block, as a bare invocation prints it.
1057///
1058/// Names and one-line summaries only, grouped. The full argument spec of
1059/// a command is a line of its own and there are thirty-two of them; put
1060/// them all here and the reader scans a wall of `<api> <key-file>` for
1061/// the one word they came for. `choir <command> --help` prints the spec
1062/// for one command, which is the question anybody actually has.
1063#[must_use]
1064pub fn usage() -> String {
1065    usage_in(Style::plain())
1066}
1067
1068/// [`usage`], styled for a terminal.
1069///
1070/// One renderer, two callers: the plain form is what the tests read,
1071/// and a second copy of this layout would be a second place for the
1072/// index to be wrong.
1073#[must_use]
1074pub fn usage_in(style: Style) -> String {
1075    let width = COMMANDS.iter().map(|c| c.name.len()).max().unwrap_or(0);
1076    let mut out = format!("{}\n  choir <command> [args]\n", style.bold("usage:"));
1077    out.push_str(&format!("  choir {AUTH_OPTIONS} <command> [args]\n"));
1078    out.push_str("  choir <command> --help\n");
1079    for group in GROUPS {
1080        out.push_str(&format!("\n{}\n", style.bold(group)));
1081        for c in COMMANDS.iter().filter(|c| &c.group == group) {
1082            // First clause only. A summary here earns one line, and the
1083            // clauses after the first are the caveats -- which belong on
1084            // the command's own help, next to the argument they qualify.
1085            let short = first_clause(c.summary, 58);
1086            // Padded before painting: an escape sequence has width in
1087            // bytes and none on screen, so `{:width$}` over a painted
1088            // name indents every line differently.
1089            let name = format!("{:width$}", c.name);
1090            out.push_str(&format!("  {}  {}\n", style.cyan(&name), style.dim(&short)));
1091        }
1092    }
1093    out.push_str(&format!(
1094        "\n{}\n",
1095        style.dim(
1096            "Most commands take the node's URL as their first argument, and fill it in \n\
1097             when you leave it out. It is looked for as `node = <url>` in `.choir/config` \n\
1098             in this directory, then in each directory above it, then in `~/.choir/config` \n\
1099             -- so a checkout that names its own node wins, and the one `choir join` \n\
1100             wrote answers everywhere else. An explicit URL always wins over both."
1101        )
1102    ));
1103    out.push_str(&format!(
1104        "\n{}\n",
1105        style.dim(
1106            "Exit codes: 0 accepted, 1 the node rejected (its JSON error body is \
1107             printed), 2 usage error."
1108        )
1109    ));
1110    out
1111}
1112
1113/// A summary's opening clause, cut to `max` on a word boundary.
1114///
1115/// The index has one line per command and the summaries are written as
1116/// several clauses, so an uncut one wraps and the list stops being a
1117/// list. What is cut is always available: `choir <command> --help`
1118/// prints the whole thing.
1119fn first_clause(summary: &str, max: usize) -> String {
1120    let clause = summary.split(';').next().unwrap_or(summary).trim();
1121    if clause.chars().count() <= max {
1122        return clause.to_string();
1123    }
1124    let mut cut = String::new();
1125    for word in clause.split_whitespace() {
1126        // +1 for the space, +1 for the ellipsis that will follow.
1127        if cut.chars().count() + word.chars().count() + 2 > max {
1128            break;
1129        }
1130        if !cut.is_empty() {
1131            cut.push(' ');
1132        }
1133        cut.push_str(word);
1134    }
1135    // Trailing punctuation before an ellipsis reads as a typo.
1136    while cut.ends_with(',') || cut.ends_with('—') || cut.ends_with('-') {
1137        cut.pop();
1138        cut = cut.trim_end().to_string();
1139    }
1140    format!("{cut}…")
1141}
1142
1143/// The help for one command: its full spec and its whole summary.
1144#[must_use]
1145pub fn command_help(name: &str) -> Option<String> {
1146    command_help_in(name, Style::plain())
1147}
1148
1149/// [`command_help`], styled for a terminal.
1150#[must_use]
1151pub fn command_help_in(name: &str, style: Style) -> Option<String> {
1152    let c = COMMANDS.iter().find(|c| c.name == name)?;
1153    let mut out = format!("  choir {} {}\n\n", style.cyan(c.name), style.dim(c.args));
1154    // The summary's clauses, one per line. They are written as one
1155    // sentence of several clauses, and read far better as a short list
1156    // than as a paragraph wrapped by the terminal.
1157    for (n, clause) in c.summary.split(';').enumerate() {
1158        let clause = clause.trim();
1159        if n == 0 {
1160            out.push_str(&format!("  {clause}\n"));
1161        } else {
1162            out.push_str(&format!("    - {clause}\n"));
1163        }
1164    }
1165    if c.args.starts_with("<api>") {
1166        out.push_str(&format!(
1167            "\n  {}\n",
1168            style.dim(
1169                "<api> may be omitted when `.choir/config` names a node, here or in\n  \
1170                 any parent directory."
1171            )
1172        ));
1173    }
1174    Some(out)
1175}
1176
1177/// The endpoint table `docs/using/cli.md` carries.
1178#[must_use]
1179pub fn api_table() -> String {
1180    let mut out = String::from("| Endpoint | Purpose |\n|---|---|\n");
1181    for e in ENDPOINTS {
1182        out.push_str(&format!("| `{} {}` | {} |\n", e.method, e.path, e.purpose));
1183    }
1184    out
1185}
1186
1187/// The generated API and CLI reference in `docs/using/cli.md`.
1188///
1189/// Named for the document it fills rather than for the README, which is
1190/// where it used to live. The README now carries [`readme_cheatsheet`]
1191/// instead: a reader arriving at a repository wants the shortest path to
1192/// a first change, and thirty-two full argument specs is not it.
1193#[must_use]
1194pub fn cli_doc_surface() -> String {
1195    format!(
1196        "### HTTP endpoints\n\n{}\n### The `choir` CLI\n\n{}",
1197        api_table(),
1198        cli_reference()
1199    )
1200}
1201
1202/// The commands a first contribution needs, in reading order.
1203///
1204/// `key` and `join` are alternatives rather than steps: an operator
1205/// mints a key, somebody holding an invite runs `join` and gets one.
1206/// The rest are the loop.
1207///
1208/// A list of names rather than a `day_one` field on [`Command`],
1209/// deliberately, and the argument cuts the other way from the one
1210/// [`Command::group`] makes. Every command must belong to *some* help
1211/// section, so a field is right there and the compiler should ask. No
1212/// command has to be on a getting-started path, so a new one is
1213/// presumptively absent, and a field would ask thirty-two questions
1214/// whose answer is `false`.
1215///
1216/// Checked against [`COMMANDS`] by the surface test: a name here that no
1217/// longer exists fails rather than silently rendering nothing.
1218pub const DAY_ONE: &[&str] = &[
1219    "host",
1220    "key",
1221    "join",
1222    "workspace",
1223    "propose",
1224    "reviews",
1225    "verdict",
1226    "state",
1227    "log",
1228];
1229
1230/// The README's cheat-sheet: the day-one commands and nothing else.
1231///
1232/// Generated rather than hand-written for the reason every other table
1233/// here is. A short list beside a complete one is the drift this module
1234/// exists to stop, and a cheat-sheet is exactly the kind of document
1235/// that gets written once and then quietly stops being true.
1236#[must_use]
1237pub fn readme_cheatsheet() -> String {
1238    let mut out = String::from("| Command | What it does |\n|:--|:--|\n");
1239    for name in DAY_ONE {
1240        if let Some(c) = COMMANDS.iter().find(|c| &c.name == name) {
1241            // First clause only, for the reason `usage_in` takes it: the
1242            // clauses after the first are caveats, and a caveat in a
1243            // table cell wraps the row to three lines and stops the
1244            // table being scannable. This is the shortest of the three
1245            // renderings and the one read first, so it is the one that
1246            // can least afford them.
1247            let short = first_clause(c.summary, 64);
1248            out.push_str(&format!("| `choir {}` | {} |\n", c.name, short));
1249        }
1250    }
1251    out.push_str("\nFull surface, every command and every endpoint: [`docs/using/cli.md`](docs/using/cli.md).\n");
1252    out
1253}
1254
1255/// The command reference in `docs/using/cli.md`: every command, its full
1256/// argument spec, and what it is for.
1257///
1258/// Not [`usage`]. Help is an index — the reader is at a prompt and wants
1259/// the name of the thing, and thirty-two full argument specs is what
1260/// they have to read past to find it. A reference page is the opposite
1261/// situation: the reader is already looking the command up, and the
1262/// specs are the reason they came. Rendering both from the same table
1263/// keeps them from disagreeing without pretending they answer the same
1264/// question.
1265///
1266/// [`readme_cheatsheet`] is the third answer to the same table, for the
1267/// third reader: somebody deciding whether to try this at all.
1268#[must_use]
1269pub fn cli_reference() -> String {
1270    let mut out = String::new();
1271    for group in GROUPS {
1272        out.push_str(&format!("**{group}**\n\n"));
1273        for c in COMMANDS.iter().filter(|c| &c.group == group) {
1274            out.push_str(&format!(
1275                "- `choir {} {}`  \n  {}\n",
1276                c.name, c.args, c.summary
1277            ));
1278        }
1279        out.push('\n');
1280    }
1281    out.push_str(
1282        "Most commands take the node's URL first. Put `node = <url>` in `.choir/config`, \
1283         in the working directory or any parent, and it is filled in when omitted. \
1284         `choir <command> --help` prints one command's spec.\n\n\
1285         Exit codes: 0 accepted, 1 the node rejected (its JSON error body is printed), \
1286         2 usage error.\n",
1287    );
1288    out
1289}
1290
1291/// The command list the agent templates carry, as a markdown bullet list.
1292#[must_use]
1293pub fn command_bullets() -> String {
1294    let mut out = format!(
1295        "For an authenticated node, place `{AUTH_OPTIONS}` before the subcommand. \
1296         Credentials are read from the named file, never an environment variable.\n\n"
1297    );
1298    for c in COMMANDS.iter().filter(|c| c.agent_facing) {
1299        out.push_str(&format!("- `choir {} {}`: {}\n", c.name, c.args, c.summary));
1300    }
1301    out
1302}
1303
1304/// `AGENTS.md`: the generated choir reference for coding agents.
1305#[must_use]
1306pub fn agents_md() -> String {
1307    format!(
1308        "# choir, for agents\n\n\
1309         Generated from `crates/choir-cli/src/surface.rs`. Do not edit; edit the table.\n\n\
1310         choir is an agent-first code collaboration platform. Many agents work on one \
1311         repository at once, a single-writer sequencer puts every change in one total order, \
1312         and merge conflicts are first-class values rather than errors.\n\n\
1313         ## Use the signed-op API, not `git push`\n\n\
1314         `git push` works and is the compatibility path. The signed-operation API is the \
1315         primary agent path: faster, carries your identity, and says what you are doing. \
1316         For more than one change, use `POST /api/submit-batch`, not a loop over \
1317         `POST /api/submit`: a batch is one durability barrier.\n\n\
1318         ## Commands\n\n{}\n\
1319         ## Endpoints\n\n{}\n\
1320         ## Conventions that are not obvious\n\n\
1321         - A conflict is a committed value, not a failure. Commit it and resolve in a \
1322         follow-up.\n\
1323         - Request review naming no reviewers; the node draws them. A review with no \
1324         reviewers never counts as approved.\n\
1325         - Channel names are `operator/agent`. Agents sharing an operator prefix cannot \
1326         review each other.\n\
1327         - Publish your task spec with `choir intent` when you pick up work; update it when \
1328         scope changes.\n\
1329         - Commit and push the git object before `choir checkpoint`; the checkpoint does not \
1330         transfer workspace-local objects.\n\
1331         - Secrets live under `~/.choir/`. Never write one into the repository.\n",
1332        command_bullets(),
1333        api_table()
1334    )
1335}
1336
1337/// `llms.txt`, served by the node: the same surface, compact, no markdown
1338/// tables — a plain list survives a small context window better.
1339#[must_use]
1340pub fn llms_txt() -> String {
1341    let mut out = String::from(
1342        "# choir\n\n\
1343         Agent-first code collaboration. One total order from a single-writer sequencer; \
1344         conflicts are values, not errors.\n\n\
1345         Use POST /api/submit-batch for more than one change: a batch is one durability \
1346         barrier, a loop is one per operation. git push is the compatibility path.\n\n\
1347         ## Endpoints\n",
1348    );
1349    for e in ENDPOINTS {
1350        out.push_str(&format!("{} {} - {}\n", e.method, e.path, e.purpose));
1351    }
1352    // What a denial means, because the two statuses carry different
1353    // instructions and neither is worth retrying (D29).
1354    out.push_str(
1355        "\n## Access\n\
1356         404 on a repository means no read grant, and says nothing about whether it exists; \
1357         403 means read but not write, or the operation needs a node-wide grant. Neither is \
1358         retryable: ask the operator for a grant line.\n",
1359    );
1360    out.push_str("\n## CLI\n");
1361    out.push_str(&format!(
1362        "For authenticated nodes: choir {AUTH_OPTIONS} <command> ...\n"
1363    ));
1364    for c in COMMANDS {
1365        out.push_str(&format!("choir {} {} - {}\n", c.name, c.args, c.summary));
1366    }
1367    out
1368}
1369
1370/// The three commands a newcomer runs, as an HTML fragment the node
1371/// serves on its contribute page.
1372///
1373/// Generated rather than written into `browse.rs` for the reason
1374/// `llms.txt` is: a page that tells a newcomer which flag to pass is the
1375/// worst place in the system for a stale signature, because its whole
1376/// readership is people with no way to tell it is wrong. It lands in
1377/// [`artifacts`], so the one staleness test that covers `--help` and the
1378/// templates covers this too.
1379///
1380/// `NODE` and `REPO` are placeholders the node substitutes for its own
1381/// base URL and the repository being read. They are spelled in capitals
1382/// so that a page which somehow escapes substitution reads as obviously
1383/// unfinished rather than as an address somebody might try.
1384#[must_use]
1385pub fn contribute_html() -> String {
1386    // Pulled from the table by name, so removing or renaming a command
1387    // breaks the build here instead of quietly emptying the page.
1388    let find = |name: &str| {
1389        COMMANDS
1390            .iter()
1391            .find(|c| c.name == name)
1392            .unwrap_or_else(|| panic!("`choir {name}` is in the command table"))
1393    };
1394    let mut out = String::new();
1395    for (step, name, what, example) in [
1396        (
1397            "1",
1398            "join",
1399            "Paste the whole link your operator sent you, quotes included. This mints your key, \
1400             stores your token, points git at that token for this node, and clones each \
1401             repository the invite names into a folder of its own name. There is no \
1402             registration, and no second message to wait for.",
1403            "choir join 'NODE/join?i=…&amp;k=…'",
1404        ),
1405        (
1406            "2",
1407            "git-credential",
1408            "Anything else you can read, clone normally. The token stays in the file `choir \
1409             join` wrote and never enters the URL, so it cannot leak through `git remote -v` \
1410             or a pasted clone line. This command is here for anybody who would rather wire \
1411             that up by hand.",
1412            "git clone NODE/REPO.git",
1413        ),
1414        (
1415            "3",
1416            "propose",
1417            "Commit on a branch as you always would, then run this from inside the checkout, \
1418             with no arguments. It creates the change, pushes it, publishes the revision and \
1419             requests review. Run it again after an amend and it updates the same proposal \
1420             rather than opening a second one -- the branch name is what identifies the \
1421             change.",
1422            "git checkout -b fix-the-thing\n\
1423             git commit -am 'fix the thing'\n\
1424             choir propose",
1425        ),
1426    ] {
1427        let command = find(name);
1428        out.push_str("<li><h3><span class=\"step\">");
1429        out.push_str(step);
1430        out.push_str("</span> <code>choir ");
1431        out.push_str(command.name);
1432        out.push_str("</code></h3><p>");
1433        out.push_str(what);
1434        out.push_str("</p><pre class=\"cmd\">");
1435        // Escaped like the signature below it: an example carrying
1436        // `<your-channel>` would otherwise be parsed as a tag and vanish
1437        // from the page, leaving a command that looks complete and is
1438        // missing its last argument.
1439        out.push_str(&escape_html(example));
1440        out.push_str("</pre><p class=\"muted mono\">choir ");
1441        out.push_str(command.name);
1442        out.push(' ');
1443        out.push_str(&escape_html(command.args));
1444        out.push_str("</p></li>\n");
1445    }
1446    out
1447}
1448
1449/// Minimal HTML escaping for text rendered into the generated fragment.
1450///
1451/// Only the three characters that can end an element or an attribute.
1452/// The inputs are this file's own constants rather than anything a
1453/// request carries, so this is here to keep `<api>` rendering as `<api>`
1454/// rather than disappearing into an unknown tag.
1455fn escape_html(text: &str) -> String {
1456    text.replace('&', "&amp;")
1457        .replace('<', "&lt;")
1458        .replace('>', "&gt;")
1459}
1460
1461/// The machine-readable API description (D17), as pretty JSON.
1462///
1463/// The Code-Mode bet is that an agent writes code against a typed API
1464/// rather than making many tool calls. Whatever language that code is
1465/// eventually written in, it needs one description of the surface that
1466/// cannot drift from the surface — so this is rendered from the same
1467/// table that already renders `--help`, `docs/using/cli.md`,
1468/// `llms.txt`, the three `templates/` snippets and the MCP tool list,
1469/// and lands in
1470/// [`artifacts`] beside them so the one staleness test covers it.
1471///
1472/// **Static facts only.** What a *particular* node will accept —
1473/// accounts, quotas, an ACL, the review gates — varies per deployment
1474/// and cannot be generated, so the node merges a live `capabilities`
1475/// object into this document when it serves it. Putting a runtime fact
1476/// in a committed file would be a lie with a staleness test guarding it.
1477#[must_use]
1478pub fn schema_json() -> String {
1479    let endpoints: Vec<serde_json::Value> = ENDPOINTS
1480        .iter()
1481        .map(|e| {
1482            // The table's `path` carries the query parameter that is part
1483            // of the contract (`/api/log?from=N`), which is right for a
1484            // human reading `llms.txt` and useless to a generator: it
1485            // would have to parse the template back out. So the two are
1486            // split here, from information the table already holds —
1487            // `McpArguments::Query` names the parameters.
1488            let (path, query) = match e.mcp.as_ref().map(|m| m.arguments) {
1489                Some(McpArguments::Query { parameters }) => (
1490                    e.path.split('?').next().unwrap_or(e.path),
1491                    parameters.to_vec(),
1492                ),
1493                _ => (e.path, Vec::new()),
1494            };
1495            serde_json::json!({
1496                "method": e.method,
1497                "path": path,
1498                // Present and empty rather than absent, so a generator
1499                // never has to distinguish "no parameters" from "this
1500                // build did not say".
1501                "query_parameters": query,
1502                // The documented spelling, kept because `llms.txt` and
1503                // `docs/using/cli.md` show it, and a client comparing
1504                // the two should not have to wonder whether they
1505                // disagree.
1506                "documented_as": e.path,
1507                "purpose": e.purpose,
1508                // The stable programmatic name, and the signal that this
1509                // endpoint is one an agent is meant to call at all:
1510                // internal hook endpoints carry neither.
1511                "name": e.mcp.as_ref().map(|m| m.name),
1512                "agent_facing": e.mcp.is_some(),
1513                "input_schema": e.mcp.as_ref().map(|m| {
1514                    serde_json::from_str::<serde_json::Value>(m.input_schema)
1515                        .expect("every input schema in this table is JSON")
1516                }),
1517            })
1518        })
1519        .collect();
1520    let commands: Vec<serde_json::Value> = COMMANDS
1521        .iter()
1522        .map(|c| {
1523            serde_json::json!({
1524                "name": c.name,
1525                "args": c.args,
1526                "summary": c.summary,
1527                "agent_facing": c.agent_facing,
1528            })
1529        })
1530        .collect();
1531    let deprecations: Vec<serde_json::Value> = DEPRECATIONS
1532        .iter()
1533        .map(|(name, replacement, note)| {
1534            serde_json::json!({ "name": name, "replaced_by": replacement, "note": note })
1535        })
1536        .collect();
1537    let doc = serde_json::json!({
1538        "api_version": API_VERSION,
1539        "endpoints": endpoints,
1540        "commands": commands,
1541        "deprecations": deprecations,
1542    });
1543    format!(
1544        "{}\n",
1545        serde_json::to_string_pretty(&doc).expect("the schema is always serializable")
1546    )
1547}
1548
1549/// The generated half of `templates/shell/choir.sh`: one function per
1550/// agent-facing command (D17).
1551///
1552/// Thin on purpose. A wrapper that only forwards its arguments cannot
1553/// drift from the binary, and the arguments it forwards come from the
1554/// same table `--help` prints — so a command renamed here renames the
1555/// shell function in the same commit or the staleness test fails.
1556/// Anything worth more than forwarding is a *flow*, which is judgement
1557/// about order and lives in the hand-written half of that file.
1558///
1559/// `sh` rather than `bash`: the harnesses that will source this run
1560/// whatever `/bin/sh` is, and nothing here needs an array or a
1561/// `[[`-test.
1562#[must_use]
1563pub fn shell_functions() -> String {
1564    let mut out = String::from(
1565        "# One function per agent-facing command, forwarding its arguments\n\
1566         # to the binary. Generated from the same table as `choir --help`;\n\
1567         # edit `crates/choir-cli/src/surface.rs` and regenerate.\n",
1568    );
1569    for c in COMMANDS.iter().filter(|c| c.agent_facing) {
1570        // Shell function names cannot carry a hyphen portably.
1571        let name = c.name.replace('-', "_");
1572        out.push_str(&format!(
1573            "\n# choir {} {}\n#   {}\nchoir_{name}() {{\n\tchoir_run {} \"$@\"\n}}\n",
1574            c.name, c.args, c.summary, c.name
1575        ));
1576    }
1577    out
1578}
1579
1580/// Directory name the agent skill installs under; the skill frontmatter's
1581/// `name:` must equal it, because skill loaders resolve by directory.
1582pub const SKILL_DIR: &str = "choir";
1583
1584/// The installable agent skill.
1585///
1586/// Rendered from the same table as `--help` and `AGENTS.md` at the moment
1587/// of installation, so — unlike docs baked in as static files — the
1588/// installed skill can never describe a different version than the binary
1589/// that wrote it. Re-installing after an upgrade refreshes it.
1590#[must_use]
1591pub fn skill_md() -> String {
1592    format!(
1593        "---\nname: {SKILL_DIR}\ndescription: Drive a choir node — signed operations, \
1594         workspaces, reviews, triage and next actions. Use when working in a repository \
1595         served by a choir node, or when asked to run choir commands.\n---\n\n{}",
1596        agents_md()
1597    )
1598}
1599
1600/// The generated half of `templates/python/choir.py`: one method per
1601/// tool, rendered **from the schema document and nothing else** (D17).
1602///
1603/// This is the point of the artifact rather than an implementation
1604/// detail. D17 bets that an agent writes code against a typed API, and
1605/// `/api/schema` is the description that code would be generated from —
1606/// but a description is only sufficient if something has actually been
1607/// generated from it without reading choir's source. So this takes the
1608/// parsed schema as its argument and never touches [`ENDPOINTS`] or
1609/// [`COMMANDS`]. If a method comes out wrong, the schema is what was
1610/// insufficient, and that is the finding.
1611///
1612/// The shell library is the opposite bargain and both are wanted: it
1613/// wraps the binary and can therefore sign, while this needs no binary
1614/// and therefore cannot.
1615#[must_use]
1616pub fn python_client(schema: &serde_json::Value) -> String {
1617    let mut out = String::from(
1618        "    # One method per tool, from the node's own description.\n\
1619         \x20   # Generated; edit `crates/choir-cli/src/surface.rs`.\n",
1620    );
1621    let endpoints = schema["endpoints"].as_array().cloned().unwrap_or_default();
1622    for endpoint in endpoints {
1623        // Only the tools: an endpoint with no name is one the schema
1624        // marks as not agent-facing, and a generated client offering it
1625        // would be offering something the description says not to call.
1626        let Some(name) = endpoint["name"].as_str() else {
1627            continue;
1628        };
1629        let method = endpoint["method"].as_str().unwrap_or("GET");
1630        let path = endpoint["path"].as_str().unwrap_or_default();
1631        let purpose = endpoint["purpose"].as_str().unwrap_or_default();
1632        let query: Vec<&str> = endpoint["query_parameters"]
1633            .as_array()
1634            .map(|values| values.iter().filter_map(|v| v.as_str()).collect())
1635            .unwrap_or_default();
1636
1637        // The schema says whether a tool takes anything at all. An
1638        // endpoint whose input schema has no properties gets a method
1639        // with no parameters, rather than one that accepts arguments and
1640        // drops them — which is what the first generated copy did, and
1641        // reading it is how that was found.
1642        let takes_arguments = endpoint["input_schema"]["properties"]
1643            .as_object()
1644            .is_some_and(|properties| !properties.is_empty());
1645
1646        // A query parameter is a keyword argument; a body is one dict.
1647        // `from` is a Python keyword, so every argument arrives through
1648        // `**kwargs` rather than a signature this generator would have
1649        // to escape — the schema names the parameters and the docstring
1650        // repeats them.
1651        if takes_arguments {
1652            out.push_str(&format!("\n    def {name}(self, **arguments):\n"));
1653        } else {
1654            out.push_str(&format!("\n    def {name}(self):\n"));
1655        }
1656        out.push_str(&format!("        \"\"\"{}\n\n", wrap_python_doc(purpose)));
1657        if !takes_arguments {
1658            out.push_str("        Takes no arguments.\n");
1659        } else if query.is_empty() {
1660            out.push_str("        Arguments become the JSON request body.\n");
1661        } else {
1662            out.push_str(&format!(
1663                "        Arguments become the query string: {}.\n",
1664                query.join(", ")
1665            ));
1666        }
1667        out.push_str("        \"\"\"\n");
1668        if !takes_arguments {
1669            out.push_str(&format!(
1670                "        return self._request(\"{method}\", \"{path}\")\n"
1671            ));
1672        } else if query.is_empty() {
1673            out.push_str(&format!(
1674                "        return self._request(\"{method}\", \"{path}\", body=arguments)\n"
1675            ));
1676        } else {
1677            out.push_str(&format!(
1678                "        return self._request(\"{method}\", \"{path}\", query=arguments)\n"
1679            ));
1680        }
1681    }
1682    out
1683}
1684
1685/// Reflows one purpose line into an indented Python docstring body.
1686fn wrap_python_doc(text: &str) -> String {
1687    let mut out = String::new();
1688    let mut column = 0;
1689    for word in text.split_whitespace() {
1690        if column + word.len() > 64 && column > 0 {
1691            out.push_str("\n        ");
1692            column = 0;
1693        } else if column > 0 {
1694            out.push(' ');
1695            column += 1;
1696        }
1697        out.push_str(word);
1698        column += word.len();
1699    }
1700    out
1701}
1702
1703/// Replaces the region between [`GEN_START`] and [`GEN_END`] in `doc`.
1704///
1705/// # Errors
1706///
1707/// Returns a description when the markers are missing or out of order,
1708/// rather than appending and quietly producing two generated regions.
1709pub fn splice(doc: &str, generated: &str) -> Result<String, String> {
1710    splice_between(doc, generated, GEN_START, GEN_END)
1711}
1712
1713/// [`splice`] with explicit markers, for a file whose comment syntax is
1714/// not HTML.
1715///
1716/// # Errors
1717///
1718/// Same as [`splice`]: a missing or out-of-order marker pair.
1719pub fn splice_between(
1720    doc: &str,
1721    generated: &str,
1722    start_marker: &str,
1723    end_marker: &str,
1724) -> Result<String, String> {
1725    let start = doc
1726        .find(start_marker)
1727        .ok_or("missing generated-start marker")?;
1728    let end = doc.find(end_marker).ok_or("missing generated-end marker")?;
1729    if end < start {
1730        return Err("generated markers are out of order".to_string());
1731    }
1732    Ok(format!(
1733        "{}{}\n\n{}\n{}",
1734        &doc[..start],
1735        start_marker,
1736        generated.trim_end(),
1737        &doc[end..]
1738    ))
1739}
1740
1741/// Every artifact rendered from this table, as `(path, full contents)`,
1742/// relative to the repository `root`.
1743///
1744/// Returned rather than written so the generator and the staleness test
1745/// share one definition of what exists — a test that enumerated the
1746/// artifacts separately would pass while missing a new one.
1747///
1748/// # Errors
1749///
1750/// A file that should carry generated markers and does not, or cannot be
1751/// read.
1752pub fn artifacts(root: &std::path::Path) -> Result<Vec<(std::path::PathBuf, String)>, String> {
1753    let read = |rel: &str| -> Result<String, String> {
1754        std::fs::read_to_string(root.join(rel)).map_err(|e| format!("{rel}: {e}"))
1755    };
1756    let mut out = vec![
1757        (root.join("AGENTS.md"), agents_md()),
1758        (root.join("crates/choir-node/src/llms.txt"), llms_txt()),
1759        (
1760            root.join("crates/choir-node/src/contribute.html"),
1761            contribute_html(),
1762        ),
1763        (
1764            root.join("crates/choir-node/src/schema.json"),
1765            schema_json(),
1766        ),
1767        // Owned by choir-node's reject module, generated here so one
1768        // staleness test covers every generated artifact rather than two
1769        // tests each covering half.
1770        (root.join("ERRORS.md"), choir_node::reject::errors_md()),
1771        // The book's palette, cut from the daemon's own stylesheet, so
1772        // the documentation and the product cannot drift apart in
1773        // colour, type scale or spacing. Same reason it lives here: one
1774        // staleness test over every generated artifact.
1775        (
1776            root.join("theme/choir-tokens.css"),
1777            choir_node::ui_tokens_css(),
1778        ),
1779        // Two documents, two audiences, one table. `docs/using/cli.md`
1780        // gets the complete surface; the README gets the eight commands
1781        // a first change needs. Both are generated so neither can drift
1782        // from the other.
1783        (
1784            root.join("docs/using/cli.md"),
1785            splice(&read("docs/using/cli.md")?, &cli_doc_surface())
1786                .map_err(|e| format!("docs/using/cli.md: {e}"))?,
1787        ),
1788        (
1789            root.join("README.md"),
1790            splice(&read("README.md")?, &readme_cheatsheet())
1791                .map_err(|e| format!("README.md: {e}"))?,
1792        ),
1793    ];
1794    out.push((
1795        root.join("templates/python/choir.py"),
1796        splice_between(
1797            &read("templates/python/choir.py")?,
1798            // Fed the rendered schema, not the table it came from: a
1799            // client generated from the description is the only thing
1800            // that shows the description is sufficient.
1801            &python_client(
1802                &serde_json::from_str(&schema_json()).expect("the schema we just rendered is JSON"),
1803            ),
1804            SH_GEN_START,
1805            SH_GEN_END,
1806        )
1807        .map_err(|e| format!("templates/python/choir.py: {e}"))?,
1808    ));
1809    out.push((
1810        root.join("templates/shell/choir.sh"),
1811        splice_between(
1812            &read("templates/shell/choir.sh")?,
1813            &shell_functions(),
1814            SH_GEN_START,
1815            SH_GEN_END,
1816        )
1817        .map_err(|e| format!("templates/shell/choir.sh: {e}"))?,
1818    ));
1819    for rel in [
1820        "templates/claude-code/CLAUDE.snippet.md",
1821        "templates/codex/AGENTS.snippet.md",
1822        "templates/cursor/choir.mdc",
1823    ] {
1824        out.push((
1825            root.join(rel),
1826            splice(&read(rel)?, &command_bullets()).map_err(|e| format!("{rel}: {e}"))?,
1827        ));
1828    }
1829    Ok(out)
1830}