1pub const GEN_START: &str = "<!-- generated: choir surface, do not edit -->";
29pub const GEN_END: &str = "<!-- /generated -->";
31
32pub const SH_GEN_START: &str = "# --- generated: choir surface, do not edit ---";
40pub const SH_GEN_END: &str = "# --- /generated ---";
42
43pub const AUTH_OPTIONS: &str = "[--auth-file <path>] [--auth-user <name>]";
45
46pub const API_VERSION: u32 = 1;
59
60use crate::style::Style;
61
62pub 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
76pub struct Command {
78 pub name: &'static str,
80 pub args: &'static str,
82 pub summary: &'static str,
84 pub agent_facing: bool,
86 pub group: &'static str,
98}
99
100pub 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
112pub struct Endpoint {
114 pub method: &'static str,
116 pub path: &'static str,
118 pub purpose: &'static str,
120 pub mcp: Option<McpTool>,
123}
124
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub enum McpArguments {
128 Empty,
130 Body,
132 Query {
139 parameters: &'static [&'static str],
141 },
142}
143
144pub struct McpTool {
146 pub name: &'static str,
148 pub input_schema: &'static str,
150 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
259const 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
316pub 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 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 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 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
794pub 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 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#[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#[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#[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#[must_use]
1064pub fn usage() -> String {
1065 usage_in(Style::plain())
1066}
1067
1068#[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 let short = first_clause(c.summary, 58);
1086 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
1113fn 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 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 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#[must_use]
1145pub fn command_help(name: &str) -> Option<String> {
1146 command_help_in(name, Style::plain())
1147}
1148
1149#[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 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#[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#[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
1202pub 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#[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 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#[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#[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#[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#[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 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#[must_use]
1385pub fn contribute_html() -> String {
1386 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=…&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 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
1449fn escape_html(text: &str) -> String {
1456 text.replace('&', "&")
1457 .replace('<', "<")
1458 .replace('>', ">")
1459}
1460
1461#[must_use]
1478pub fn schema_json() -> String {
1479 let endpoints: Vec<serde_json::Value> = ENDPOINTS
1480 .iter()
1481 .map(|e| {
1482 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 "query_parameters": query,
1502 "documented_as": e.path,
1507 "purpose": e.purpose,
1508 "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#[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 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
1580pub const SKILL_DIR: &str = "choir";
1583
1584#[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#[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 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 let takes_arguments = endpoint["input_schema"]["properties"]
1643 .as_object()
1644 .is_some_and(|properties| !properties.is_empty());
1645
1646 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
1685fn 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
1703pub fn splice(doc: &str, generated: &str) -> Result<String, String> {
1710 splice_between(doc, generated, GEN_START, GEN_END)
1711}
1712
1713pub 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
1741pub 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 (root.join("ERRORS.md"), choir_node::reject::errors_md()),
1771 (
1776 root.join("theme/choir-tokens.css"),
1777 choir_node::ui_tokens_css(),
1778 ),
1779 (
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 &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}