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>]",
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, and the node URL in ~/.choir/config; --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: "node serve",
651        args: "[--state <dir>] [--port <n>] [--create <owner/repo.git>] [-- <daemon flags>]",
652        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",
653        agent_facing: false,
654        group: "operating a node",
655    },
656    Command {
657        name: "node install",
658        args: "[--state <dir>] [--port <n>] [-- <daemon flags>]",
659        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`",
660        agent_facing: false,
661        group: "operating a node",
662    },
663    Command {
664        name: "node tls",
665        args: "<domain> --user <account> [--port <n>] [--dry-run | --staging]",
666        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",
667        agent_facing: false,
668        group: "operating a node",
669    },
670    Command {
671        name: "node stop",
672        args: "",
673        summary: "stop the supervised node for this boot, leaving the unit in place; `node uninstall` is the one that ends it",
674        agent_facing: false,
675        group: "operating a node",
676    },
677    Command {
678        name: "node restart",
679        args: "",
680        summary: "reload the unit and start it again, which is how a rebuilt binary reaches the running node",
681        agent_facing: false,
682        group: "operating a node",
683    },
684    Command {
685        name: "node uninstall",
686        args: "",
687        summary: "stop the node and remove its unit; the state directory, with the keys, repositories and op log, is kept",
688        agent_facing: false,
689        group: "operating a node",
690    },
691    Command {
692        name: "node logs",
693        args: "[<lines>] [--state <dir>]",
694        summary: "the tail of the node's log; defaults to the last 30 lines",
695        agent_facing: false,
696        group: "operating a node",
697    },
698    Command {
699        name: "node status",
700        args: "[<api>]",
701        summary: "health, the commit serving, the sequencer's position and its p99 against the 100 ms gate, and how much this credential can see",
702        agent_facing: false,
703        group: "operating a node",
704    },
705    Command {
706        name: "doctor",
707        args: "[<api>] [--state <dir>]",
708        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",
709        // The one command worth reaching for when nothing else works,
710        // so it is not gated on being an agent's habit.
711        agent_facing: true,
712        group: "operating a node",
713    },
714    Command {
715        name: "backup verify",
716        args: "<backup-dir>",
717        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",
718        agent_facing: false,
719        group: "operating a node",
720    },
721    Command {
722        name: "backup restore",
723        args: "<backup-dir> <target-root>",
724        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",
725        agent_facing: false,
726        group: "operating a node",
727    },
728    Command {
729        name: "repair",
730        args: "<log-file> --verify | --truncate-tail",
731        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",
732        agent_facing: false,
733        group: "operating a node",
734    },
735];
736
737/// Every endpoint the node serves, in the order `docs/using/cli.md`
738/// lists them.
739pub const ENDPOINTS: &[Endpoint] = &[
740    Endpoint {
741        method: "POST",
742        path: "/api/submit",
743        purpose: "Submit one signed operation (hex payload, hex signature)",
744        mcp: Some(McpTool {
745            name: "choir_submit",
746            input_schema: SUBMISSION_MCP_SCHEMA,
747            arguments: McpArguments::Body,
748        }),
749    },
750    Endpoint {
751        method: "POST",
752        path: "/api/submit-batch",
753        purpose: "Same, in array order; the primary path for agent workloads",
754        mcp: Some(McpTool {
755            name: "choir_submit_batch",
756            input_schema: BATCH_MCP_SCHEMA,
757            arguments: McpArguments::Body,
758        }),
759    },
760    Endpoint {
761        method: "GET",
762        path: "/api/view?limit=N&offset=M",
763        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. 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`",
764        mcp: Some(McpTool {
765            name: "choir_view",
766            input_schema: PAGING_MCP_SCHEMA,
767            arguments: McpArguments::Query {
768                parameters: &["limit", "offset"],
769            },
770        }),
771    },
772    Endpoint {
773        method: "POST",
774        path: "/api/appeal",
775        purpose: "Record an appeal for a rejected newcomer attempt; requests operator adjudication and never changes privilege",
776        mcp: Some(McpTool {
777            name: "choir_appeal",
778            input_schema: APPEAL_MCP_SCHEMA,
779            arguments: McpArguments::Body,
780        }),
781    },
782    Endpoint {
783        method: "GET",
784        path: "/api/log?from=N",
785        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",
786        mcp: Some(McpTool {
787            name: "choir_log",
788            input_schema: LOG_MCP_SCHEMA,
789            arguments: McpArguments::Query { parameters: &["from"] },
790        }),
791    },
792    Endpoint {
793        method: "POST",
794        path: "/api/workspace",
795        purpose: "Provision a CoW workspace; optional exact base/change binding makes retries idempotent",
796        mcp: Some(McpTool {
797            name: "choir_workspace",
798            input_schema: WORKSPACE_MCP_SCHEMA,
799            arguments: McpArguments::Body,
800        }),
801    },
802    Endpoint {
803        method: "POST",
804        path: "/api/workspace/archive",
805        purpose: "Recoverably archive a change-bound workspace and remove it from the active view",
806        mcp: Some(McpTool {
807            name: "choir_workspace_archive",
808            input_schema: WORKSPACE_ARCHIVE_MCP_SCHEMA,
809            arguments: McpArguments::Body,
810        }),
811    },
812    Endpoint {
813        method: "GET",
814        path: "/api/reviews?reviewer=X",
815        purpose: "One actor's pending review queue",
816        mcp: Some(McpTool {
817            name: "choir_reviews",
818            input_schema: REVIEWS_MCP_SCHEMA,
819            arguments: McpArguments::Query {
820                parameters: &["reviewer", "limit", "offset"],
821            },
822        }),
823    },
824    Endpoint {
825        method: "GET",
826        path: "/api/schema",
827        purpose: "This surface, machine-readable and versioned, plus what this node will accept; the description an agent generates a client from (D17)",
828        // A tool like any other: "what will this node accept" is a
829        // question an agent asks before branching, and routing it
830        // through the same authenticated client is what keeps a
831        // credential off a `curl` command line in the shell library.
832        mcp: Some(McpTool {
833            name: "choir_schema",
834            input_schema: EMPTY_MCP_SCHEMA,
835            arguments: McpArguments::Empty,
836        }),
837    },
838    Endpoint {
839        method: "GET",
840        path: "/api/search?q=X&in=code&repo=owner/name&rev=R&limit=N",
841        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",
842        mcp: Some(McpTool {
843            name: "choir_search",
844            input_schema: SEARCH_MCP_SCHEMA,
845            arguments: McpArguments::Query {
846                parameters: &["q", "in", "repo", "rev", "limit"],
847            },
848        }),
849    },
850    Endpoint {
851        method: "GET",
852        path: "/api/profile?channel=X",
853        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",
854        mcp: Some(McpTool {
855            name: "choir_profile",
856            input_schema: PROFILE_MCP_SCHEMA,
857            arguments: McpArguments::Query {
858                parameters: &["channel"],
859            },
860        }),
861    },
862    Endpoint {
863        method: "GET",
864        path: "/llms.txt",
865        purpose: "This surface, as text, for an agent that has never seen choir",
866        mcp: None,
867    },
868    Endpoint {
869        method: "GET",
870        path: "/sync.md",
871        purpose: "The sync contract: cursor semantics and how to verify a page's hash chain and author signatures",
872        mcp: None,
873    },
874    Endpoint {
875        method: "GET",
876        path: "/api/repos",
877        purpose: "Which repositories this credential can see, read from the filesystem; `narrowed` says whether an ACL was applied",
878        mcp: None,
879    },
880    Endpoint {
881        method: "POST",
882        path: "/api/repo",
883        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",
884        mcp: None,
885    },
886    Endpoint {
887        method: "GET",
888        path: "/api/ref-agreement",
889        purpose: "Where the op log and the bare repos disagree about a ref, read-only",
890        mcp: None,
891    },
892    Endpoint {
893        method: "POST",
894        path: "/api/accounts/invite",
895        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)",
896        mcp: None,
897    },
898    Endpoint {
899        method: "POST",
900        path: "/api/accounts/redeem",
901        purpose: "Redeem an invite, presented as the credential, for a token, once, and register an ssh key",
902        mcp: None,
903    },
904    Endpoint {
905        method: "POST",
906        path: "/api/accounts/request/grant",
907        purpose: "Answer an access request (D72): turns it into an invite under the id and secret the asker already holds",
908        mcp: None,
909    },
910    Endpoint {
911        method: "POST",
912        path: "/api/accounts/request/decline",
913        purpose: "Drop a pending access request; their link then reads as never valid",
914        mcp: None,
915    },
916    Endpoint {
917        method: "POST",
918        path: "/api/accounts/revoke",
919        purpose: "Delete an account: its token stops authenticating on the next request, and its grants and keys go with it",
920        mcp: None,
921    },
922    Endpoint {
923        method: "GET",
924        path: "/api/accounts",
925        purpose: "Who holds an account, what they were granted, and which invites are outstanding; never a secret or its hash",
926        mcp: None,
927    },
928    Endpoint {
929        method: "POST",
930        path: "/api/git-update",
931        purpose: "Internal: the pre-receive hook callback",
932        mcp: None,
933    },
934    Endpoint {
935        method: "POST",
936        path: "/api/git-abort",
937        purpose: "Internal: retracts a refused push's already-accepted refs",
938        mcp: None,
939    },
940];
941
942/// MCP tools in deterministic endpoint-table order.
943///
944/// The order is deliberately not sorted at runtime: stable tool order
945/// improves client prompt-cache hits, and table order is the one source
946/// shared with the reference page and the discovery documents.
947#[must_use]
948pub fn mcp_tools() -> Vec<serde_json::Value> {
949    ENDPOINTS
950        .iter()
951        .filter_map(|endpoint| {
952            let tool = endpoint.mcp.as_ref()?;
953            let schema: serde_json::Value =
954                serde_json::from_str(tool.input_schema).expect("static MCP schema is valid JSON");
955            Some(serde_json::json!({
956                "name": tool.name,
957                "description": endpoint.purpose,
958                "inputSchema": schema,
959            }))
960        })
961        .collect()
962}
963
964/// Finds the HTTP endpoint backing an MCP tool.
965#[must_use]
966pub fn mcp_endpoint(name: &str) -> Option<&'static Endpoint> {
967    ENDPOINTS
968        .iter()
969        .find(|endpoint| endpoint.mcp.as_ref().is_some_and(|tool| tool.name == name))
970}
971
972/// Finds an HTTP endpoint by method and path.
973///
974/// Separate from [`mcp_endpoint`] because a few endpoints are
975/// deliberately outside the MCP surface — `GET /api/accounts` is the
976/// roster, which is the operator's to read and not an agent tool — and
977/// the CLI still has to reach them. Looking them up here rather than
978/// spelling a path into a command keeps the table the one description of
979/// what this node serves.
980#[must_use]
981pub fn endpoint(method: &str, path: &str) -> Option<&'static Endpoint> {
982    ENDPOINTS
983        .iter()
984        .find(|endpoint| endpoint.method == method && endpoint.path == path)
985}
986
987/// The `choir` usage block, as a bare invocation prints it.
988///
989/// Names and one-line summaries only, grouped. The full argument spec of
990/// a command is a line of its own and there are thirty-two of them; put
991/// them all here and the reader scans a wall of `<api> <key-file>` for
992/// the one word they came for. `choir <command> --help` prints the spec
993/// for one command, which is the question anybody actually has.
994#[must_use]
995pub fn usage() -> String {
996    usage_in(Style::plain())
997}
998
999/// [`usage`], styled for a terminal.
1000///
1001/// One renderer, two callers: the plain form is what the tests read,
1002/// and a second copy of this layout would be a second place for the
1003/// index to be wrong.
1004#[must_use]
1005pub fn usage_in(style: Style) -> String {
1006    let width = COMMANDS.iter().map(|c| c.name.len()).max().unwrap_or(0);
1007    let mut out = format!("{}\n  choir <command> [args]\n", style.bold("usage:"));
1008    out.push_str(&format!("  choir {AUTH_OPTIONS} <command> [args]\n"));
1009    out.push_str("  choir <command> --help\n");
1010    for group in GROUPS {
1011        out.push_str(&format!("\n{}\n", style.bold(group)));
1012        for c in COMMANDS.iter().filter(|c| &c.group == group) {
1013            // First clause only. A summary here earns one line, and the
1014            // clauses after the first are the caveats -- which belong on
1015            // the command's own help, next to the argument they qualify.
1016            let short = first_clause(c.summary, 58);
1017            // Padded before painting: an escape sequence has width in
1018            // bytes and none on screen, so `{:width$}` over a painted
1019            // name indents every line differently.
1020            let name = format!("{:width$}", c.name);
1021            out.push_str(&format!("  {}  {}\n", style.cyan(&name), style.dim(&short)));
1022        }
1023    }
1024    out.push_str(&format!(
1025        "\n{}\n",
1026        style.dim(
1027            "Most commands take the node's URL as their first argument, and fill it in \n\
1028             when you leave it out. It is looked for as `node = <url>` in `.choir/config` \n\
1029             in this directory, then in each directory above it, then in `~/.choir/config` \n\
1030             -- so a checkout that names its own node wins, and the one `choir join` \n\
1031             wrote answers everywhere else. An explicit URL always wins over both."
1032        )
1033    ));
1034    out.push_str(&format!(
1035        "\n{}\n",
1036        style.dim(
1037            "Exit codes: 0 accepted, 1 the node rejected (its JSON error body is \
1038             printed), 2 usage error."
1039        )
1040    ));
1041    out
1042}
1043
1044/// A summary's opening clause, cut to `max` on a word boundary.
1045///
1046/// The index has one line per command and the summaries are written as
1047/// several clauses, so an uncut one wraps and the list stops being a
1048/// list. What is cut is always available: `choir <command> --help`
1049/// prints the whole thing.
1050fn first_clause(summary: &str, max: usize) -> String {
1051    let clause = summary.split(';').next().unwrap_or(summary).trim();
1052    if clause.chars().count() <= max {
1053        return clause.to_string();
1054    }
1055    let mut cut = String::new();
1056    for word in clause.split_whitespace() {
1057        // +1 for the space, +1 for the ellipsis that will follow.
1058        if cut.chars().count() + word.chars().count() + 2 > max {
1059            break;
1060        }
1061        if !cut.is_empty() {
1062            cut.push(' ');
1063        }
1064        cut.push_str(word);
1065    }
1066    // Trailing punctuation before an ellipsis reads as a typo.
1067    while cut.ends_with(',') || cut.ends_with('—') || cut.ends_with('-') {
1068        cut.pop();
1069        cut = cut.trim_end().to_string();
1070    }
1071    format!("{cut}…")
1072}
1073
1074/// The help for one command: its full spec and its whole summary.
1075#[must_use]
1076pub fn command_help(name: &str) -> Option<String> {
1077    command_help_in(name, Style::plain())
1078}
1079
1080/// [`command_help`], styled for a terminal.
1081#[must_use]
1082pub fn command_help_in(name: &str, style: Style) -> Option<String> {
1083    let c = COMMANDS.iter().find(|c| c.name == name)?;
1084    let mut out = format!("  choir {} {}\n\n", style.cyan(c.name), style.dim(c.args));
1085    // The summary's clauses, one per line. They are written as one
1086    // sentence of several clauses, and read far better as a short list
1087    // than as a paragraph wrapped by the terminal.
1088    for (n, clause) in c.summary.split(';').enumerate() {
1089        let clause = clause.trim();
1090        if n == 0 {
1091            out.push_str(&format!("  {clause}\n"));
1092        } else {
1093            out.push_str(&format!("    - {clause}\n"));
1094        }
1095    }
1096    if c.args.starts_with("<api>") {
1097        out.push_str(&format!(
1098            "\n  {}\n",
1099            style.dim(
1100                "<api> may be omitted when `.choir/config` names a node, here or in\n  \
1101                 any parent directory."
1102            )
1103        ));
1104    }
1105    Some(out)
1106}
1107
1108/// The endpoint table `docs/using/cli.md` carries.
1109#[must_use]
1110pub fn api_table() -> String {
1111    let mut out = String::from("| Endpoint | Purpose |\n|---|---|\n");
1112    for e in ENDPOINTS {
1113        out.push_str(&format!("| `{} {}` | {} |\n", e.method, e.path, e.purpose));
1114    }
1115    out
1116}
1117
1118/// The generated API and CLI reference in `docs/using/cli.md`.
1119///
1120/// Named for the document it fills rather than for the README, which is
1121/// where it used to live. The README now carries [`readme_cheatsheet`]
1122/// instead: a reader arriving at a repository wants the shortest path to
1123/// a first change, and thirty-two full argument specs is not it.
1124#[must_use]
1125pub fn cli_doc_surface() -> String {
1126    format!(
1127        "### HTTP endpoints\n\n{}\n### The `choir` CLI\n\n{}",
1128        api_table(),
1129        cli_reference()
1130    )
1131}
1132
1133/// The commands a first contribution needs, in reading order.
1134///
1135/// `key` and `join` are alternatives rather than steps: an operator
1136/// mints a key, somebody holding an invite runs `join` and gets one.
1137/// The rest are the loop.
1138///
1139/// A list of names rather than a `day_one` field on [`Command`],
1140/// deliberately, and the argument cuts the other way from the one
1141/// [`Command::group`] makes. Every command must belong to *some* help
1142/// section, so a field is right there and the compiler should ask. No
1143/// command has to be on a getting-started path, so a new one is
1144/// presumptively absent, and a field would ask thirty-two questions
1145/// whose answer is `false`.
1146///
1147/// Checked against [`COMMANDS`] by the surface test: a name here that no
1148/// longer exists fails rather than silently rendering nothing.
1149pub const DAY_ONE: &[&str] = &[
1150    "host",
1151    "key",
1152    "join",
1153    "workspace",
1154    "propose",
1155    "reviews",
1156    "verdict",
1157    "state",
1158    "log",
1159];
1160
1161/// The README's cheat-sheet: the day-one commands and nothing else.
1162///
1163/// Generated rather than hand-written for the reason every other table
1164/// here is. A short list beside a complete one is the drift this module
1165/// exists to stop, and a cheat-sheet is exactly the kind of document
1166/// that gets written once and then quietly stops being true.
1167#[must_use]
1168pub fn readme_cheatsheet() -> String {
1169    let mut out = String::from("| Command | What it does |\n|:--|:--|\n");
1170    for name in DAY_ONE {
1171        if let Some(c) = COMMANDS.iter().find(|c| &c.name == name) {
1172            // First clause only, for the reason `usage_in` takes it: the
1173            // clauses after the first are caveats, and a caveat in a
1174            // table cell wraps the row to three lines and stops the
1175            // table being scannable. This is the shortest of the three
1176            // renderings and the one read first, so it is the one that
1177            // can least afford them.
1178            let short = first_clause(c.summary, 64);
1179            out.push_str(&format!("| `choir {}` | {} |\n", c.name, short));
1180        }
1181    }
1182    out.push_str("\nFull surface, every command and every endpoint: [`docs/using/cli.md`](docs/using/cli.md).\n");
1183    out
1184}
1185
1186/// The command reference in `docs/using/cli.md`: every command, its full
1187/// argument spec, and what it is for.
1188///
1189/// Not [`usage`]. Help is an index — the reader is at a prompt and wants
1190/// the name of the thing, and thirty-two full argument specs is what
1191/// they have to read past to find it. A reference page is the opposite
1192/// situation: the reader is already looking the command up, and the
1193/// specs are the reason they came. Rendering both from the same table
1194/// keeps them from disagreeing without pretending they answer the same
1195/// question.
1196///
1197/// [`readme_cheatsheet`] is the third answer to the same table, for the
1198/// third reader: somebody deciding whether to try this at all.
1199#[must_use]
1200pub fn cli_reference() -> String {
1201    let mut out = String::new();
1202    for group in GROUPS {
1203        out.push_str(&format!("**{group}**\n\n"));
1204        for c in COMMANDS.iter().filter(|c| &c.group == group) {
1205            out.push_str(&format!(
1206                "- `choir {} {}`  \n  {}\n",
1207                c.name, c.args, c.summary
1208            ));
1209        }
1210        out.push('\n');
1211    }
1212    out.push_str(
1213        "Most commands take the node's URL first. Put `node = <url>` in `.choir/config`, \
1214         in the working directory or any parent, and it is filled in when omitted. \
1215         `choir <command> --help` prints one command's spec.\n\n\
1216         Exit codes: 0 accepted, 1 the node rejected (its JSON error body is printed), \
1217         2 usage error.\n",
1218    );
1219    out
1220}
1221
1222/// The command list the agent templates carry, as a markdown bullet list.
1223#[must_use]
1224pub fn command_bullets() -> String {
1225    let mut out = format!(
1226        "For an authenticated node, place `{AUTH_OPTIONS}` before the subcommand. \
1227         Credentials are read from the named file, never an environment variable.\n\n"
1228    );
1229    for c in COMMANDS.iter().filter(|c| c.agent_facing) {
1230        out.push_str(&format!("- `choir {} {}`: {}\n", c.name, c.args, c.summary));
1231    }
1232    out
1233}
1234
1235/// `AGENTS.md`: the generated choir reference for coding agents.
1236#[must_use]
1237pub fn agents_md() -> String {
1238    format!(
1239        "# choir, for agents\n\n\
1240         Generated from `crates/choir-cli/src/surface.rs`. Do not edit; edit the table.\n\n\
1241         choir is an agent-first code collaboration platform. Many agents work on one \
1242         repository at once, a single-writer sequencer puts every change in one total order, \
1243         and merge conflicts are first-class values rather than errors.\n\n\
1244         ## Use the signed-op API, not `git push`\n\n\
1245         `git push` works and is the compatibility path. The signed-operation API is the \
1246         primary agent path: faster, carries your identity, and says what you are doing. \
1247         For more than one change, use `POST /api/submit-batch`, not a loop over \
1248         `POST /api/submit`: a batch is one durability barrier.\n\n\
1249         ## Commands\n\n{}\n\
1250         ## Endpoints\n\n{}\n\
1251         ## Conventions that are not obvious\n\n\
1252         - A conflict is a committed value, not a failure. Commit it and resolve in a \
1253         follow-up.\n\
1254         - Request review naming no reviewers; the node draws them. A review with no \
1255         reviewers never counts as approved.\n\
1256         - Channel names are `operator/agent`. Agents sharing an operator prefix cannot \
1257         review each other.\n\
1258         - Publish your task spec with `choir intent` when you pick up work; update it when \
1259         scope changes.\n\
1260         - Commit and push the git object before `choir checkpoint`; the checkpoint does not \
1261         transfer workspace-local objects.\n\
1262         - Secrets live under `~/.choir/`. Never write one into the repository.\n",
1263        command_bullets(),
1264        api_table()
1265    )
1266}
1267
1268/// `llms.txt`, served by the node: the same surface, compact, no markdown
1269/// tables — a plain list survives a small context window better.
1270#[must_use]
1271pub fn llms_txt() -> String {
1272    let mut out = String::from(
1273        "# choir\n\n\
1274         Agent-first code collaboration. One total order from a single-writer sequencer; \
1275         conflicts are values, not errors.\n\n\
1276         Use POST /api/submit-batch for more than one change: a batch is one durability \
1277         barrier, a loop is one per operation. git push is the compatibility path.\n\n\
1278         ## Endpoints\n",
1279    );
1280    for e in ENDPOINTS {
1281        out.push_str(&format!("{} {} - {}\n", e.method, e.path, e.purpose));
1282    }
1283    // What a denial means, because the two statuses carry different
1284    // instructions and neither is worth retrying (D29).
1285    out.push_str(
1286        "\n## Access\n\
1287         404 on a repository means no read grant, and says nothing about whether it exists; \
1288         403 means read but not write, or the operation needs a node-wide grant. Neither is \
1289         retryable: ask the operator for a grant line.\n",
1290    );
1291    out.push_str("\n## CLI\n");
1292    out.push_str(&format!(
1293        "For authenticated nodes: choir {AUTH_OPTIONS} <command> ...\n"
1294    ));
1295    for c in COMMANDS {
1296        out.push_str(&format!("choir {} {} - {}\n", c.name, c.args, c.summary));
1297    }
1298    out
1299}
1300
1301/// The three commands a newcomer runs, as an HTML fragment the node
1302/// serves on its contribute page.
1303///
1304/// Generated rather than written into `browse.rs` for the reason
1305/// `llms.txt` is: a page that tells a newcomer which flag to pass is the
1306/// worst place in the system for a stale signature, because its whole
1307/// readership is people with no way to tell it is wrong. It lands in
1308/// [`artifacts`], so the one staleness test that covers `--help` and the
1309/// templates covers this too.
1310///
1311/// `NODE` and `REPO` are placeholders the node substitutes for its own
1312/// base URL and the repository being read. They are spelled in capitals
1313/// so that a page which somehow escapes substitution reads as obviously
1314/// unfinished rather than as an address somebody might try.
1315#[must_use]
1316pub fn contribute_html() -> String {
1317    // Pulled from the table by name, so removing or renaming a command
1318    // breaks the build here instead of quietly emptying the page.
1319    let find = |name: &str| {
1320        COMMANDS
1321            .iter()
1322            .find(|c| c.name == name)
1323            .unwrap_or_else(|| panic!("`choir {name}` is in the command table"))
1324    };
1325    let mut out = String::new();
1326    for (step, name, what, example) in [
1327        (
1328            "1",
1329            "join",
1330            "Paste the whole link your operator sent you, quotes included. This mints your key, \
1331             stores your token, and points git at that token for this node -- so the clone \
1332             below needs no credential in its URL. There is no registration, and no second \
1333             message to wait for.",
1334            "choir join 'NODE/join?i=…&amp;k=…'",
1335        ),
1336        (
1337            "2",
1338            "git-credential",
1339            "Clone normally. The token stays in the file `choir join` wrote and never enters \
1340             the URL, so it cannot leak through `git remote -v` or a pasted clone line. This \
1341             command is here for anybody who would rather wire that up by hand.",
1342            "git clone NODE/REPO.git",
1343        ),
1344        (
1345            "3",
1346            "propose",
1347            "Commit on a branch as you always would, then run this from inside the checkout, \
1348             with no arguments. It creates the change, pushes it, publishes the revision and \
1349             requests review. Run it again after an amend and it updates the same proposal \
1350             rather than opening a second one -- the branch name is what identifies the \
1351             change.",
1352            "git checkout -b fix-the-thing\n\
1353             git commit -am 'fix the thing'\n\
1354             choir propose",
1355        ),
1356    ] {
1357        let command = find(name);
1358        out.push_str("<li><h3><span class=\"step\">");
1359        out.push_str(step);
1360        out.push_str("</span> <code>choir ");
1361        out.push_str(command.name);
1362        out.push_str("</code></h3><p>");
1363        out.push_str(what);
1364        out.push_str("</p><pre class=\"cmd\">");
1365        // Escaped like the signature below it: an example carrying
1366        // `<your-channel>` would otherwise be parsed as a tag and vanish
1367        // from the page, leaving a command that looks complete and is
1368        // missing its last argument.
1369        out.push_str(&escape_html(example));
1370        out.push_str("</pre><p class=\"muted mono\">choir ");
1371        out.push_str(command.name);
1372        out.push(' ');
1373        out.push_str(&escape_html(command.args));
1374        out.push_str("</p></li>\n");
1375    }
1376    out
1377}
1378
1379/// Minimal HTML escaping for text rendered into the generated fragment.
1380///
1381/// Only the three characters that can end an element or an attribute.
1382/// The inputs are this file's own constants rather than anything a
1383/// request carries, so this is here to keep `<api>` rendering as `<api>`
1384/// rather than disappearing into an unknown tag.
1385fn escape_html(text: &str) -> String {
1386    text.replace('&', "&amp;")
1387        .replace('<', "&lt;")
1388        .replace('>', "&gt;")
1389}
1390
1391/// The machine-readable API description (D17), as pretty JSON.
1392///
1393/// The Code-Mode bet is that an agent writes code against a typed API
1394/// rather than making many tool calls. Whatever language that code is
1395/// eventually written in, it needs one description of the surface that
1396/// cannot drift from the surface — so this is rendered from the same
1397/// table that already renders `--help`, `docs/using/cli.md`,
1398/// `llms.txt`, the three `templates/` snippets and the MCP tool list,
1399/// and lands in
1400/// [`artifacts`] beside them so the one staleness test covers it.
1401///
1402/// **Static facts only.** What a *particular* node will accept —
1403/// accounts, quotas, an ACL, the review gates — varies per deployment
1404/// and cannot be generated, so the node merges a live `capabilities`
1405/// object into this document when it serves it. Putting a runtime fact
1406/// in a committed file would be a lie with a staleness test guarding it.
1407#[must_use]
1408pub fn schema_json() -> String {
1409    let endpoints: Vec<serde_json::Value> = ENDPOINTS
1410        .iter()
1411        .map(|e| {
1412            // The table's `path` carries the query parameter that is part
1413            // of the contract (`/api/log?from=N`), which is right for a
1414            // human reading `llms.txt` and useless to a generator: it
1415            // would have to parse the template back out. So the two are
1416            // split here, from information the table already holds —
1417            // `McpArguments::Query` names the parameters.
1418            let (path, query) = match e.mcp.as_ref().map(|m| m.arguments) {
1419                Some(McpArguments::Query { parameters }) => (
1420                    e.path.split('?').next().unwrap_or(e.path),
1421                    parameters.to_vec(),
1422                ),
1423                _ => (e.path, Vec::new()),
1424            };
1425            serde_json::json!({
1426                "method": e.method,
1427                "path": path,
1428                // Present and empty rather than absent, so a generator
1429                // never has to distinguish "no parameters" from "this
1430                // build did not say".
1431                "query_parameters": query,
1432                // The documented spelling, kept because `llms.txt` and
1433                // `docs/using/cli.md` show it, and a client comparing
1434                // the two should not have to wonder whether they
1435                // disagree.
1436                "documented_as": e.path,
1437                "purpose": e.purpose,
1438                // The stable programmatic name, and the signal that this
1439                // endpoint is one an agent is meant to call at all:
1440                // internal hook endpoints carry neither.
1441                "name": e.mcp.as_ref().map(|m| m.name),
1442                "agent_facing": e.mcp.is_some(),
1443                "input_schema": e.mcp.as_ref().map(|m| {
1444                    serde_json::from_str::<serde_json::Value>(m.input_schema)
1445                        .expect("every input schema in this table is JSON")
1446                }),
1447            })
1448        })
1449        .collect();
1450    let commands: Vec<serde_json::Value> = COMMANDS
1451        .iter()
1452        .map(|c| {
1453            serde_json::json!({
1454                "name": c.name,
1455                "args": c.args,
1456                "summary": c.summary,
1457                "agent_facing": c.agent_facing,
1458            })
1459        })
1460        .collect();
1461    let deprecations: Vec<serde_json::Value> = DEPRECATIONS
1462        .iter()
1463        .map(|(name, replacement, note)| {
1464            serde_json::json!({ "name": name, "replaced_by": replacement, "note": note })
1465        })
1466        .collect();
1467    let doc = serde_json::json!({
1468        "api_version": API_VERSION,
1469        "endpoints": endpoints,
1470        "commands": commands,
1471        "deprecations": deprecations,
1472    });
1473    format!(
1474        "{}\n",
1475        serde_json::to_string_pretty(&doc).expect("the schema is always serializable")
1476    )
1477}
1478
1479/// The generated half of `templates/shell/choir.sh`: one function per
1480/// agent-facing command (D17).
1481///
1482/// Thin on purpose. A wrapper that only forwards its arguments cannot
1483/// drift from the binary, and the arguments it forwards come from the
1484/// same table `--help` prints — so a command renamed here renames the
1485/// shell function in the same commit or the staleness test fails.
1486/// Anything worth more than forwarding is a *flow*, which is judgement
1487/// about order and lives in the hand-written half of that file.
1488///
1489/// `sh` rather than `bash`: the harnesses that will source this run
1490/// whatever `/bin/sh` is, and nothing here needs an array or a
1491/// `[[`-test.
1492#[must_use]
1493pub fn shell_functions() -> String {
1494    let mut out = String::from(
1495        "# One function per agent-facing command, forwarding its arguments\n\
1496         # to the binary. Generated from the same table as `choir --help`;\n\
1497         # edit `crates/choir-cli/src/surface.rs` and regenerate.\n",
1498    );
1499    for c in COMMANDS.iter().filter(|c| c.agent_facing) {
1500        // Shell function names cannot carry a hyphen portably.
1501        let name = c.name.replace('-', "_");
1502        out.push_str(&format!(
1503            "\n# choir {} {}\n#   {}\nchoir_{name}() {{\n\tchoir_run {} \"$@\"\n}}\n",
1504            c.name, c.args, c.summary, c.name
1505        ));
1506    }
1507    out
1508}
1509
1510/// Directory name the agent skill installs under; the skill frontmatter's
1511/// `name:` must equal it, because skill loaders resolve by directory.
1512pub const SKILL_DIR: &str = "choir";
1513
1514/// The installable agent skill.
1515///
1516/// Rendered from the same table as `--help` and `AGENTS.md` at the moment
1517/// of installation, so — unlike docs baked in as static files — the
1518/// installed skill can never describe a different version than the binary
1519/// that wrote it. Re-installing after an upgrade refreshes it.
1520#[must_use]
1521pub fn skill_md() -> String {
1522    format!(
1523        "---\nname: {SKILL_DIR}\ndescription: Drive a choir node — signed operations, \
1524         workspaces, reviews, triage and next actions. Use when working in a repository \
1525         served by a choir node, or when asked to run choir commands.\n---\n\n{}",
1526        agents_md()
1527    )
1528}
1529
1530/// The generated half of `templates/python/choir.py`: one method per
1531/// tool, rendered **from the schema document and nothing else** (D17).
1532///
1533/// This is the point of the artifact rather than an implementation
1534/// detail. D17 bets that an agent writes code against a typed API, and
1535/// `/api/schema` is the description that code would be generated from —
1536/// but a description is only sufficient if something has actually been
1537/// generated from it without reading choir's source. So this takes the
1538/// parsed schema as its argument and never touches [`ENDPOINTS`] or
1539/// [`COMMANDS`]. If a method comes out wrong, the schema is what was
1540/// insufficient, and that is the finding.
1541///
1542/// The shell library is the opposite bargain and both are wanted: it
1543/// wraps the binary and can therefore sign, while this needs no binary
1544/// and therefore cannot.
1545#[must_use]
1546pub fn python_client(schema: &serde_json::Value) -> String {
1547    let mut out = String::from(
1548        "    # One method per tool, from the node's own description.\n\
1549         \x20   # Generated; edit `crates/choir-cli/src/surface.rs`.\n",
1550    );
1551    let endpoints = schema["endpoints"].as_array().cloned().unwrap_or_default();
1552    for endpoint in endpoints {
1553        // Only the tools: an endpoint with no name is one the schema
1554        // marks as not agent-facing, and a generated client offering it
1555        // would be offering something the description says not to call.
1556        let Some(name) = endpoint["name"].as_str() else {
1557            continue;
1558        };
1559        let method = endpoint["method"].as_str().unwrap_or("GET");
1560        let path = endpoint["path"].as_str().unwrap_or_default();
1561        let purpose = endpoint["purpose"].as_str().unwrap_or_default();
1562        let query: Vec<&str> = endpoint["query_parameters"]
1563            .as_array()
1564            .map(|values| values.iter().filter_map(|v| v.as_str()).collect())
1565            .unwrap_or_default();
1566
1567        // The schema says whether a tool takes anything at all. An
1568        // endpoint whose input schema has no properties gets a method
1569        // with no parameters, rather than one that accepts arguments and
1570        // drops them — which is what the first generated copy did, and
1571        // reading it is how that was found.
1572        let takes_arguments = endpoint["input_schema"]["properties"]
1573            .as_object()
1574            .is_some_and(|properties| !properties.is_empty());
1575
1576        // A query parameter is a keyword argument; a body is one dict.
1577        // `from` is a Python keyword, so every argument arrives through
1578        // `**kwargs` rather than a signature this generator would have
1579        // to escape — the schema names the parameters and the docstring
1580        // repeats them.
1581        if takes_arguments {
1582            out.push_str(&format!("\n    def {name}(self, **arguments):\n"));
1583        } else {
1584            out.push_str(&format!("\n    def {name}(self):\n"));
1585        }
1586        out.push_str(&format!("        \"\"\"{}\n\n", wrap_python_doc(purpose)));
1587        if !takes_arguments {
1588            out.push_str("        Takes no arguments.\n");
1589        } else if query.is_empty() {
1590            out.push_str("        Arguments become the JSON request body.\n");
1591        } else {
1592            out.push_str(&format!(
1593                "        Arguments become the query string: {}.\n",
1594                query.join(", ")
1595            ));
1596        }
1597        out.push_str("        \"\"\"\n");
1598        if !takes_arguments {
1599            out.push_str(&format!(
1600                "        return self._request(\"{method}\", \"{path}\")\n"
1601            ));
1602        } else if query.is_empty() {
1603            out.push_str(&format!(
1604                "        return self._request(\"{method}\", \"{path}\", body=arguments)\n"
1605            ));
1606        } else {
1607            out.push_str(&format!(
1608                "        return self._request(\"{method}\", \"{path}\", query=arguments)\n"
1609            ));
1610        }
1611    }
1612    out
1613}
1614
1615/// Reflows one purpose line into an indented Python docstring body.
1616fn wrap_python_doc(text: &str) -> String {
1617    let mut out = String::new();
1618    let mut column = 0;
1619    for word in text.split_whitespace() {
1620        if column + word.len() > 64 && column > 0 {
1621            out.push_str("\n        ");
1622            column = 0;
1623        } else if column > 0 {
1624            out.push(' ');
1625            column += 1;
1626        }
1627        out.push_str(word);
1628        column += word.len();
1629    }
1630    out
1631}
1632
1633/// Replaces the region between [`GEN_START`] and [`GEN_END`] in `doc`.
1634///
1635/// # Errors
1636///
1637/// Returns a description when the markers are missing or out of order,
1638/// rather than appending and quietly producing two generated regions.
1639pub fn splice(doc: &str, generated: &str) -> Result<String, String> {
1640    splice_between(doc, generated, GEN_START, GEN_END)
1641}
1642
1643/// [`splice`] with explicit markers, for a file whose comment syntax is
1644/// not HTML.
1645///
1646/// # Errors
1647///
1648/// Same as [`splice`]: a missing or out-of-order marker pair.
1649pub fn splice_between(
1650    doc: &str,
1651    generated: &str,
1652    start_marker: &str,
1653    end_marker: &str,
1654) -> Result<String, String> {
1655    let start = doc
1656        .find(start_marker)
1657        .ok_or("missing generated-start marker")?;
1658    let end = doc.find(end_marker).ok_or("missing generated-end marker")?;
1659    if end < start {
1660        return Err("generated markers are out of order".to_string());
1661    }
1662    Ok(format!(
1663        "{}{}\n\n{}\n{}",
1664        &doc[..start],
1665        start_marker,
1666        generated.trim_end(),
1667        &doc[end..]
1668    ))
1669}
1670
1671/// Every artifact rendered from this table, as `(path, full contents)`,
1672/// relative to the repository `root`.
1673///
1674/// Returned rather than written so the generator and the staleness test
1675/// share one definition of what exists — a test that enumerated the
1676/// artifacts separately would pass while missing a new one.
1677///
1678/// # Errors
1679///
1680/// A file that should carry generated markers and does not, or cannot be
1681/// read.
1682pub fn artifacts(root: &std::path::Path) -> Result<Vec<(std::path::PathBuf, String)>, String> {
1683    let read = |rel: &str| -> Result<String, String> {
1684        std::fs::read_to_string(root.join(rel)).map_err(|e| format!("{rel}: {e}"))
1685    };
1686    let mut out = vec![
1687        (root.join("AGENTS.md"), agents_md()),
1688        (root.join("crates/choir-node/src/llms.txt"), llms_txt()),
1689        (
1690            root.join("crates/choir-node/src/contribute.html"),
1691            contribute_html(),
1692        ),
1693        (
1694            root.join("crates/choir-node/src/schema.json"),
1695            schema_json(),
1696        ),
1697        // Owned by choir-node's reject module, generated here so one
1698        // staleness test covers every generated artifact rather than two
1699        // tests each covering half.
1700        (root.join("ERRORS.md"), choir_node::reject::errors_md()),
1701        // The book's palette, cut from the daemon's own stylesheet, so
1702        // the documentation and the product cannot drift apart in
1703        // colour, type scale or spacing. Same reason it lives here: one
1704        // staleness test over every generated artifact.
1705        (
1706            root.join("theme/choir-tokens.css"),
1707            choir_node::ui_tokens_css(),
1708        ),
1709        // Two documents, two audiences, one table. `docs/using/cli.md`
1710        // gets the complete surface; the README gets the eight commands
1711        // a first change needs. Both are generated so neither can drift
1712        // from the other.
1713        (
1714            root.join("docs/using/cli.md"),
1715            splice(&read("docs/using/cli.md")?, &cli_doc_surface())
1716                .map_err(|e| format!("docs/using/cli.md: {e}"))?,
1717        ),
1718        (
1719            root.join("README.md"),
1720            splice(&read("README.md")?, &readme_cheatsheet())
1721                .map_err(|e| format!("README.md: {e}"))?,
1722        ),
1723    ];
1724    out.push((
1725        root.join("templates/python/choir.py"),
1726        splice_between(
1727            &read("templates/python/choir.py")?,
1728            // Fed the rendered schema, not the table it came from: a
1729            // client generated from the description is the only thing
1730            // that shows the description is sufficient.
1731            &python_client(
1732                &serde_json::from_str(&schema_json()).expect("the schema we just rendered is JSON"),
1733            ),
1734            SH_GEN_START,
1735            SH_GEN_END,
1736        )
1737        .map_err(|e| format!("templates/python/choir.py: {e}"))?,
1738    ));
1739    out.push((
1740        root.join("templates/shell/choir.sh"),
1741        splice_between(
1742            &read("templates/shell/choir.sh")?,
1743            &shell_functions(),
1744            SH_GEN_START,
1745            SH_GEN_END,
1746        )
1747        .map_err(|e| format!("templates/shell/choir.sh: {e}"))?,
1748    ));
1749    for rel in [
1750        "templates/claude-code/CLAUDE.snippet.md",
1751        "templates/codex/AGENTS.snippet.md",
1752        "templates/cursor/choir.mdc",
1753    ] {
1754        out.push((
1755            root.join(rel),
1756            splice(&read(rel)?, &command_bullets()).map_err(|e| format!("{rel}: {e}"))?,
1757        ));
1758    }
1759    Ok(out)
1760}