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>]",
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 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: "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 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
737pub 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 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#[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#[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#[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#[must_use]
995pub fn usage() -> String {
996 usage_in(Style::plain())
997}
998
999#[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 let short = first_clause(c.summary, 58);
1017 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
1044fn 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 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 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#[must_use]
1076pub fn command_help(name: &str) -> Option<String> {
1077 command_help_in(name, Style::plain())
1078}
1079
1080#[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 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#[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#[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
1133pub 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#[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 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#[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#[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#[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#[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 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#[must_use]
1316pub fn contribute_html() -> String {
1317 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=…&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 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
1379fn escape_html(text: &str) -> String {
1386 text.replace('&', "&")
1387 .replace('<', "<")
1388 .replace('>', ">")
1389}
1390
1391#[must_use]
1408pub fn schema_json() -> String {
1409 let endpoints: Vec<serde_json::Value> = ENDPOINTS
1410 .iter()
1411 .map(|e| {
1412 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 "query_parameters": query,
1432 "documented_as": e.path,
1437 "purpose": e.purpose,
1438 "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#[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 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
1510pub const SKILL_DIR: &str = "choir";
1513
1514#[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#[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 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 let takes_arguments = endpoint["input_schema"]["properties"]
1573 .as_object()
1574 .is_some_and(|properties| !properties.is_empty());
1575
1576 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
1615fn 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
1633pub fn splice(doc: &str, generated: &str) -> Result<String, String> {
1640 splice_between(doc, generated, GEN_START, GEN_END)
1641}
1642
1643pub 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
1671pub 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 (root.join("ERRORS.md"), choir_node::reject::errors_md()),
1701 (
1706 root.join("theme/choir-tokens.css"),
1707 choir_node::ui_tokens_css(),
1708 ),
1709 (
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 &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}