/** * `cotal docs [page]`: the installed version's own protocol and CLI docs, offline. It reads the docs * bundle that ships in `@cotal-ai/connector-core`, the one `cotal_docs` serves agents. * * Registered here, in the composition root, because the CLI package must not depend on an extension. * The bundle is imported inside `run` through its narrow subpath, so no other command loads it. */ import { registry, type Command, type CompletionResult, type FlagSpec, type FlagValues, type ParsedArgs } from "@cotal-ai/core"; import { CommandFailure, jsonFlag, plainFlag, presentationOf, writeResult, type Cell, type Report } from "@cotal-ai/workspace"; import type { Block, Presentation, Section } from "@cotal-ai/workspace"; const searchFlag = { name: "search", type: "string", value: "", description: "show the sections that best match a query" } as const; const refreshFlag = { name: "refresh", type: "boolean", description: "also try the version-pinned copy of the page on docs.cotal.ai" } as const; export const docsFlags = [searchFlag, refreshFlag, jsonFlag, plainFlag] as const satisfies readonly FlagSpec[]; const usage = (message: string): CommandFailure => new CommandFailure("refused", "USAGE_INVALID", message); type Docs = typeof import("@cotal-ai/connector-core/docs"); /** Every page name `docs ` reads. */ const pageSlugs = (d: Docs): string[] => ["spec", "lang", "schema", ...d.docsIndex().pages.map((p) => p.slug)]; interface DocsIndexResult extends ReturnType { readonly kind: "docs-index"; } interface DocsPageResult { readonly kind: "docs-page"; readonly version: string; readonly slug: string; readonly title: string; readonly source: "bundled" | "docs.cotal.ai"; readonly refresh: "served" | "not-published" | "unreachable" | null; readonly body: string; } interface DocsSearchResult { readonly kind: "docs-search"; readonly version: string; readonly query: string; readonly hits: readonly { readonly slug: string; readonly page: string; readonly heading: string; readonly text: string }[]; } async function docs(args: ParsedArgs): Promise { const v = args.values as FlagValues; const presentation = presentationOf(v); if (args.positionals.length > 1) throw usage("docs takes at most one page"); const page = args.positionals[0]; const query = v.search?.trim(); if (v.search !== undefined && !query) throw usage("--search takes a query"); if (page !== undefined && query !== undefined) throw usage("give a page or --search, not both"); if (v.refresh && page === undefined) throw usage("--refresh applies to reading a page; the index and search use the bundled docs"); const d: Docs = await import("@cotal-ai/connector-core/docs"); if (page !== undefined) { const read = await d.readDocsPage(page, { refresh: v.refresh }); if (!read) { throw usage(`no docs page "${page}"; pages: ${pageSlugs(d).join(", ")}`); } const result: DocsPageResult = { kind: "docs-page", version: read.version, slug: read.page.slug, title: read.page.title, source: read.source, refresh: read.refresh, body: read.page.body }; writeResult(presentation, { command: "docs", target: null, result }, (r) => pageView(r, presentation)); return; } if (query !== undefined) { const hits = d.searchDocs(query).map((h) => ({ slug: h.slug, page: h.pageTitle, heading: h.heading, text: d.capSection(h.text) })); const result: DocsSearchResult = { kind: "docs-search", version: d.docsIndex().version, query, hits }; writeResult(presentation, { command: "docs", target: null, result }, searchView); return; } const result: DocsIndexResult = { kind: "docs-index", ...d.docsIndex() }; writeResult(presentation, { command: "docs", target: null, result }, indexView); } function indexView(r: DocsIndexResult): Report { const columns = [{ key: "slug", label: "PAGE" }, { key: "title", label: "TITLE" }]; const table = (pages: readonly { slug: string; title: string }[]) => ({ kind: "table" as const, columns, rows: pages.map((p) => ({ slug: { text: p.slug, tone: "command" as const }, title: p.title })) }); const groups = new Map(); for (const p of r.pages) groups.set(p.category, [...(groups.get(p.category) ?? []), p]); return { sections: [ { title: `Cotal v${r.version} documentation`, blocks: [{ kind: "lines", lines: [{ text: "Bundled with this installed version and readable offline.", tone: "hint" }] }] }, { title: "Normative sources", blocks: [table(r.sources)] }, ...[...groups].map(([title, pages]) => ({ title, blocks: [table(pages)] })), { blocks: [{ kind: "lines", lines: [{ text: "Read a page with `cotal docs `; search with `cotal docs --search `.", tone: "hint" }] }] }, ], }; } /** A page drawn for reading on a terminal. To a pipe or a file, and with `--plain`, it is its * Markdown, since output there is never wrapped. */ function pageView(r: DocsPageResult, presentation: Presentation): Report { const note: Cell = r.refresh === null ? { text: `bundled v${r.version}`, tone: "hint" } : r.refresh === "served" ? { text: `refreshed from docs.cotal.ai: the version-pinned copy for v${r.version}`, tone: "hint" } : { text: `bundled v${r.version}: docs.cotal.ai ${r.refresh === "unreachable" ? "did not answer" : `has no version-pinned copy for v${r.version}`}`, tone: "waiting" }; const body: Section[] = presentation === "plain" || !process.stdout.isTTY ? [{ blocks: [{ kind: "lines", lines: r.body.split("\n") }] }] : drawnPage(r.body); return { sections: [{ blocks: [{ kind: "lines", lines: [note] }] }, ...body] }; } /** Markdown inline text as read in a terminal: links keep their destinations and bold markers are * dropped. Code keeps its backticks. */ const inline = (text: string): string => text.replace(/!?\[([^\]]*)\]\(([^)\s]*)\)/g, (_, label: string, url: string) => (url ? `${label} <${url}>` : label)).replace(/\*\*/g, ""); /** A table row's cells; a `\|` inside a cell is a pipe, not a column break. */ const cellsOf = (row: string): string[] => row.trim().replace(/^\|/, "").replace(/\|$/, "").split(/(? inline(cell.trim().replace(/\\\|/g, "|"))); /** A table starts at a row whose next line is the header rule. */ const startsTable = (line: string, next = ""): boolean => line.trim().startsWith("|") && /^\s*\|[\s:|-]+\|?\s*$/.test(next); /** Whether a line starts a block of its own: a heading, a fence, a table, a list item or a quote. */ const startsBlock = (line: string, next = ""): boolean => /^#{1,6} /.test(line) || /^\s*(```|~~~)/.test(line) || startsTable(line, next) || /^\s*([-*+]|\d+[.)]) /.test(line) || line.startsWith(">"); /** * A Markdown page as report sections, so the report fits it to the terminal. Each heading starts a * section. A paragraph, list item or quote is one line the report wraps. A table is a table, which * becomes one record per row when the terminal is too narrow for its columns. A code block keeps its * lines. A blank line in the page is kept between blocks, so a list stays together. */ function drawnPage(body: string): Section[] { const sections: { title?: string; blocks: Block[] }[] = [{ blocks: [] }]; const lines = body.split("\n"); let gap = false; const add = (block: Block) => { const blocks = sections.at(-1)!.blocks; if (gap && blocks.length) blocks.push({ kind: "lines", lines: [""] }); blocks.push(block); gap = false; }; for (let i = 0; i < lines.length; i++) { const line = lines[i]!; const heading = /^#{1,6} +(.*)$/.exec(line); const fence = /^\s*(```|~~~)/.exec(line); if (!line.trim()) gap = true; else if (heading) { sections.push({ title: inline(heading[1]!.trim()), blocks: [] }); gap = false; } else if (fence) { const code: string[] = []; while (++i < lines.length && !lines[i]!.trimStart().startsWith(fence[1]!)) code.push(` ${lines[i]}`); add({ kind: "lines", lines: code.length ? code : [""] }); } else if (startsTable(line, lines[i + 1])) { const columns = cellsOf(line).map((label, c) => ({ key: String(c), label })); const rows: Record[] = []; for (i += 2; i < lines.length && lines[i]!.trim().startsWith("|"); i++) rows.push(Object.fromEntries(cellsOf(lines[i]!).map((cell, c) => [String(c), cell]))); i--; add({ kind: "table", columns, rows }); } else if (line.startsWith(">")) { const parts = [line]; while (lines[i + 1]?.startsWith(">")) parts.push(lines[++i]!); add({ kind: "lines", lines: [{ text: inline(parts.map((part) => part.replace(/^>\s?/, "").trim()).join(" ")), tone: "hint" }] }); } else { // A paragraph or list item runs to a blank line or the next block. const parts = [line.trimEnd()]; while (lines[i + 1]?.trim() && !startsBlock(lines[i + 1]!, lines[i + 2])) parts.push(lines[++i]!.trim()); add({ kind: "lines", lines: [inline(parts.join(" "))] }); } } return sections.filter((section) => section.title !== undefined || section.blocks.length); } /** Each hit drawn as its page is, so a paragraph is one line the report wraps. A hit opens at its own * heading, which the hit's title names with its page. */ function searchView(r: DocsSearchResult): Report { if (!r.hits.length) return { sections: [{ blocks: [{ kind: "lines", lines: [{ text: `no matches for "${r.query}" in the Cotal v${r.version} docs; \`cotal docs\` lists the pages, or search an exact identifier`, tone: "hint" }] }] }] }; return { sections: r.hits.flatMap((h) => { const [first, ...rest] = drawnPage(h.text); const drawn: Section[] = [{ title: h.heading, blocks: first?.blocks ?? [] }, ...rest]; const last = drawn.pop()!; return [...drawn, { ...last, blocks: [...last.blocks, { kind: "lines", lines: [{ text: `read the full page: \`cotal docs ${h.slug}\``, tone: "hint" }] }] }]; }), }; } /** Complete the page name from the bundled index. Its module loads only on this , as in `run`. */ async function docsComplete(argv: string[]): Promise { if (argv.slice(0, -1).some((word) => word === "--search" || !word.startsWith("-"))) return { items: [], directive: "nofiles" }; const index = (await import("@cotal-ai/connector-core/docs")).docsIndex(); return { items: [...index.pages, ...index.sources].map((page) => ({ value: page.slug, description: page.title })), directive: "nofiles" }; } const docsCommand: Command = { kind: "command", name: "docs", group: "Setup", summary: "read this version's protocol and CLI docs, offline", usage: "cotal docs [] [--refresh] [--json|--plain] | cotal docs --search [--json|--plain]", positionals: "[]", flags: docsFlags, outputs: ["json", "plain"], examples: [{ command: "cotal docs", note: "list the docs pages bundled with this version" }, { command: "cotal docs cli", note: "read the CLI reference" }, { command: "cotal docs --search ", note: "find the sections that match a query" }], reads: true, run: docs, complete: docsComplete, }; registry.register(docsCommand);